- Book: Kotlin and Java to TypeScript — A Bridge for JVM Developers
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A Java engineer joins a TypeScript team. They open the codebase looking for the data classes: the small immutable carriers that hold a request body or a domain value object. In Java they would be records:
public record Money(long amountCents, String currency) {}
That is one line. It buys you a constructor, accessors, equals, hashCode, and toString. The compiler enforces immutability and structural equality. The cost is zero.
The Java engineer types record in TypeScript and gets a red squiggle. There is no such keyword. The language never had one and probably never will. The closest you get is a set of patterns, not a keyword.
What you get instead is three patterns. Each one covers part of what a Java record gives you. None of them covers all of it. Pick the pattern by the part you actually need. Reading closest to record on the page is not the criterion.
What a Java record actually buys you
Before picking a TypeScript shape, list what the Java compiler is doing for you. There are five things, and they are not equally easy to replace:
-
Construction with named components.
new Money(100, "EUR"). -
Read-only access. No setters. The fields are
final. -
Structural equality. Two records with the same components compare equal via
.equals()and hash to the same bucket. - Compiler-enforced shape. You cannot accidentally add a mutable field on the side.
-
Pattern-match exhaustion. With sealed interfaces and records, Java's
switchchecks you handled every case.
TypeScript covers 1, 2, and 4 with a single keyword each. For 3 you write or import a helper. There is no answer for 5.
Pattern 1: interface plus Object.freeze
The lightest replacement. An interface (or type) for the shape and a factory function that calls Object.freeze. A JSDoc comment ties the contract together with a "treat me as a record" note.
export interface Money {
readonly amountCents: number;
readonly currency: string;
}
export function money(
amountCents: number,
currency: string,
): Money {
return Object.freeze({ amountCents, currency });
}
const a = money(100, "EUR");
// a.amountCents = 200; // TS error: readonly
// runtime freeze blocks the mutation too
readonly covers compile time, Object.freeze covers runtime, and the factory call site reads like a constructor. Equality is structural to the type checker, but at runtime two money(100, "EUR") calls produce two different object references. a === b is false.
For most data-bag use cases you do not need runtime equality. You compare a.amountCents === b.amountCents && a.currency === b.currency once, in a domain helper, and move on. If your only consumer is a JSON serializer or a network boundary, stop here.
Where it stops being enough: domain logic that puts records into a Set, uses them as Map keys, or pattern-matches on them inside hot loops. JavaScript Set and Map use reference equality. Two record-shaped values with the same fields are two different keys. That is not a bug you can fix with a freeze.
Pattern 2: class with readonly fields and an equals helper
When you need value equality, reach for a class. Readonly fields, a private constructor (or a public one if you do not need validation), and a static equals you call deliberately give you the building blocks.
export class Money {
constructor(
public readonly amountCents: number,
public readonly currency: string,
) {}
static equals(a: Money, b: Money): boolean {
return (
a.amountCents === b.amountCents &&
a.currency === b.currency
);
}
toString(): string {
return `${this.amountCents} ${this.currency}`;
}
}
const a = new Money(100, "EUR");
const b = new Money(100, "EUR");
Money.equals(a, b); // true
a === b; // false
The class form gives you three things the interface does not:
- An
instanceofcheck at the boundary. Useful when JSON arrives and you want to reject anything that did not pass through your factory. - A natural place to attach domain methods (
add,negate,times) without polluting the type with a separate set of free functions. - A clean target for
JSON.stringifyvia atoJSONmethod, and a clean target for parsing via a staticfrommethod.
The cost is verbosity and the explicit equals call. JavaScript will not call it for you. === still compares references, Set and Map still bucket by reference, and a comparator function has to be passed to anything that sorts. If you forget, you get false negatives (two equal values treated as different) and the bug is silent until it bites in production.
A workable team rule: every value-object class exports a static equals, and every collection that holds them is wrapped in a small domain helper that uses it. The helper is one place to audit. Without the rule, every call site invents its own comparison and one of them gets it subtly wrong.
The class form also does not help you parse. If a record arrives as JSON from a queue or an HTTP body, you still need a validator. That is what pattern 3 is for.
Pattern 3: schema as the record
The heaviest pattern. Define the record as a schema in a runtime-validation library (Zod, Valibot, Effect Schema, ArkType — pick one and stick with it). The schema is the source of truth for both the runtime parser and the static type.
import { z } from "zod";
export const MoneySchema = z.object({
amountCents: z.number().int().nonnegative(),
currency: z.string().length(3),
});
export type Money = z.infer<typeof MoneySchema>;
export function money(input: unknown): Money {
return MoneySchema.parse(input); // throws on invalid
}
const a = money({ amountCents: 100, currency: "EUR" });
const b = MoneySchema.safeParse({ amountCents: -1, currency: "EUR" });
// b.success === false — caught by the schema
The schema buys you parsing for free at the boundary. JSON in, validated record out, with a typed error path on failure. That covers a use case Java records never tried to cover. Java has Jackson for that, and Jackson is not part of the language.
Where the schema pattern wins: any record that crosses a process boundary (HTTP, queue, file, DB row). Pay one parse call at the edge. Inside the system you carry the inferred type. The compiler treats the parsed value as a regular structural type, so you can use it the same way you would the interface in pattern 1.
Where it loses: equality and methods. The schema does not generate an equals. It does not give you a class to attach methods to. You either compose with a class around the schema, or you accept that records-as-schemas are inert data and put behaviour in free functions next to them.
The pattern works best when the record is a pure boundary type and you have other patterns for the inner-domain shapes. It is overkill for a struct that only ever lives inside one module.
A decision matrix
Three patterns, each for a different job. The matrix below is what to reach for first; you can always upgrade later.
| Use case | Reach for |
|---|---|
| Internal data bag, no equality, no parsing | Pattern 1 (interface + freeze) |
| Domain value object, needs equality + methods | Pattern 2 (class + readonly + equals) |
| Boundary type that arrives as JSON | Pattern 3 (schema) |
| Boundary type that needs domain methods | Pattern 3 plus Pattern 2 (class wrapping schema) |
| Compile-time-only types (DTOs, route params) | Pattern 1 |
| Set or Map keys with value semantics | Pattern 2 with a custom collection helper, or a Map<string, T> keyed by a stable string form |
A common shape on real teams: pattern 1 for HTTP DTOs you generate from an OpenAPI doc, pattern 2 for the handful of domain value objects (Money, EmailAddress, UserId, with ten or twenty of them, no more), pattern 3 for every external boundary. Three patterns mapped onto three layers. No fight about which one is "correct".
Where TypeScript still loses to Java records
Two gaps the patterns do not close.
The first is pattern-match exhaustion across record types. In Java, a sealed interface with three record implementations and a switch that handles all three cases is checked by the compiler. If you add a fourth record, the switch fails to compile until you handle it. TypeScript discriminated unions get you most of the way:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "square": return s.side ** 2;
case "triangle": return (s.base * s.height) / 2;
default: {
const _exhaustive: never = s;
throw new Error(`unreachable: ${JSON.stringify(_exhaustive)}`);
}
}
}
The never assignment is the manual exhaustion check. It works, but it is a pattern you have to remember and apply. There is no language feature that warns you when you add pentagon to the union and forget the new case anywhere. ESLint's @typescript-eslint/switch-exhaustiveness-check rule plugs the gap on a per-codebase basis, but it is a lint rule, not a compiler error by default.
The second gap is default value equality. Records in Java compare by component out of the box; in TypeScript you write the comparator. This is not solvable in the language as it stands. Records-as-records (TC39's Records and Tuples proposal) has been at stage 2 for years, and as of 2026 no major engine has shipped it. Until something lands, value equality is a library decision (immer, immutable-js) or a hand-rolled comparator.
Neither gap is a deal-breaker. They are taxes you pay at specific call sites: the exhaustion check at the switch, and the comparator at the equality call. The patterns above cover the rest of what records buy you, and the call-site cost is visible enough that you can audit it.
What to do with this on Monday
Pick one record-shaped concept in your TypeScript code: a DTO, a value object, or a boundary type. Map it onto the matrix:
- If it is a data bag with no equality and no parsing, swap whatever ad-hoc shape you have for pattern 1: a
readonlyinterface and a factory that callsObject.freeze. Five lines, no library. - If it is a domain value object that ends up in
Mapkeys or sort comparators, write the class and the staticequals. Add a one-line comment that says "useMoney.equals, never===". - If it is a boundary type, define the schema first, infer the type, and parse at the edge. Move the inner code to consume the inferred type, not the schema directly.
Do that for one record this week. The next time a Java-shaped reflex stalls, the matrix tells you which way to translate it. The reflex still wants record and the language still says no, but now the no comes with three answers instead of a red squiggle.
If this was useful
Kotlin and Java to TypeScript — A Bridge for JVM Developers in The TypeScript Library covers the JVM-to-TS translation as a system, not a phrasebook: records, sealed types, variance, null safety, coroutines, generics. The patterns above sit in the chapter on data classes and value objects; the book carries them through the rest of the JVM-shaped reflexes you bring with you. It is one of five books in the collection:
- TypeScript Essentials — entry point. Types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, the browser.
-
The TypeScript Type System — the deep dive. Generics, mapped and conditional types,
infer, template literals, branded types. - Kotlin and Java to TypeScript — bridge for JVM developers. Variance, null safety, sealed-to-unions, coroutines to async/await.
- PHP to TypeScript — bridge for PHP 8+ developers. Sync to async, generics, discriminated unions.
- TypeScript in Production — the production layer. tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.
Books 1 and 2 are the core path. Book 3 is the JVM-developer bridge — it picks up where this post stops and takes the record-and-sealed-type translation through the rest of the language. Book 5 is for anyone shipping TypeScript at work.
All five books ship in ebook, paperback, and hardcover.

Top comments (0)