Page Content

Tutorials

What is a Discriminated Union in TypeScript?

Discriminated Union in TypeScript

A pattern for joining two or more object types that have a single-literal property in common is called a discriminating union. This common characteristic is referred to as the type tag, discriminant, or discriminant property. TypeScript can restrict the union to a single, particular member type by looking at the value of this tag attribute.

Structure of a Discriminated Union

Each member interface or type alias inside the union must have a property with a distinct string, integer, or boolean literal type in order for the union to be considered discriminatory. Kindness is a common choice for the discriminant name.

When modelling data structures where a value can have multiple distinct, mutually exclusive representations, this pattern works very well.

Consider constructing geometric forms, for example, where each shape object has unique features but all of them share a kind property:

// Define member types with unique literal properties
interface Square {
    kind: "square"; // Discriminant: "square"
    size: number;
}
interface Rectangle {
    kind: "rectangle"; // Discriminant: "rectangle"
    width: number;
    height: number;
}
// Combine them into a union type
type Shape = Square | Rectangle;

Type Narrowing using the Discriminant

Without checks, it is dangerous to access a property that is only present on a subset of union members when working with a union type like Shape. However, TypeScript automatically narrows the types when it comes across a conditional check on the literal discriminant property (using comparison operators like === or switch). You can securely access the member type-specific characteristics after the type has been narrowed down.

The kind property, for instance, is used by a function that calculates a shape’s area to identify the available dimension properties:

function area(s: Shape): number {
    if (s.kind === "square") {
        // Inside this block, TypeScript knows 's' is a Square.
        // We can safely access 's.size'.
        return s.size * s.size; // OK
    } else {
        // If it wasn't a square, TypeScript figures out it must be a Rectangle 
        // because "square" and "rectangle" are mutually exclusive members
        // of the Shape union [16].
        return s.width * s.height; // OK
    }
}

// Example Usage (Runtime output):
// area({ kind: "square", size: 5 })  // Output: 25
// area({ kind: "rectangle", width: 4, height: 6 }) // Output: 24

The type system automatically determines that s must be a rectangle in the above otherwise else block. The key to the power of discriminated unions is their ability to restrict the type according to a certain literal property value.

Never type in TypeScript 

In TypeScript, the never type is essentially referred to as a falsum or the bottom type. It represents the empty set of values and is located at the very bottom of the type hierarchy.

A variable or location assigned the never type signifies that it cannot hold any value whatsoever. Consequently, never can only be assigned to another never, and no other type (except possibly any) can be assigned to never. However, never is a subtype of every other type, meaning it can be assigned anywhere safely (though this has largely theoretical significance for the programmer).

When it is mathematically demonstrated that a function’s execution path will never end gracefully or yield a value, the never type automatically appears:

  1. Functions that always throw an error: The return type is assumed to be never.
  2. Functions containing infinite loops: The return type is always the same because the execution never ends.
  3. Code paths exhausted by control flow analysis: The resulting type is never used if TypeScript’s code flow analysis finds that a variable can no longer contain any values from its specified type set.

Using for Compile-Time Exhaustive Checks

Compile-time exhaustive checks are the primary use case for mixing discriminatory unions and the never type. By requiring the developer to treat every scenario inside a union, this pattern guarantees runtime safety and offers a robust defence against the introduction of additional, unhandled data types in the future.

The inferred type in the last, unreached branch should, in theory, never be if a union type is completely narrowed using conditional tests (such as if/else if/else or switch statements).

Catching Missing Union Members

Let’s add Circle as a new member to the Shape union:

interface Square {
    kind: "square";
    size: number;
}
interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}
interface Circle {
    kind: "circle";
    radius: number;
}
// Updated union type
type Shape = Square | Rectangle | Circle;

TypeScript can indicate this absence by using never if we neglect to update the area function to incorporate logic for Circle. In the unhandled path, we add a certain variable assignment to initiate this check:

function area(s: Shape): number {
    if (s.kind === "square") {
        return s.size * s.size;
    } else if (s.kind === "rectangle") {
        return s.width * s.height;
    }
    
    // In this remaining else path, 's' is inferred as type 'Circle'.
    
    // Attempting an exhaustive check using the never type:
    // If we hadn't handled Circle, the compiler complains:
    
    const _exhaustiveCheck: never = s; 
    // Output/Error (if Circle wasn't handled): 
    // Error: Type 'Circle' is not assignable to type 'never'.
    
    // Note: The function would also need a final return or throw
    // to satisfy compilation if 'noImplicitReturns' is true.
}

The error Type ‘Circle’ is not assignable to type ‘never’ confirms that the variable s still holds the potential type Circle in that code path, thus alerting the developer that the new type has not been handled.

Preventing Unreachable Code Paths

It is necessary to explicitly address every possibility in order to pass the exhaustive check. The variable s appropriately narrows to never in the last block after Circle is added, and the compiler passes the check, guaranteeing that no union members were inadvertently overlooked.

Implementing this with a switch statement and using the default case for the exhaustive check is frequently cleaner:

// Assuming Shape = Square | Rectangle | Circle

function areaExhaustive(s: Shape): number {
    switch (s.kind) {
        case "square": 
            return s.size * s.size;
        case "rectangle": 
            return s.width * s.height;
        case "circle":
            return Math.PI * (s.radius ** 2);
        default:
            // At this point, 's' must be 'never' because all defined cases 
            // ('square', 'rectangle', 'circle') have been covered.
            const _exhaustiveCheck: never = s; 
            
            // Note on 'strictNullChecks' and 'noImplicitReturns':
            // If the strictNullChecks flag is active, the compiler might complain
            // "not all code paths return a value" if the default case throws an error 
            // (since throwing results in a 'never' return type).
            // To satisfy strict return requirements, we can return the never variable:
            return _exhaustiveCheck;
            
            // If strict return checks are not an issue, throwing a runtime error
            // using a never-returning function is also a common and effective pattern:
            // throw new Error("Unexhaustive!");
    }
}

// Example Usage:
console.log(areaExhaustive({ kind: "square", size: 10 }));
console.log(areaExhaustive({ kind: "circle", radius: 2 })); 

Output (Console):

100
12.566370614359172

In this final structure, the compiler guarantees that every member of the Shape union is accounted for. If a new member were added to Shape (e.g., Triangle), the default case would fail the assignment to _exhaustiveCheck: never, forcing the developer to address the new case immediately. This usage effectively prevents code paths that should never be reached from compiling successfully.

Using a Throwing Function

Creating a utility function that explicitly returns never by consistently throwing an error is a related concept. Using TypeScript’s knowledge of functions that never return, this function can then be used in the unhandled path to indicate that this code should never be run.

// Define a function that explicitly returns never
function fail(message: string): never { 
    throw new Error(message); 
}

// Implement the exhaustive check using 'fail'
function processShape(s: Shape): string {
    switch (s.kind) {
        case "square": 
            return `A square of size ${s.size}`;
        case "rectangle": 
            return `A rectangle ${s.width}x${s.height}`;
        case "circle":
            return `A circle of radius ${s.radius}`;
        default:
            // If a new type is introduced, 's' here is the new unhandled type,
            // resulting in a compile error because 'fail' expects never.
            return fail("Unhandled shape type!"); // If exhaustive, 's' is 'never' and this is fine 
    }
}

Because the fail function is used for runtime safety/exhaustive tests, this pattern takes advantage of TypeScript’s understanding that it never returns, which enables it to call it even when the code path technically requires a return value.

Developers can achieve great type safety, particularly when working with dynamic or developing data structures, by utilising the never type to enforce exhaustiveness and discriminated unions to enable fine-grained type narrowing.

Index