TL;DR
- Narrowing = TypeScript eliminating types based on runtime checks.
- Discriminated unions = unions with a shared literal property (the "tag") that TS uses for exhaustive switching.
nevertype = "this should be impossible" — use for exhaustiveness checking.- These patterns are the #1 way to write safe TypeScript without
anyor type assertions.
Step 1: How Narrowing Works
Narrowing is TypeScript's killer feature — it's what makes the type system practical rather than just annotations. The compiler tracks control flow and eliminates impossible types after each check. Without narrowing, you'd need explicit type assertions everywhere (value as string), which are unsafe. Narrowing makes TypeScript genuinely catch bugs: if you forget a null check, the type system shows the error before runtime. Understanding narrowing mechanics (typeof, truthiness, equality, in, instanceof) is essential for writing TypeScript that "just works" without any escape hatches.
TypeScript tracks which types are possible at each point in your code. Every control flow statement that checks a type narrows it:
function process(value: string | number | null) {
// Here: value is string | number | null
if (value === null) {
// Here: value is null
return;
}
// Here: value is string | number (null eliminated)
if (typeof value === "string") {
// Here: value is string
console.log(value.toUpperCase());
} else {
// Here: value is number
console.log(value.toFixed(2));
}
}
All Narrowing Mechanisms
| Mechanism | Example | What it narrows |
|---|---|---|
typeof |
typeof x === "string" |
Primitives |
instanceof |
x instanceof Date |
Class instances |
in operator |
"name" in x |
Objects with property |
| Equality | x === null |
Specific values |
| Truthiness | if (x) |
Removes null, undefined, 0, "" |
| Assignment | x = "hello" |
To assigned type |
| Type predicate | isString(x) |
Custom narrowing |
Step 2: Type Guards (Custom Narrowing)
Type guards let you write runtime checks that TypeScript understands at the type level. They exist because typeof only handles primitives and instanceof only handles classes — but real code works with interfaces, union types, and complex shapes that need custom validation logic. Every API response handler, form validator, and event processor uses type guards to safely distinguish between different shapes of data without resorting to any or unsafe casts.
typeof Guards
function double(x: string | number): string | number {
if (typeof x === "string") {
return x + x; // string concatenation
}
return x * 2; // number multiplication
}
instanceof Guards
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
function handleError(error: Error | ApiError) {
if (error instanceof ApiError) {
// Narrowed to ApiError — can access .status
console.log(`HTTP ${error.status}: ${error.message}`);
} else {
// Just a regular Error
console.log(error.message);
}
}
in Operator Guards
interface Bird { fly(): void; layEgg(): void; }
interface Fish { swim(): void; layEgg(): void; }
function move(animal: Bird | Fish) {
if ("fly" in animal) {
animal.fly(); // Narrowed to Bird
} else {
animal.swim(); // Narrowed to Fish
}
}
Step 3: User-Defined Type Predicates
Type predicates (x is SomeType) were invented because TypeScript can't automatically infer narrowing from helper functions. If you extract a check into a separate function, TypeScript loses the narrowing unless you annotate the return type as a predicate. This pattern is essential for reusable validation logic — checking if an API response is valid, if a user has admin permissions, or if a value from unknown is a specific shape. They're also how libraries like Zod and io-ts provide type-safe parsing.
When built-in narrowing isn't enough, write a type predicate function:
// The return type `x is string` tells TS to narrow in the caller
function isString(x: unknown): x is string {
return typeof x === "string";
}
function process(value: unknown) {
if (isString(value)) {
// value is now string! TS narrowed it.
console.log(value.toUpperCase());
}
}
// Practical: Validate API response
interface User {
id: number;
name: string;
email: string;
}
function isUser(data: unknown): data is User {
return (
typeof data === "object" &&
data !== null &&
"id" in data &&
"name" in data &&
"email" in data &&
typeof (data as any).id === "number" &&
typeof (data as any).name === "string"
);
}
async function fetchUser(id: string) {
const res = await fetch(`/api/users/${id}`);
const data: unknown = await res.json();
if (isUser(data)) {
// data is User — fully typed, safely narrowed
console.log(data.name);
}
}
asserts predicates
function assertDefined<T>(value: T | null | undefined, msg?: string): asserts value is T {
if (value === null || value === undefined) {
throw new Error(msg ?? "Value is not defined");
}
}
function process(user: User | null) {
assertDefined(user, "User must exist");
// After this line, user is User (not null)
console.log(user.name);
}
Step 4: Discriminated Unions
Discriminated unions are arguably TypeScript's most powerful modeling tool. They exist because real domains have mutually exclusive states: an HTTP response is either success or error, a payment is pending or completed or failed. Traditional OOP uses inheritance for this, but discriminated unions with a literal "tag" property let TypeScript narrow types exhaustively in switch statements. This pattern replaced entire categories of runtime bugs and is how React models actions in reducers, how tRPC models router outputs, and how every well-typed state machine works.
The most important pattern in TypeScript for modeling domain logic safely.
The Pattern
- Define a union where each member has a shared literal property (the discriminant/tag)
- Switch on that property — TS narrows to the correct member
// Step 1: Define tagged types
type ApiResponse =
| { status: "success"; data: User; timestamp: number }
| { status: "error"; error: string; code: number }
| { status: "loading" };
// Step 2: Switch on the tag
function handleResponse(response: ApiResponse) {
switch (response.status) {
case "success":
// TS knows: { status: "success"; data: User; timestamp: number }
console.log(response.data.name);
console.log(response.timestamp);
break;
case "error":
// TS knows: { status: "error"; error: string; code: number }
console.log(`Error ${response.code}: ${response.error}`);
break;
case "loading":
// TS knows: { status: "loading" } — no other properties
console.log("Loading...");
break;
}
}
Step 5: Exhaustiveness Checking with never
Exhaustiveness checking ensures you handle every possible case in a union — and more importantly, it makes the compiler error when someone adds a new case you haven't handled. Without it, adding a new variant to a union silently falls through unhandled. The never type trick (assigning the narrowed value to never) catches these at compile time rather than production. This is why discriminated unions + exhaustiveness are the recommended pattern for state machines, reducers, and protocol handlers.
The never type represents "this code should never execute." Use it to ensure you handle ALL cases:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rect":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// If all cases handled, shape is 'never' here
const _exhaustive: never = shape;
return _exhaustive;
}
}
// If you add a new shape kind and forget to handle it,
// TS gives a compile error at the `never` assignment! 🎯
Helper function for exhaustiveness
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
// Use in switch default
default:
return assertNever(shape);
Step 6: Real-World Patterns
These patterns bring narrowing and discriminated unions together in production scenarios. State machines model UI states (loading, error, success) so you can't accidentally access data before it's loaded. API response typing ensures error responses and success responses are handled differently. These patterns are what separate "TypeScript with any everywhere" from TypeScript that genuinely prevents bugs — they're used in every modern React app, backend service, and CLI tool.
Pattern 1: State Machines
type AuthState =
| { state: "idle" }
| { state: "authenticating"; email: string }
| { state: "authenticated"; user: User; token: string }
| { state: "error"; message: string; retryCount: number };
type AuthAction =
| { type: "LOGIN"; email: string; password: string }
| { type: "LOGIN_SUCCESS"; user: User; token: string }
| { type: "LOGIN_FAILURE"; message: string }
| { type: "LOGOUT" };
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case "LOGIN":
return { state: "authenticating", email: action.email };
case "LOGIN_SUCCESS":
return { state: "authenticated", user: action.user, token: action.token };
case "LOGIN_FAILURE":
return {
state: "error",
message: action.message,
retryCount: state.state === "error" ? state.retryCount + 1 : 1,
};
case "LOGOUT":
return { state: "idle" };
}
}
Pattern 2: Result Type (Error Handling without Exceptions)
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return { ok: false, error: "Division by zero" };
return { ok: true, value: a / b };
}
const result = divide(10, 2);
if (result.ok) {
console.log(result.value); // TS knows value exists
} else {
console.log(result.error); // TS knows error exists
}
Pattern 3: Event System
type AppEvent =
| { type: "user.created"; payload: { id: string; name: string } }
| { type: "user.deleted"; payload: { id: string } }
| { type: "post.published"; payload: { postId: string; authorId: string } };
type EventHandler<T extends AppEvent["type"]> = (
payload: Extract<AppEvent, { type: T }>["payload"]
) => void;
// Handler is fully typed based on event name
const handler: EventHandler<"user.created"> = (payload) => {
console.log(payload.name); // ✅ TS knows about name
};
Interview Questions
-
What's a discriminated union?
- A union type where each member has a common literal property (the discriminant). TypeScript uses it to narrow the type in switch/if statements.
-
How do you enforce exhaustive handling?
- Use a
nevertype in the default case. If a new variant is added but not handled, TS gives a compile error because the value can't be assigned tonever.
- Use a
-
What's the difference between
unknownandany?anydisables type-checking.unknownis type-safe: you MUST narrow it before using it. Always preferunknownfor values of truly unknown type.
-
When would you use a type predicate vs
instanceof?instanceofonly works with classes. Type predicates work with interfaces, unions, and any custom validation logic.