SDKs
Forward compatible unions in TypeScript
David Adler
January 11, 2026 - 8 min read
The problem: API evolution
Consider a typical API that returns user-linked accounts:
type LinkedAccount =
| { provider: "google"; email: string; googleId: string }
| { provider: "github"; username: string; githubId: number }
| { provider: "email"; email: string; verified: boolean };Looking at this union, you can probably guess what’s coming. Next quarter, the team adds Apple Sign-In. Then Microsoft. Maybe SAML for enterprise customers. This union will grow.
Now imagine you’ve deployed an SDK with this type baked in. When the server starts returning { provider: "apple", ... }, your application receives a 200 response, but then crashes trying to parse it.
Error: Unknown provider "apple" in LinkedAccountGetting a successful HTTP response and then erroring is confusing. The user’s code might not even care about the provider. They might just be checking if the user is authenticated. But one unexpected value nukes the entire response.
In this article, we’ll discuss how to design unions that are forward compatible, working around TypeScript’s constraints as a structurally typed language where types are sets of values.
A note on API versioning: Proper API versioning can sidestep this problem, but most backends aren’t built that way. Forward-compatible clients are often the more practical solution.
TL;DR: Add an explicit unknown variant with a sentinel discriminator value:
type LinkedAccount =
| { provider: "google"; email: string; googleId: string }
| { provider: "github"; username: string; githubId: number }
| { provider: "email"; email: string; verified: boolean }
| { provider: "UNKNOWN"; raw: unknown };This preserves type narrowing on known variants while gracefully capturing anything new the server sends. The raw field holds the original payload for logging or custom handling.
Approach 1: the starting point
This is what most SDKs and API clients do today. Here’s a typical discriminated union:
type Pet = Dog | Cat;
type Dog = { kind: "dog"; name: string; barkVolume: number };
type Cat = { kind: "cat"; name: string; napsPerDay: number };This looks like clean, type-safe code. TypeScript can narrow based on the kind discriminator:
function describe(pet: Pet): string {
switch (pet.kind) {
case "dog":
return `${pet.name} barks at volume ${pet.barkVolume}`;
case "cat":
return `${pet.name} takes ${pet.napsPerDay} naps daily`;
}
}Try in TypeScript Playground →
The compiler knows exactly which properties exist in each branch. But this type safety is an illusion.
❌ The problem: When the server starts returning parrots, your code throws runtime errors. You had compile-time confidence that didn’t hold at runtime.
Approach 2: the naive solution
The obvious fix is to add unknown to handle future cases:
type Pet = Dog | Cat | unknown;This compiles. Try to use it:
function describe(pet: Pet): string {
if (pet.kind === "dog") {
// Error: 'pet' is of type 'unknown'
}
}Try in TypeScript Playground →
❌ What happened? TypeScript uses structural typing, not nominal typing. Types aren’t defined by their names. They’re defined by the set of values they can hold. A union is the set-theoretic union of its members.
Here’s the key insight: unknown is the top type in TypeScript. It’s the set of all possible values. When you union something with its superset, the superset wins:
Dog | Cat | unknown = unknownThe union collapses. Your carefully crafted discriminated union becomes useless. (Note: any is also a top type with the same absorption behavior
This is set theory being unforgiving. You can’t have a “weaker” type that represents “none of the above” because if it accepts more values, it’s a superset, and supersets absorb their subsets.
Approach 3: the monadic wrapper
A pattern from functional programming, popularized by Rust’s Result type, is to wrap values in a result type:
type Result<T> = { ok: true; value: T } | { ok: false; raw: unknown };
type Pet = Result<Dog | Cat>;Now you can handle unknown cases:
function describe(pet: Pet): string {
if (!pet.ok) {
return `Unknown pet: ${JSON.stringify(pet.raw)}`;
}
switch (pet.value.kind) {
case "dog":
return `${pet.value.name} barks at volume ${pet.value.barkVolume}`;
case "cat":
return `${pet.value.name} takes ${pet.value.napsPerDay} naps daily`;
}
}Try in TypeScript Playground →
This works. Type narrowing is preserved, unknown cases are handled gracefully.
❌ The problem: Consider a realistic domain model where unions appear at multiple levels:
type Pet = Result<Dog | Cat>;
type Dog = { kind: "dog"; name: string; owner: Result<Person | Company> };
type Cat = { kind: "cat"; name: string; owner: Result<Person | Company> };
type Person = { type: "person"; name: string; address: Result<Home | Office> };
type Company = { type: "company"; name: string; address: Result<Home | Office> };
type Home = { location: "home"; city: string };
type Office = { location: "office"; city: string };Every level requires navigating through .value:
function getOwnerCity(pet: Pet): string | undefined {
if (!pet.ok) return undefined;
if (!pet.value.owner.ok) return undefined;
if (!pet.value.owner.value.address.ok) return undefined;
return pet.value.owner.value.address.value.city;
}Compare to what you want to write:
function getOwnerCity(pet: Pet): string | undefined {
return pet.owner.address.city;
}The nesting exposes SDK internals when developers just want to think about their domain. They’re thinking about pets and owners, not about whether the SDK successfully parsed a field.
❌ Another issue: switching a union from closed to open (or vice versa) becomes a breaking change. Adding the Result wrapper changes the type signature, forcing all consumers to update their code.
That said, this pattern works well for unions without a common discriminator, like string | { data: object }. When there’s no property to narrow on, wrapping is the only type-safe option.
Approach 4: discriminator with string fallback
We can do better than approach 3 if we have a common discriminator. The discriminator allows indexing into the union for TypeScript consumption:
type Pet = Dog | Cat | { kind: string; raw: unknown };
type Dog = { kind: "dog"; name: string; barkVolume: number };
type Cat = { kind: "cat"; name: string; napsPerDay: number };The idea: kind: string can hold any value beyond "dog" or "cat", capturing unknown variants from the server.
❌ The problem: Narrowing breaks. When you check pet.kind === "dog", TypeScript can’t narrow to just Dog:
function describe(pet: Pet): string {
if (pet.kind === "dog") {
return pet.barkVolume.toString();
// Error: Property 'barkVolume' does not exist on type
// 'Dog | { kind: string; raw: unknown; }'
}
return "other";
}Try in TypeScript Playground →
Since "dog" is a valid string, the { kind: string; raw: unknown } variant still matches after the check. TypeScript can’t eliminate it from the union. This is the same structural typing problem from approach 2: string is a superset of "dog", so it absorbs the literal.
Approach 5: discriminator with symbol
What if we use a symbol to guarantee no collision?
const UNKNOWN_KIND: unique symbol = Symbol("UNKNOWN_KIND");
type UnknownPet = { kind: typeof UNKNOWN_KIND; raw: unknown };
type Pet = Dog | Cat | UnknownPet;
// Narrowing works correctly
function describe(pet: Pet): string {
switch (pet.kind) {
case "dog":
return `${pet.name} barks at volume ${pet.barkVolume}`;
case "cat":
return `${pet.name} takes ${pet.napsPerDay} naps daily`;
case UNKNOWN_KIND:
return `Unknown pet: ${JSON.stringify(pet.raw)}`;
}
}Try in TypeScript Playground →
This solves the assignability problem. Symbols are completely distinct from string literals.
❌ The problem: Symbols introduce friction:
- Comparison requires imports: Checking for unknown variants forces you to import from the SDK:
import { UNKNOWN_KIND } from "pets-sdk";
if (pet.kind === UNKNOWN_KIND) {
// handle unknown
}- Breaking changes: If the union goes from closed to open, existing code that didn’t account for symbols breaks:
function logPetKind(kind: string) {
console.log(kind);
}
logPetKind(pet.kind);
// Error: Argument of type 'string | typeof UNKNOWN_KIND' is not
// assignable to parameter of type 'string'.- Serialization: Symbols are stripped when serializing, e.g. using
JSON.stringify
Approach 6: the UNKNOWN literal string sentinel
The solution is simpler than the previous approaches:
type Pet = Dog | Cat | { kind: "UNKNOWN"; raw: unknown };
type Dog = { kind: "dog"; name: string; barkVolume: number };
type Cat = { kind: "cat"; name: string; napsPerDay: number };Use an uppercase "UNKNOWN" string literal as the sentinel value.
function describe(pet: Pet): string {
switch (pet.kind) {
case "dog":
return `${pet.name} barks at volume ${pet.barkVolume}`;
case "cat":
return `${pet.name} takes ${pet.napsPerDay} naps daily`;
case "UNKNOWN":
return `Unknown pet: ${JSON.stringify(pet.raw)}`;
}
}Try in TypeScript Playground →
Why this works:
"UNKNOWN"is a distinct literal, not a supertype of other values- Assignability is preserved:
Dogclearly doesn’t match{ kind: "UNKNOWN" } - No imports needed for comparison
- Works with object keys and serialization
❌ The problem: What if the API later documents kind: "UNKNOWN" as a legitimate value? You’d need to rename the sentinel to "_UNKNOWN_" or similar, which is a breaking change for SDK consumers. We detect this during code generation and adjust accordingly, but it’s something to be aware of.
What we use in Speakeasy TypeScript SDKs
In the Speakeasy SDK generator:
- When we can infer a discriminator: We use approach 6 (the
"UNKNOWN"sentinel). We have a smart algorithm for inferring discriminators inoneOf/anyOfschemas. It finds a property that’s present on all variants with distinct literal values. - When there’s no common discriminator: We fall back to approach 3 (the monadic wrapper). It’s not as ergonomic, but it’s the only type-safe option for polymorphic unions like
string | { data: object }.
The discriminator inference looks for:
// Detected: `a` is the discriminator
{ a: "x" } | { a: "y" }
// Not detected: no single property distinguishes all variants
{ a: "x"; b: 1 } | { a: "x"; c: 1 } | { b: 1; c: 1 }There are no perfect solutions here, only tradeoffs. Pick the approach that fits your constraints.
If you’re building SDKs and want forward-compatible unions out of the box, check out Speakeasy