DEV Community

Cover image for From Java's Optional<T> to TypeScript: Three Idioms That Replace It Cleanly
Gabriel Anhaia
Gabriel Anhaia

Posted on

From Java's Optional<T> to TypeScript: Three Idioms That Replace It Cleanly


You join a TypeScript codebase as a Java engineer, see a function returning User | undefined, and reach for the obvious move — you wrap it.

type Optional<T> = { value: T } | { value: null };
Enter fullscreen mode Exit fullscreen mode

Then you add .map(), .flatMap(), .orElse() methods. Two
weeks later the code review backs up. The team is half-fluent
in this homemade Optional type, half-fluent in plain
TypeScript, and the boundary between them is where bugs hide.
Half the codebase wraps, half doesn't, and the boundary leaks.
Functions that take a User | undefined get called with an
Optional<User> value that contains a non-null user. Both
compile. Neither is what the caller meant.

The instinct is right, but the implementation is wrong: TypeScript
already expresses what Optional<T> does in Java without a
wrapper. Three idioms cover the cases, ordered by how often you
should reach for them in a healthy codebase.

What Java's Optional actually buys you

Java Language Architect Brian Goetz, who shepherded Optional<T>
into Java 8 as part of JSR 335, has been public about its
intended scope: return values from methods where the absence is
a normal outcome the caller must handle. Not fields,
parameters, collections, or a null substitute in every position.

The class itself is small. It carries either a present value
or no value, and it gives you .map, .flatMap,
.filter, .orElse, .orElseGet, .ifPresent. The point
of the API is not the wrapper. The point is that the type
signature forces the caller to decide what happens when the
value is absent. A method returning Optional<User> cannot be
called and dereferenced without the compiler reminding you
that absence is a possibility.

TypeScript reaches the same goal through different machinery,
with three idioms ranked by frequency in a healthy codebase.

Idiom 1: T | undefined with ?. and ??

This is the default. Most absences in a TypeScript codebase
should be expressed as a union with undefined, chained with
the optional access operator ?. and defaulted with the
nullish coalescing operator ??.

type User = { id: string; email: string; manager?: User };

function findUser(id: string): User | undefined {
  // ...
}

const email = findUser("u_123")?.email ?? "anon@example.com";
Enter fullscreen mode Exit fullscreen mode

That is the entire pattern. ?. short-circuits the chain when
the receiver is null or undefined and returns undefined.
?? substitutes the default when the result is null or
undefined. Together they cover what Optional.map(...).orElse(...)
covers in Java for the simple case.

In Java:

String email = findUser("u_123")
    .map(User::getEmail)
    .orElse("anon@example.com");
Enter fullscreen mode Exit fullscreen mode

The TypeScript line does the same job in fewer characters and
with less indirection: no wrapper to allocate, no .get() to
call, no extra unwrap step at the caller. The compiler tracks
the union and complains the moment you try to read .email on
a value that might be undefined without going through ?.
or a narrowing check first.

?. chains across multiple levels:

const managerEmail =
  findUser("u_123")?.manager?.email ?? "no manager";
Enter fullscreen mode Exit fullscreen mode

The Java equivalent stacks .map / .flatMap calls, which is
where the API starts to feel heavier than the underlying
question.

When the absence is just absence (no error context to carry,
no enum of failure reasons), T | undefined plus ?./?? is
the right idiom. It composes, the IDE narrows it correctly,
and a Java engineer reading the code sees exactly what is
happening on the first pass.

A note on the union shape. Pick T | undefined over
T | null and stick with it. The two are not equivalent; ?.
and ?? treat them the same way, but generic constraints,
function parameter optionality, and JSON serialisation do not.
Most TypeScript style guides land on undefined for absences
the program produces and null only for values that came in
that way over the wire — see the TypeScript Handbook's
Everyday Types page

for the canonical examples.

The foot-gun: nullable-as-Optional vs Optional-as-monad

Before the second idiom, the trap that catches Java engineers
new to TypeScript.

In Java, optional.map(f) calls f only when the value is
present, and wraps the result back into an Optional. In
TypeScript, there is no implicit version of that for unions
with undefined. ?. does the short-circuit for member
access
, but it does not extend to function calls.

declare const user: User | undefined;
declare function shout(s: string): string;

const r1 = shout(user?.email ?? "");
//                       ^ if user is undefined, "" goes to shout

const r2 = user?.email && shout(user.email);
//         ^ short-circuits, but you've coupled absence to
//           truthiness — this breaks the moment email could be ""
Enter fullscreen mode Exit fullscreen mode

The closest direct equivalent of optional.map(f) for
T | undefined is a tiny helper:

function mapOpt<T, U>(
  v: T | undefined,
  f: (t: T) => U,
): U | undefined {
  return v === undefined ? undefined : f(v);
}

const r3 = mapOpt(user, u => shout(u.email));
//    ^ string | undefined
Enter fullscreen mode Exit fullscreen mode

Most codebases never write mapOpt because ?. covers the
member-access case and a one-line if covers the rest. The
key thing to internalise: null and undefined do not
auto-thread through arbitrary function calls the way
Optional.map does. The compiler tells you when you slip; the
fix is usually to narrow with an if and let the rest of the
function run on the narrowed type.

if (user === undefined) return defaultEmail;
const result = shout(user.email);
//                   ^ user is User here, no question mark
Enter fullscreen mode Exit fullscreen mode

That early return is the TypeScript-native shape. A Java
engineer who tries to keep the value wrapped through five
chained calls is fighting the language.

Idiom 2: Result when you need error context

Sometimes absence is not enough. The caller needs to know why
the value is missing. The user does not exist. The user was
deleted. The query timed out. A wrapper that carries either
the value or the error reads better than a T | undefined
followed by a separate error channel.

The discriminated union form is the idiomatic shape.

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

type FindUserError =
  | { kind: "not-found"; id: string }
  | { kind: "deleted"; id: string; deletedAt: Date }
  | { kind: "timeout"; afterMs: number };

function findUser(id: string): Result<User, FindUserError> {
  // ...
  return { ok: false, error: { kind: "not-found", id } };
}
Enter fullscreen mode Exit fullscreen mode

The ok field is the discriminant. The compiler narrows the
union on if (result.ok) so the value is reachable in one
branch and the error is reachable in the other. Switching on
error.kind then narrows further so each handler sees the
specific error shape.

Most codebases skip the helper functions and write the
narrowing pattern inline:

const r = findUser("u_123");
if (!r.ok) {
  log.warn("user lookup failed", r.error);
  return "anon@example.com";
}
return r.value.email;
Enter fullscreen mode Exit fullscreen mode

That reads better than chained method calls in TypeScript and
gives the error handler the exact context the API wanted to
surface. The Result<T, E> shape is what the rest of the
codebase imports and returns. It composes, serialises, and
narrows under control flow analysis without a library
dependency.

When to reach for this over T | undefined: any time a silent
absence would lose information the caller needs. Database
reads that distinguish missing rows from query failures.
Validation that produces structured error reasons. External
API calls where the response can fail in three different ways
the UI handles differently. Anywhere a Java method would have
returned Result<T> from Vavr or thrown a checked exception,
this is the TypeScript shape.

Idiom 3: fp-ts or Effect Option for fp-leaning codebases

The third idiom is for codebases that have already committed
to a functional style, where pipes, monads, and explicit
effects are the house dialect. fp-ts
ships an Option<T> type that mirrors Java's Optional<T>
and Scala's Option[T] more directly than anything else in
the TypeScript ecosystem. Effect,
the project that has absorbed much of the fp-ts ecosystem's
momentum, ships the same primitive under the same name.

Assuming findUser returns User | undefined again:

import * as O from "fp-ts/Option";
import { pipe } from "fp-ts/function";

const found: O.Option<User> = O.fromNullable(findUser("u_123"));

const email = pipe(
  found,
  O.map(u => u.email),
  O.getOrElse(() => "anon@example.com"),
);
Enter fullscreen mode Exit fullscreen mode

That is a near line-for-line port of the Java
optional.map(User::getEmail).orElse("anon@example.com")
pattern. O.fromNullable lifts a T | null | undefined into
an Option<T>. O.map runs the function only on the present
case. O.getOrElse unwraps with a default. The pipe
composition is what fp-ts does best, and once the team is
fluent it reads as cleanly as any monadic code in Scala or
Haskell.

The Effect version is structurally identical:

import { Option, pipe } from "effect";

const found = Option.fromNullable(findUser("u_123"));

const email = pipe(
  found,
  Option.map(u => u.email),
  Option.getOrElse(() => "anon@example.com"),
);
Enter fullscreen mode Exit fullscreen mode

This idiom earns its keep when the codebase already pipes
everything through Effect or fp-ts and the rest of the
machinery (Either, Task, Reader, IO) is in regular
use. In a codebase that does not lean functional, importing
fp-ts just for Option<T> is overkill — T | undefined
covers the same ground with less indirection, and a developer
who has not internalised pipes will find every Option call
site harder to read than a plain narrowing if.

Picking one per codebase

The mistake is mixing all three in the same module. A function
that consumes Option<User>, calls into a Result<Profile,
FetchError>
API, and returns Profile | undefined has paid
the conversion cost three times for nothing. Convert at
boundaries, not inside.

The compiler already does what Optional<T> does in Java.
Pick the shape that matches the absence and let it.


If this was useful

The bridge from Java to TypeScript is full of small choices
like this one — patterns that look like a one-line port and
turn out to be a paradigm shift. Kotlin and Java to
TypeScript
walks through them in order: nullability and
narrowing, sealed classes to discriminated unions, variance
rules, generics that work differently than you expect,
coroutines mapped to async/await, and the JVM-shaped patterns
that survive the trip versus the ones that should be retired.

If you want the broader set, the five-book TypeScript Library
collection covers the type system itself, the foundations,
production tooling, and the JVM and PHP bridges in one place:
xgabriel.com/the-typescript-library.

The TypeScript Library — the 5-book collection

Top comments (0)