DEV Community

Cover image for Kotlin Null Safety to TypeScript: Sealed Classes Become Unions
Gabriel Anhaia
Gabriel Anhaia

Posted on

Kotlin Null Safety to TypeScript: Sealed Classes Become Unions


You have spent years in Kotlin where the compiler refuses to
let null slip through. A String? is a different type from a
String. A when over a sealed class fails to compile if you
miss a branch. The type system has your back, and you stopped
thinking about whole categories of runtime crashes.

Then you move a service to TypeScript and the first thing you
hit is a function that returns user.profile.email and throws
Cannot read properties of undefined in production. The
instinct is that TypeScript is the weaker language. It is not.
It has the same three tools you relied on in Kotlin. They wear
different names and they live in slightly different places.

This is the translation table for the three Kotlin features you
will miss first: nullable types, sealed classes, and exhaustive
when.

?. is the same operator, with a catch

Start with the one that ports cleanly. Kotlin's safe-call
operator is ?., and TypeScript has the exact same syntax,
called optional chaining since 3.7.

// Kotlin
val email = user?.profile?.email
// email is String?
Enter fullscreen mode Exit fullscreen mode
// TypeScript
const email = user?.profile?.email;
// email is string | undefined
Enter fullscreen mode Exit fullscreen mode

The behaviour matches: the chain short-circuits to undefined
the moment any link is null or undefined, and it never throws.

The catch is what each language hands back. Kotlin gives you a
String?. TypeScript gives you string | undefined. In Kotlin
the absence is null. In TypeScript optional chaining produces
undefined, and the wider language has both null and
undefined floating around. You need to decide which one your
codebase treats as "absent" and stay consistent.

The Elvis operator ports too. Kotlin's ?: becomes nullish
coalescing ??:

// Kotlin
val name = user?.name ?: "anonymous"
Enter fullscreen mode Exit fullscreen mode
// TypeScript
const name = user?.name ?? "anonymous";
Enter fullscreen mode Exit fullscreen mode

Reach for ??, not ||. The || operator falls back on every
falsy value, so 0 and "" get replaced too. ?? only falls
back on null and undefined, which is the Elvis semantics you
already know.

Turn on the flag, or none of this is real

Here is the part that bites JVM developers hardest. Kotlin's
null safety is on by default and cannot be switched off.
TypeScript's is a compiler flag.

If your tsconfig.json does not have strict (or at least
strictNullChecks) enabled, then string includes null and
undefined, the ?. discipline above buys you nothing, and the
compiler waves through the exact crash you came here to avoid.

// tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}
Enter fullscreen mode Exit fullscreen mode

In TypeScript 6.0 strict defaults to true, but plenty of
codebases predate that and pin it off. Check this before you
write a line. Without it, TypeScript is the dynamically typed
language people accuse it of being. With it, the type
string | undefined is genuinely distinct from string, and
the compiler forces you to narrow before you use the value.
That narrowing is what replaces Kotlin's smart casts.

function greet(name: string | undefined): string {
  // name.toUpperCase() here is a compile error
  if (name === undefined) {
    return "hello, stranger";
  }
  // here name is narrowed to string
  return `hello, ${name.toUpperCase()}`;
}
Enter fullscreen mode Exit fullscreen mode

The if guard is TypeScript's smart cast. After the
=== undefined check, the compiler knows the remaining branch
holds a string and lets you call string methods on it.

Sealed classes become discriminated unions

This is the translation that takes the most rewiring, because
Kotlin reaches for inheritance and TypeScript reaches for a
plain union of object shapes.

In Kotlin you model a closed set of states with a sealed class
and subtypes:

// Kotlin
sealed class Result {
    data class Ok(val value: String) : Result()
    data class Err(val code: Int) : Result()
    object Loading : Result()
}
Enter fullscreen mode Exit fullscreen mode

In TypeScript you do not subclass. You write each variant as an
object type with a shared literal field, then union them. That
shared field is the discriminant, and it is what makes the
union "sealed" for the compiler.

// TypeScript
type Result =
  | { kind: "ok"; value: string }
  | { kind: "err"; code: number }
  | { kind: "loading" };
Enter fullscreen mode Exit fullscreen mode

The kind field plays the role Kotlin's class hierarchy played.
Every variant carries a distinct string literal, so when you
check kind, the compiler narrows the whole object to the
matching shape. You read value only on the ok branch and
code only on the err branch, and trying to read the wrong
field on the wrong branch is a compile error.

You do not have to call the field kind. type, tag, and
status are all common. Pick one and keep it across the
codebase so the narrowing reads the same everywhere.

when becomes a switch, and exhaustiveness is a trick

In Kotlin, a when over a sealed class is checked for
exhaustiveness by the compiler. Miss a subtype and the build
fails. That safety is half the reason you use sealed classes.

// Kotlin
fun render(r: Result): String = when (r) {
    is Result.Ok -> r.value
    is Result.Err -> "error ${r.code}"
    Result.Loading -> "loading"
}
Enter fullscreen mode Exit fullscreen mode

TypeScript's switch narrows on the discriminant the same way,
but the language does not check exhaustiveness for you out of
the box. You add it with one idiom: a default branch that
assigns the value to never.

function render(r: Result): string {
  switch (r.kind) {
    case "ok":
      return r.value;
    case "err":
      return `error ${r.code}`;
    case "loading":
      return "loading";
    default:
      const _exhaustive: never = r;
      return _exhaustive;
  }
}
Enter fullscreen mode Exit fullscreen mode

The mechanism is worth understanding, because it is the closest
TypeScript gets to Kotlin's built-in check. Once you handle
every case, the type of r in the default branch is never,
the type with no values. Assigning it to a never variable
compiles. Add a fourth variant to Result, say
{ kind: "timeout" }, and now r in the default branch is
{ kind: "timeout" }, which is not assignable to never. The
build breaks, pointing at the function you forgot to update.

That is the same protection Kotlin gives you for free. The
difference is you opt into it, function by function, with the
never assignment. Skip the default branch and a missing case
falls through silently. Wire a small helper once and reuse it:

function assertNever(x: never): never {
  throw new Error(`unhandled variant: ${JSON.stringify(x)}`);
}
Enter fullscreen mode Exit fullscreen mode

Then every switch ends with default: return assertNever(r).
One line per switch, and you have Kotlin's exhaustiveness back.

Why TypeScript skips the class hierarchy

The instinct from Kotlin is to mirror the sealed class with a
TypeScript class hierarchy and instanceof checks. You can,
and it works, but it fights the grain of the language.

TypeScript is structurally typed. A union of object literals
costs nothing at runtime, serialises straight to and from JSON,
and narrows on a plain field read. A class hierarchy adds
constructors, prototype chains, and instanceof checks that
break the moment a value crosses a serialisation boundary, which
in a TypeScript service it does constantly: every HTTP response,
every message off a queue, every row from a database.

The discriminated union is the idiomatic shape because it
survives that round trip. Your Result came off the wire as
plain JSON with a kind string, and the union handles it
without rehydrating any class. That is the payoff for giving up
the hierarchy you knew from the JVM.

The translation table

Three features, three mappings, and you have most of the null
safety you came from:

  • Kotlin ?. and ?: map to TypeScript ?. and ??. Same syntax, but turn on strict or none of it is enforced.
  • Kotlin String? maps to string | undefined. Narrow with an if guard before you use the value; that is the smart cast.
  • Kotlin sealed classes map to discriminated unions with a shared literal field. No subclassing.
  • Kotlin's exhaustive when maps to a switch with a never-assignment default. You opt in per function, and the compiler holds the line from there.

The mental model that ports is the important part. You modeled
closed sets of states and let the compiler prove you handled
all of them. TypeScript can do the same thing. It asks you to
spell out the discriminant and wire the exhaustiveness check
yourself, and in exchange it gives you types that survive every
JSON boundary your JVM code had to work around.

If the JVM-to-TypeScript jump is the move you are making,
Kotlin and Java to TypeScript walks the full bridge: variance,
null safety, sealed classes to unions, and coroutines to
async/await, with the structural-typing mindset that the rest of
the language assumes. It is the book I would hand a Kotlin team
on day one of a TypeScript service.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

  1. TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
  2. The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
  4. PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
  5. TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)