Recommended resources
The freely available resources this lesson will direct you to.
Hello again. Last lesson, you followed asynchronous results and errors through promises and async functions. An awaited operation can also produce different kinds of successful result. TypeScript helps you describe those possibilities and write code that handles each one safely.
In this lesson, you’ll narrow union types using ordinary JavaScript checks, then design discriminated unions whose branches TypeScript can distinguish reliably. This is useful when working with API results, application state, and the kinds of code-reading questions that appear in full-stack interviews.
A union describes possibilities, not a value you can use in every way
Suppose a function accepts an ID as either a number or a string. Its parameter has the type number | string, but TypeScript cannot let us call a number-only method until we establish which value we have:
function formatId(id: number | string): string {
// return id.toFixed(0); // Error: a string has no toFixed method
if (typeof id === "number") {
return id.toFixed(0);
}
return id.toUpperCase();
}
The typeof check runs in JavaScript. TypeScript examines that check and the surrounding control flow: inside the if, id is a number; after the if, it must be a string because the number branch returned. This refinement of a broad type to a more specific one is narrowing. It changes what the checker knows at each point in the program; it does not convert the value.
Watch the relevant portions of Lucas Paganini’s Fundamental Type Guards – TypeScript Narrowing #2 for the runtime checks and the reasoning TypeScript performs after an early return.
Fundamental Type Guards - TypeScript Narrowing #2
Lucas Paganini’s video shows how familiar JavaScript checks become TypeScript type guards, then connects those checks to control-flow analysis.
Watch the typeof guard and notice why returning from the string branch leaves only Error afterward. Continue with the property check using in. Skip to null and truthiness, then watch remaining possibilities for the explanation of reachability and instanceof. Focus on what is known inside a branch versus after it returns.
The choice of check matters. If an ID may also be null, check that explicitly before using it:
function formatId(id: number | string | null): string {
if (id === null) {
return "No ID";
}
if (typeof id === "number") {
return id.toFixed(0);
}
return id.toUpperCase();
}
Here, equality removes null, and the later typeof check separates the remaining types. A truthiness check such as if (!id) would be a different condition: it also catches the valid values 0 and "". Similarly, typeof value === "object" does not rule out null, because JavaScript reports typeof null as "object". Use a check that matches the distinction your program actually needs.
For object unions, a property-presence check can help when one member has a distinctive property:
type ApiIssue =
| { message: string }
| { errors: string[] };
function describeIssue(issue: ApiIssue): string {
if ("errors" in issue) {
return issue.errors.join(", ");
}
return issue.message;
}
The in operator checks for a property at runtime. Given these types, TypeScript knows that the first branch handles the member with errors. This is less decisive if both union members may have the property—for example, if one declares it as optional. instanceof Error is another useful guard for an actual Error instance, but it is not a test for a plain JSON object received from an API.
Put the relationship between fields into the type
Consider a result for loading a user’s orders. A tempting first definition puts every possible field into one object:
type OrderResult = {
status: "success" | "not-found";
orders?: { id: number }[];
userId?: number;
};
We might intend that "success" always has orders and "not-found" always has userId. The type does not say that. It permits { status: "success" }. Consequently, checking result.status === "success" cannot establish that result.orders exists.
Instead, define each valid shape separately and join them with a union:
type OrderResult =
| { status: "success"; orders: { id: number }[] }
| { status: "not-found"; userId: number };
function describeResult(result: OrderResult): string {
if (result.status === "success") {
return `${result.orders.length} orders`;
}
return `No orders for user ${result.userId}`;
}
Each member has a shared status field, but with a different literal type. That field is the discriminant. The success branch guarantees orders; because it returns, the final line handles only the not-found branch. No non-null assertion is needed.
The supplied TypeScript screenshot illustrates the same structure with Circle and Square: their kind fields distinguish the members of Shape, allowing the visible "circle" branch to access radius.
Watch Andrew Burgess’s discriminated-union segment, then read the TypeScript Handbook’s comparison between the “one object with optional fields” approach and separate union members. The comparison explains why the second model works, rather than just showing syntax that compiles.
Andrew Burgess’s Type Narrowing in TypeScript demonstrates how checking a shared literal field narrows an object union, including a switch that handles its cases.
Watch discriminated unions. Pay particular attention to what the compiler knows within each switch case and why a shared field with distinct literal values makes that possible.
TypeScript: Documentation - Narrowing
The TypeScript Handbook develops the shape example from an unsafe optional-property model into a discriminated union. Read it to connect the type definition to the checks you can safely write.
In “Discriminated unions,” begin after the opening paragraph at the shape introduction: the shape comparison. Read through the first Shape definition, its unsuccessful kind check, the separate Circle and Square definitions, and the complete getArea switch. Stop after the paragraph explaining why the encoding of Shape was crucial, before the aside about switch fallthrough. Focus on what the first definition fails to guarantee about radius.
This design carries a practical distinction from the previous lesson. An async order-loading function might resolve to either OrderResult member, and you can narrow the value after await. If the function’s promise rejects, though, execution does not receive an OrderResult to narrow: the rejection still follows the error path you studied last lesson. A tagged "not-found" result is one way to represent an expected outcome; it does not automatically turn network failures or thrown exceptions into typed results.
TypeScript also does not validate incoming JSON merely because you assign it a type. Narrowing is trustworthy when the value really satisfies the declared union. At an untrusted API boundary, runtime validation is a separate responsibility.
Use switch to make missing cases visible
As a result model grows, a switch keeps its branches easy to inspect. Add a third member for an expected error that the application intends to display:
type OrderResult =
| { status: "success"; orders: { id: number }[] }
| { status: "not-found"; userId: number }
| { status: "error"; message: string };
function describeResult(result: OrderResult): string {
switch (result.status) {
case "success":
return `${result.orders.length} orders`;
case "not-found":
return `No orders for user ${result.userId}`;
case "error":
return result.message;
}
}
In each case, TypeScript narrows result to the member whose status matches. Suppose another developer later adds { status: "loading" } to OrderResult but forgets this function. We can make that omission an explicit compile-time error using never, the type for a value that should be impossible at that point:
function assertNever(value: never): never {
throw new Error(`Unexpected result: ${JSON.stringify(value)}`);
}
function describeResult(result: OrderResult): string {
switch (result.status) {
case "success":
return `${result.orders.length} orders`;
case "not-found":
return `No orders for user ${result.userId}`;
case "error":
return result.message;
default:
return assertNever(result);
}
}
With the three current members handled, nothing remains for default, so result is never there. Add a "loading" member without adding a case, and TypeScript reports that the remaining member cannot be passed to assertNever. The thrown error is also a safeguard if an unexpected value reaches this code at runtime; it is not a substitute for validating untrusted input.
Read the Handbook’s short exhaustiveness example to see the same check applied when a shape union gains a Triangle.
TypeScript: Documentation - Narrowing
This continuation of the Handbook shows why assigning an unhandled union member to never detects a missing switch case.
In “Exhaustiveness checking,” start with the explanation of using never in a switch: the missing-case example. Read through both getArea examples, including the compiler error after Triangle is added; stop before the page-navigation links. Compare the default branch with the assertNever call above: both require all declared members to have been handled.
Key takeaways
Narrowing combines a runtime check with TypeScript’s knowledge of which paths are reachable. Use typeof, equality, in, or instanceof when they test the distinction you need; remember that truthiness can discard valid falsy values.
For related object shapes, give each variant its own required fields and a shared discriminant with a distinct literal value. Checking that field makes the corresponding fields safe to access. An exhaustive switch with a never check then alerts you when a newly added variant needs handling.
Next, you’ll build reusable type-safe functions with generics and constraints—a way to preserve information about a caller’s type without writing a separate function for every possible type.