DEV Community

Cover image for Java's `Optional<T>` Is Dead. TypeScript Gave You Better.
Gabriel Anhaia
Gabriel Anhaia

Posted on

Java's `Optional<T>` Is Dead. TypeScript Gave You Better.


A Java developer joins a TypeScript service. The repository's findUserById returns User | undefined. Their hand reaches for the muscle memory: wrap it. Make it safe. Give the caller a thing that signals absence the way a Java codebase has signalled absence since 2014.

class Optional<T> {
  private constructor(private readonly value: T | undefined) {}

  static of<T>(value: T): Optional<T> { return new Optional(value); }
  static empty<T>(): Optional<T> { return new Optional<T>(undefined); }
  static ofNullable<T>(value: T | undefined): Optional<T> {
    return new Optional(value);
  }

  isPresent(): boolean { return this.value !== undefined; }
  get(): T {
    if (this.value === undefined) throw new Error("No value");
    return this.value;
  }
  map<U>(fn: (v: T) => U): Optional<U> {
    return this.value === undefined
      ? Optional.empty<U>()
      : Optional.of(fn(this.value));
  }
}

function findUser(id: number): Optional<User> {
  const u = repo.findById(id);
  return Optional.ofNullable(u);
}
Enter fullscreen mode Exit fullscreen mode

The PR comment lands within the hour. "What is this? Why is findUser not just returning User | undefined like every other repo in the codebase?"

The Java developer types out a half-page reply about null safety and NullPointerException and the value of explicit absence. They mean every word. They are also wrong, because the language they are writing in already gives them all of those properties without the wrapper. The Optional<T> class they just wrote is solving a problem that does not exist on this side of the runtime.

This post is for that developer. The instinct is right; the wrapper is not. The path out is short.

Why Optional<T> exists in Java

You cannot understand why TypeScript does not need Optional<T> until you understand what Optional<T> was actually solving in Java. Two things, neither of which is "give me a nicer API for absence".

The first thing. Java's reference types were nullable from day one. Every String, every User, every List<X> could be null, and the compiler had nothing to say about it. Brian Goetz, the Java language architect, described Optional on Stack Overflow as a "limited mechanism for library method return types where there needed to be a clear way to represent 'no result'…". The keyword there is library method return types. Optional<T> was not a general null-replacement. It was a hint to callers of Stream.findFirst(), Map.computeIfAbsent, Optional.flatMap chains: this method might give you nothing back, and the type signature is going to make you face that.

The second thing. Java's type system could not represent absence in the type itself. T and T or nothing had to be the same type. Optional<T> is the workaround. The class is a one-element box: either it holds a T, or it does not, and the API forces you to acknowledge both states before reaching for the value.

Both of those problems are real in Java. Both are solved on the TypeScript side without leaving the type system.

Why TypeScript does not need it

TypeScript's type system represents absence directly. User | undefined is a real type. The compiler tracks it. Reading .email off it is a compile error until you narrow.

function findUser(id: number): User | undefined {
  return repo.findById(id);
}

const u = findUser(1);
console.log(u.email);
// Property 'email' does not exist on type 'User | undefined'.
//   Property 'email' does not exist on type 'undefined'.

if (u !== undefined) {
  console.log(u.email);   // ok — narrowed to User
}
Enter fullscreen mode Exit fullscreen mode

That is Optional.isPresent() and Optional.get() collapsed into the type system. No wrapper, no allocation, no method call, no second type for callers to import. The compiler refuses the unsafe access and accepts the safe one. Same guarantee, less ceremony.

The other tools in the kit:

  • Optional chaining ?. (TypeScript docs). user?.address?.city short-circuits on null or undefined. This is Optional.map(User::getAddress).map(Address::getCity) written as one line of normal property access.
  • Nullish coalescing ?? (TypeScript docs). user?.email ?? "no email" substitutes when the left side is null or undefined, specifically not when it is 0, "", or false. That is Optional.orElse("no email").
  • Narrowing. if (u !== undefined), typeof x === "string", instanceof, discriminated-union kind checks. These are the type-system equivalent of Optional.ifPresent(...) and they read like normal control flow because they are normal control flow.
  • exactOptionalPropertyTypes (TypeScript docs). Splits "field is missing" from "field is set to undefined" when you need that distinction.
  • Structural typing. The thing returned from findUser is a User, full stop. Nothing forces the caller to import a wrapper type to read the field.

A Java developer reading that list will object: but the wrapper forced the caller to handle absence. Right. So does the union type. The compiler is as unforgiving about reading .email off User | undefined as the Java compiler is about calling .get() on a raw Optional<User> without checking. Enforcement moved from a class API into the type system, and the class became unnecessary.

Side by side: the same logic in both languages

Take a small piece of business logic. Find a user, take their primary email if they have one, lower-case it, fall back to a placeholder. Real shape, no toy.

In Java with Optional:

public String displayEmail(long userId) {
    return userRepo.findById(userId)
        .map(User::getPrimaryEmail)
        .map(String::toLowerCase)
        .orElse("(no email on file)");
}
Enter fullscreen mode Exit fullscreen mode

In TypeScript with the wrapper a Java developer ports over:

function displayEmail(userId: number): string {
  return findUser(userId)
    .map(u => u.primaryEmail)
    .map(e => e?.toLowerCase())
    .orElse("(no email on file)");
}
Enter fullscreen mode Exit fullscreen mode

In TypeScript with the language as it is:

function displayEmail(userId: number): string {
  return findUser(userId)?.primaryEmail?.toLowerCase()
    ?? "(no email on file)";
}
Enter fullscreen mode Exit fullscreen mode

One line. No wrapper class. No method chain that exists only to thread a value through transformations. The ?. operator handles the "skip if absent" semantics. The ?? handles the fallback. The compiler refuses the same unsafe accesses that Optional was forcing you to acknowledge.

There is a second cost Java developers discount. Optional<T> allocates. Every call returns a heap object whose only job is to hold a pointer and a presence flag (under the standard JDK; Project Valhalla's value classes may change this). In a hot path running often, those one-shot wrappers add up. The TypeScript version returns the value or undefined directly. No box.

The map and flatMap you "lose"

The objection that lands hardest from Java developers is this: Optional gives me map, flatMap, filter, ifPresent. TypeScript's union types do not. I have to write if statements like a caveman.

Let us walk that through.

Optional.map(fn) is "apply fn to the value if it is present, otherwise stay empty". TypeScript has two ways to write that. For property access, it is ?.:

// Java: opt.map(User::getEmail).map(String::toLowerCase)
const lowered = user?.email?.toLowerCase();
Enter fullscreen mode Exit fullscreen mode

For arbitrary functions, it is a narrow-and-call:

const slug = user !== undefined ? makeSlug(user) : undefined;
Enter fullscreen mode Exit fullscreen mode

That second form is verbose enough that some teams reach for a tiny helper:

function mapDefined<T, U>(
  value: T | undefined,
  fn: (v: T) => U,
): U | undefined {
  return value === undefined ? undefined : fn(value);
}

const slug = mapDefined(user, makeSlug);
Enter fullscreen mode Exit fullscreen mode

That is a four-line helper, generic, allocation-free, and it does not need its own class. If you reach for it twice and it sticks, fine. If you reach for it once and never again, also fine.

Optional.flatMap(fn) is "apply fn (which itself returns an Optional) to the value, flatten the result". For TypeScript unions, flattening is structural. User | undefined followed by a function returning Email | undefined is Email | undefined, nothing more. No flattening operation needed because there is nothing nested.

// Java: opt.flatMap(this::lookupPrimaryEmail)
const email: Email | undefined =
  user !== undefined ? lookupPrimaryEmail(user) : undefined;
Enter fullscreen mode Exit fullscreen mode

Optional.filter(pred) becomes a guard:

const adult = user !== undefined && user.age >= 18 ? user : undefined;
Enter fullscreen mode Exit fullscreen mode

Optional.ifPresent(fn) becomes an if:

if (user !== undefined) {
  notify(user);
}
Enter fullscreen mode Exit fullscreen mode

Every method on Optional<T> has a one-line equivalent in TypeScript that uses the value directly. The fluent chain reads pretty in Java because Java's lack of operator overloading and type-level absence forces the API into a method-chain shape. TypeScript's operators and narrowing do the same work without the chain.

The Either / Result pattern is a discriminated union

The other place Java developers reach for a wrapper is when absence carries a reason. Optional<T> does not carry one. So the pattern in Java becomes Either<Error, T> (Vavr, FunctionalJava) or a homegrown Result<T, E> sealed hierarchy.

That pattern is real and it is good. The wrapper is still the wrong shape for TypeScript.

In TypeScript, Result<T, E> is a discriminated union of two object shapes:

type Result<T, E> =
  | { ok: true;  value: T }
  | { ok: false; error: E };

function findUser(id: number): Result<User, "not_found" | "db_error"> {
  try {
    const u = repo.findById(id);
    return u !== undefined
      ? { ok: true, value: u }
      : { ok: false, error: "not_found" };
  } catch (e) {
    return { ok: false, error: "db_error" };
  }
}

const r = findUser(1);
if (r.ok) {
  console.log(r.value.email);   // narrowed to { ok: true; value: User }
} else {
  console.log(r.error);          // narrowed to { ok: false; error: ... }
}
Enter fullscreen mode Exit fullscreen mode

The ok field is the discriminant. The compiler narrows on it. The branches see different shapes. There is no Result class, no match method, no getOrElse ladder. Reading .value in the success branch and .error in the failure branch is exactly what the Java sealed hierarchy was forcing you to do, written as plain field access after a check.

Discriminated unions are not a workaround for missing sealed classes. They are how a structurally typed system says "this value is one of N shapes and you have to check which" without inventing a class hierarchy to carry the information. Java got sealed classes in JDK 17 and they are good. TypeScript already had the same expressive power through the type system, no sealed keyword required.

Where JVM developers still reach for a wrapper, honestly

Every claim that TypeScript "covers it natively" deserves the case where it does not. Two cases stand out.

Lazy evaluation. Optional.orElseGet(() -> expensive()) only invokes the supplier when the optional is empty. The TypeScript ?? operator covers the simple case: value ?? expensive() only evaluates the right side when the left is nullish. It gets fiddlier when you want the lazy value held in a structure other code passes around — Java's Supplier<T> plus Optional.orElseGet is ergonomic in a way the TypeScript equivalent matches but does not improve on.

Deferred computation across boundaries. When you actually need to pass "a thing that may compute a value later" across a function boundary, Optional<T> was not the right Java tool either — Supplier<T> or CompletableFuture<T> was. The TypeScript equivalent is a function or a Promise<T>. Unrelated to absence. The two concepts shared a wrapper in some Java codebases for accidental reasons, and porting that conflation to TypeScript is the wrong move.

If you find yourself reaching for a wrapper class, ask which of the two reasons above is in play. If neither is, the union plus narrowing covers it.

What you actually get back

Drop the Optional<T> port. The TypeScript codebase stops looking like a Java translation and starts looking like the language it is.

Function signatures get readable. findUser(id: number): User | undefined tells the caller everything Optional<User> told them, in fewer characters, without a class import.

Hot paths stop allocating. The wrapper objects you were creating to thread values through map chains were heap allocations the V8 garbage collector had to clean up. The native version returns the value directly.

The team's TypeScript developers stop asking what the wrapper is. The PR comment from the opening goes away.

The Java developer's instinct stays right: absence should be visible in the type. The execution of that instinct moves from a class to a union. That is the trade.

Pick the shape and move

Open the next PR where you were about to introduce Optional<T> into a TypeScript file. Replace the return type with T | undefined. Replace the .map(...).map(...).orElse(...) chain with ?. and ??. Replace the Optional.empty() returns with undefined. Run the type checker. The errors it surfaces are the places where the codebase was actually unsafe. Fix those.

Then turn on strictNullChecks and exactOptionalPropertyTypes in tsconfig.json if they are not already on. Without them, none of the above is enforced. With them, the union-type-plus-narrowing model is at least as strict as Optional<T> was, and arguably stricter, because Optional.get() was always available as an escape hatch and User | undefined has no .get() to escape with.

The Java developer who walked into the codebase with the Optional muscle memory is the same developer who, six months in, will write the helper that encodes a discriminated-union Result<T, E> for the team and quietly delete the Optional<T> class from utils/.

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 BrowserAmazon · entry point. Types, narrowing, modules, async, daily-driver tooling.
  • The TypeScript Type System — From Generics to DSL-Level TypesAmazon · deep dive. Generics, mapped/conditional types, infer, template literals, branded types.
  • Kotlin and Java to TypeScript — A Bridge for JVM DevelopersAmazon · 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+ DevelopersAmazon · sync→async paradigm, generics, discriminated unions for PHP 8+ devs.
  • TypeScript in Production — Tooling, Build, and Library Authoring Across RuntimesAmazon · 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)