# open-unions-typescript-type-theory
Source: https://speakeasy.com/blog/open-unions-typescript-type-theory


## The problem: API evolution

Consider a typical API that returns user-linked accounts:

```typescript
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 LinkedAccount
```

Getting 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.

At Speakeasy, we’re obsessed with building the best clients, and forward compatibility is a core part of that work. TypeScript’s structural type system introduces subtle constraints that make designing for forward compatibility non-trivial. In this article, we examine six approaches to designing forward-compatible unions, discuss the tradeoffs of each, and explain the approach we ultimately chose.

**A note on API versioning:** Proper [API versioning](/blog/building-apis-and-sdks-that-never-break) 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:

```typescript
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:

```typescript
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:

```typescript
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`;
  }
}
```

<OpenTypeScriptPlayground withCode={`type Pet = Dog | Cat;

type Dog = { kind: "dog"; name: string; barkVolume: number };
type Cat = { kind: "cat"; name: string; napsPerDay: number };

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\`;
  }
}`} />

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:

```typescript
type Pet = Dog | Cat | unknown;
```

This compiles. Try to use it:

```typescript
function describe(pet: Pet): string {
  if (pet.kind === "dog") {
    // Error: 'pet' is of type 'unknown'
  }
}
```

<OpenTypeScriptPlayground withCode={`type Dog = { kind: "dog"; name: string; barkVolume: number };
type Cat = { kind: "cat"; name: string; napsPerDay: number };

type Pet = Dog | Cat | unknown;

function describe(pet: Pet): string {
  if (pet.kind === "dog") {
    // Error: 'pet' is of type 'unknown'
  }
  return "";
}`} />

❌ 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 = unknown
```

The union collapses. Your carefully crafted discriminated union becomes useless. (Note: `any` is also a top type with the [same absorption behavior](https://www.totaltypescript.com/an-unknown-cant-always-fix-an-any), but less safe.)

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:

```typescript
type Result<T> = { ok: true; value: T } | { ok: false; raw: unknown };

type Pet = Result<Dog | Cat>;
```

Now you can handle unknown cases:

```typescript
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`;
  }
}
```

<OpenTypeScriptPlayground withCode={`type Dog = { kind: "dog"; name: string; barkVolume: number };
type Cat = { kind: "cat"; name: string; napsPerDay: number };

type Result<T> = { ok: true; value: T } | { ok: false; raw: unknown };

type Pet = Result<Dog | Cat>;

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\`;
  }
}`} />

This works. Type narrowing is preserved, unknown cases are handled gracefully.

❌ **The problem:** Consider a realistic domain model where unions appear at multiple levels:

```typescript
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`:

```typescript
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:

```typescript
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:

```typescript
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`:

```typescript
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";
}
```

<OpenTypeScriptPlayground withCode={`type Pet = Dog | Cat | { kind: string; raw: unknown };

type Dog = { kind: "dog"; name: string; barkVolume: number };
type Cat = { kind: "cat"; name: string; napsPerDay: number };

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";
}`} />

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?

```typescript
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)}`;
  }
}
```

<OpenTypeScriptPlayground withCode={`type Dog = { kind: "dog"; name: string; barkVolume: number };
type Cat = { kind: "cat"; name: string; napsPerDay: number };

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)}\`;
  }
}`} />

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:

```typescript
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:

```typescript
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:

```typescript
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.

```typescript
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)}`;
  }
}
```

<OpenTypeScriptPlayground withCode={`type Pet = Dog | Cat | { kind: "UNKNOWN"; raw: unknown };

type Dog = { kind: "dog"; name: string; barkVolume: number };
type Cat = { kind: "cat"; name: string; napsPerDay: number };

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)}\`;
  }
}`} />

Why this works:

- `"UNKNOWN"` is a distinct literal, not a supertype of other values
- Assignability is preserved: `Dog` clearly 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 in `oneOf`/`anyOf` schemas. 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:

```typescript
// 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](https://www.speakeasy.com)._