- 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 Kotlin developer joins a TypeScript service. Day one, they model a user.
data class User(
val id: Long,
val email: String?,
)
Two minutes of thought. email is sometimes there, sometimes not. The compiler will refuse to let anyone touch user.email.length without a null check. Done.
Day two, they translate the same shape to TypeScript and pick the literal-looking option:
interface User {
id: number;
email: string | null;
}
The service serializes a user with email = null, sends the JSON over the wire to a frontend, and the frontend reads user.email and gets undefined. The form initializes blank. Validation passes. The user gets created without an email. By the time the support ticket lands, three other things have rotted on the same fault.
The bug is not in the code. It is in the assumption that Kotlin's one bottom value maps to one TypeScript bottom value. It does not. TypeScript has two bottoms: null and undefined. On top of that there is a third state, the missing key, that JSON, optional parameters, and ?. all care about. Kotlin developers have never had to think about any of this.
One bottom in Kotlin
Kotlin has exactly one bottom value: null. String is non-nullable. String? is String or null. The compiler tracks it through smart-casts, the safe call (?.), the Elvis operator (?:), and !!.
fun greet(name: String?): String {
if (name == null) return "Hello, stranger"
return "Hello, $name" // smart-cast to String
}
There is no separate "absent" state. Every property declared on a data class is there, with a value (possibly null). At the type-system level, you are dealing with one bottom.
This is the mental model you arrive in TypeScript with.
Two bottoms, plus a third state
TypeScript inherits JavaScript's accidental dual bottom. null is a deliberate empty value. undefined is what JavaScript hands back when nothing was assigned: un-initialized variables, un-returned functions, un-passed arguments. They are different runtime values. They are different types.
const a: null = null;
const b: undefined = undefined;
console.log(a === b); // false
console.log(typeof a); // "object" (the famous historical bug)
console.log(typeof b); // "undefined"
On top of that, JavaScript objects have a third state your Kotlin brain does not have a word for: the property is not on the object. "email" in user is false. This is distinct from user.email === undefined even though reading user.email returns undefined either way.
const a: { email?: string } = {};
const b: { email?: string } = { email: undefined };
console.log(a.email); // undefined
console.log(b.email); // undefined
console.log("email" in a); // false
console.log("email" in b); // true
console.log(JSON.stringify(a)); // '{}'
console.log(JSON.stringify(b)); // '{}' ← undefined gets dropped
Three states. null, undefined, missing-key. Each shows up in a different place:
-
nullis what your code, your DB driver, and most JSON producers emit for "this field exists but is empty". PostgresNULL, a JavaOptional.empty()through Jackson, a form's "no value" sentinel. -
undefinedis what JavaScript hands you for un-passed function arguments, unset object properties, and uninitialized variables. The runtime's "I have nothing to say". -
Missing key is the JSON wire-format reality.
JSON.stringifydrops every property whose value isundefined. The receiver's?.chain then readsundefinedfor that absent key. The two states collapse on the wire and re-separate on read.
A Kotlin developer treats these as one thing because in Kotlin they were one thing. A TypeScript codebase that treats them as one thing ships the bug from the opening.
What strictNullChecks actually does
Before you model anything, look at your tsconfig.json.
By default, strictNullChecks is off unless you have "strict": true. With it off, the official docs put it bluntly: "null and undefined are effectively ignored by the language. This can lead to unexpected errors at runtime."
// strictNullChecks: false
function len(s: string): number {
return s.length; // compiles
}
len(null); // compiles. Crashes at runtime.
len(undefined); // compiles. Crashes at runtime.
Without the flag, string silently includes null and undefined. Every type does. The type system is lying to you.
With strictNullChecks: true, the docs again: "null and undefined have their own distinct types and you'll get a type error if you try to use them where a concrete value is expected."
// strictNullChecks: true
function len(s: string): number {
return s.length;
}
len(null); // Type error: 'null' is not assignable to 'string'
len(undefined); // Type error: 'undefined' is not assignable to 'string'
Now string means string. If you want nullable, you say so: string | null, string | undefined, or string | null | undefined. This is the closest TypeScript gets to Kotlin's String vs String? distinction.
If your tsconfig does not have "strict": true or "strictNullChecks": true, stop reading and turn it on. A TypeScript codebase without strictNullChecks is, in JVM terms, a Kotlin codebase where every Java-interop value comes back as a platform type and the compiler waves it through.
exactOptionalPropertyTypes splits the third state
Here is where it gets genuinely subtle and where Kotliners get tripped up most often.
Even with strictNullChecks on, TypeScript's optional property syntax is permissive. By default, this works:
interface UserDefaults {
colorThemeOverride?: "dark" | "light";
}
const s: UserDefaults = {};
s.colorThemeOverride = "dark"; // ok
s.colorThemeOverride = undefined; // ok by default
delete s.colorThemeOverride; // also ok
The ? on colorThemeOverride quietly widens the value type to "dark" | "light" | undefined and lets you assign undefined to it freely. So an "optional" property covers three runtime cases. The field has a value. The field is set to undefined. The field is absent. The type system does not distinguish the last two.
exactOptionalPropertyTypes (added in TypeScript 4.4) changes that. With it on, the ? only marks the property as possibly absent. It does not let you assign undefined.
// exactOptionalPropertyTypes: true
interface UserDefaults {
colorThemeOverride?: "dark" | "light";
}
const s: UserDefaults = {};
s.colorThemeOverride = "dark"; // ok
s.colorThemeOverride = "light"; // ok
s.colorThemeOverride = undefined; // Error: Type 'undefined' is not
// assignable to type '"dark" | "light"'
// with 'exactOptionalPropertyTypes: true'
If you genuinely want both "absent" and "explicitly undefined" to be representable, you have to spell it out:
interface UserDefaults {
colorThemeOverride?: "dark" | "light" | undefined;
}
The two flags are independent. strictNullChecks is about null and undefined as values inside the type system. exactOptionalPropertyTypes is about whether ? and | undefined mean the same thing on a property declaration. The official docs document them as two separate flags with different defaults: strict: true includes strictNullChecks but does not include exactOptionalPropertyTypes. You opt into the second one by name.
For a Kotliner, the practical reading is straightforward. strictNullChecks gets you most of the safety of Kotlin's nullable types. exactOptionalPropertyTypes gets you the rest. That is the part where "the field is not on the object" and "the field is on the object set to nothing" stop being the same thing.
The four shapes a "nullable field" can take in TypeScript
Once you accept three bottom states, "nullable email" stops being one decision and becomes four. Each round-trips differently through JSON.
1. email: string | null — always present. Value is either a string or null. JSON encodes {"email": null} when empty. Closest match to Kotlin's email: String?. Use this when the field is canonical and "empty" should be visible to the receiver.
2. email?: string — sometimes absent. With exactOptionalPropertyTypes on, you set it or you delete it. Use this for partial updates, optional query parameters, configuration with defaults.
JSON.stringify({}); // '{}'
JSON.stringify({ email: "a@b.com" }); // '{"email":"a@b.com"}'
3. email: string | undefined — always present, value is sometimes undefined. Rarely what you want on object properties (it round-trips as a missing key). It shows up naturally for return values: find(), Map.get(), Array.at(). Use it for return positions, not object shapes.
4. email?: string | null — the maximally permissive form. Absent, string, or null. This is what an HTTP PATCH body for a nullable column actually needs: omit to mean "do not change", null to mean "clear it", a string to mean "update to this".
// PATCH /users/1 { } → no change
// PATCH /users/1 { "email": null } → clear it
// PATCH /users/1 { "email": "a@b.com" } → set it
Kotlin collapses "absent" and "null" into one bottom. TypeScript makes you choose. A working rule of thumb:
-
Canonical DTOs:
field: T | null. The shape is fixed. Empty has a value. -
PATCH / partial updates:
field?: Tfor non-nullable columns,field?: T | nullfor nullable columns. -
Return types of lookups:
T | undefined.Map.get,Array.find, andArray.atalready returnundefined; matching them keeps your code uniform. -
Function parameters with defaults:
field?: T, default applied inside the function.
The serialization trap that bit our user
Re-read the opening. The Kotliner picked email: string | null, then wrote a server that did this:
function buildUser(): User {
return {
id: 1,
// forgot to set email
} as User; // the cast hides the missing field
}
JSON.stringify(buildUser()); // '{"id":1}' — email key never made it
The cast as User told the compiler "trust me, this matches User". It does not. With the field declared as email: string | null (not optional), the cast still slips through because as is an assertion, not a check. The compiler catches you only if you assign without the cast:
const u: User = { id: 1 };
// Error: Property 'email' is missing in type '{ id: number; }'
// but required in type 'User'.
JSON.stringify on an object whose email is undefined produces {"id":1} — the key is dropped. The frontend reads user.email and gets undefined. The form sees no email. The user gets created blank.
The fix is not a Zod schema bolted on at the end. Stop using as for shape assertions, let the compiler enforce required fields, and pick the modeling shape that matches the wire reality. If "no email" is a real state, declare email: string | null and assign null explicitly. When the state is "no email field at all", use email?: string and let the JSON drop the key honestly.
Validators carry the same three-state space
Once your TypeScript types are honest about three states, the validator at your boundary has to mirror them. Both Zod and Valibot expose distinct primitives for null, for undefined, and for "optional / missing".
Zod:
import { z } from "zod";
const UserPatch = z.object({
email: z.string().nullable(), // string or null
displayName: z.string().optional(), // possibly absent
bio: z.string().nullable().optional(), // absent, null, or string
});
UserPatch.parse({ email: null }); // ok
UserPatch.parse({ email: null, bio: null }); // ok
UserPatch.parse({}); // fails (email required)
Valibot has the same triple with different names: nullable(), optional(), nullish().
The mental check, every time you touch a schema: which of the three states is legal here? null, missing, or both? If you cannot answer for a field, neither can the next person who reads the code.
What changes about the way you write code
A few habits port over cleanly once you accept the three-state reality.
The optional chain ?. works on both bottoms. user?.email?.length short-circuits on either null or undefined. This is TypeScript's safe call. It does not distinguish the two, and most of the time that is fine.
Nullish coalescing ?? is your Elvis. user.email ?? "no email" returns the right side when the left is null or undefined — specifically not when the left is 0, "", or false. That last part is the difference between ?? and ||. Kotliners arriving from ?: should reach for ??, not ||.
Narrowing replaces smart-cast. Kotlin smart-casts on if (x != null). TypeScript narrows on a few forms. The strict version is if (x !== null && x !== undefined). The shorthand if (x != null) uses loose equality and narrows both at once. A typeof x === "string" check narrows by type. Same mental model, different syntax surface.
! is !! is a code smell. TypeScript's non-null assertion x! is exactly Kotlin's x!!: a runtime promise the compiler should stop asking about. Reach for it as rarely. Each call is a future bug report.
Pick the shape, then ship
Open your tsconfig.json. If "strict": true is not there, add it. Then add "exactOptionalPropertyTypes": true, fix the errors, and treat the diff as the cheapest static-analysis pass you will run all year.
For every nullable field in your DTOs, pick one of the four shapes deliberately: field: T | null, field?: T, field: T | undefined, or field?: T | null. Match the wire format and the API contract. Mirror the choice in your validator. Stop using as to paper over shape mismatches.
You already think in null safety. The work is to stop translating "Kotlin nullable" into "TypeScript with null" and start choosing among three states on purpose. The bug from the opening goes away the moment that choice becomes explicit.
The full version of this story is Kotlin and Java to TypeScript: variance, sealed unions to discriminated unions, coroutines to async/await, the rest of the JVM-to-TypeScript bridge.
If this was useful
The TypeScript Library is a 5-book collection. Books 1 and 2 are the core path. Books 3 and 4 substitute for 1 and 2 if you speak JVM or PHP. Book 5 is the production layer for any of them.
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — Amazon · entry point. Types, narrowing, modules, async, daily-driver tooling.
- The TypeScript Type System — From Generics to DSL-Level Types — Amazon · deep dive. Generics, mapped/conditional types, infer, template literals, branded types.
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — Amazon · the bridge for the reader of this post. Variance, null safety, sealed→unions, coroutines→async/await.
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — Amazon · sync→async paradigm, generics, discriminated unions for PHP 8+ devs.
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — Amazon · tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

Top comments (0)