Why Discriminated Unions Matter

When I first started using TypeScript to model data from external services, I found myself repeatedly writing if (response.type === 'user') { … } else if (response.type === 'post') { … }. The repetitive casting and any sprinkled throughout made the code hard to follow and prone to runtime errors.

Discriminated unions give us a way to describe that a value can be one of several shapes, each with a common property that uniquely identifies its shape. By pairing the type definition with a focused type guard, we let TypeScript narrow the union at compile time, eliminating many of those runtime checks and making the intent crystal clear.

Building the Types

Let’s assume we have two endpoints: one that returns a user profile and another that returns a blog post. Both carry a type field that we can rely on as the discriminant.

interface User {
    type: 'user';
    id: number;
    name: string;
    email: string;
}

interface Post {
    type: 'post';
    id: number;
    title: string;
    body: string;
}

type ApiResponse = User | Post;

Notice that each member of the union shares the exact value for type. This is the discriminator. TypeScript will automatically treat the property as a discriminant when it has the same literal value across all union members, allowing us to narrow the type using a guard.

Writing Type Guards

A type guard is just a function that tells TypeScript "if this returns true, the value must be of this shape". The simplest guard for a discriminated union is a runtime check against the discriminator property.

function isUser(response: ApiResponse): response is User {
    return response.type === 'user';
}

function isPost(response: ApiResponse): response is Post {
    return response.type === 'post';
}

With these guards in place, we can safely narrow the union in our code:

async function handleApiCall(data: ApiResponse) {
    if (isUser(data)) {
        console.log(`Fetching user ${data.name} (${data.email})`);
        // TypeScript knows data is User here
        return data.id;
    }

    if (isPost(data)) {
        console.log(`Reading post "${data.title}"`);
        // TypeScript knows data is Post here
        return data.id;
    }

    // This line is unreachable because ApiResponse only contains the two above,
    // but TypeScript still thanks us for exhaustive checking.
    throw new Error('Unexpected response type');
}

The response is User syntax is crucial. It tells TypeScript that after the guard passes, the argument is narrowed to the named type, enabling precise typing without additional casting.

Putting It All Together – A Real‑World Scenario

In my current project we fetch either a user profile or a post from a REST API and display them in a unified feed. Because the UI needs to render different components for each shape, we use the discriminated union pattern to keep the component logic clean.

First, we define a generic service that returns our ApiResponse:

class ApiService {
    static async fetchItem(id: number): Promise {
        const res = await fetch(`/api/items/${id}`);
        return res.json();
    }
}

Then, in the React component, we call the service and let the guards drive the rendering:

function ItemDisplay({ id }: { id: number }) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        ApiService.fetchItem(id)
            .then(setData)
            .finally(() => setLoading(false));
    }, [id]);

    if (loading) return ;

    if (!data) return ;

    if (isUser(data)) {
        return ;
    }

    if (isPost(data)) {
        return ;
    }

    // Exhaustive check – TypeScript will flag any missing case.
    return ;
}

Notice that each branch receives a fully typed object. If the API ever returns a third shape (e.g., 'comment'), the guard isPost will not match, and TypeScript will flag the missing case, prompting us to add a new guard and UI component before we can even run the app.

When to Use This Pattern

  • When you have a single source of truth that can represent multiple shapes (API responses, database rows, configuration objects).
  • When you need to dispatch different logic based on the shape at runtime, but you want compile‑time safety.
  • When the discriminant is a literal value (like 'user' or 'post') that is unlikely to change.

If the discriminator is a runtime‑computed value (e.g., a numeric code), consider using a utility type guard that normalizes the data before exposing it, ensuring the union remains predictable.

Tip: Keep your guards simple and focused on the discriminant. Avoid complex logic inside a guard unless you need to perform side‑effects; otherwise you risk obscuring the intent and making TypeScript’s narrowing less obvious.

Conclusion

Discriminated unions paired with type guards give us a powerful, type‑safe way to model polymorphic data. By encoding the shape’s identity in a common property and writing concise guards, we eliminate the need for repetitive casting and any while making the code self‑documenting. The pattern scales nicely with new response types, and TypeScript’s exhaustive checking ensures we never forget to handle a case. In my daily work, this approach has turned chaotic API handling into clean, maintainable code that both the compiler and future developers can trust.