Chapter 26: TypeScript Type Guards
TypeScript Type Guards very calmly and thoroughly, like we’re sitting together with VS Code open, slowly typing examples, and understanding exactly why this feature feels magical when you first “get it”.
Type Guards are one of the most important and most frequently used advanced features in real TypeScript code — especially in 2025–2026 when dealing with APIs, forms, state, unions, unknown, any, third-party data, etc.
1. The core idea in one honest sentence
A type guard is a runtime check that tells the TypeScript compiler: “After this check passes, I now know more about the type — please narrow it down for the rest of this block.”
It’s a bridge between runtime reality and compile-time safety.
Without type guards → you are forced to use as assertions everywhere (dangerous). With type guards → TypeScript narrows the type automatically in the true branch.
2. The four most common kinds of type guards (in order of how often you’ll use them)
A. typeof checks (the simplest & most frequent)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function printLength(value: string | number) { if (typeof value === "string") { // value is now string (narrowed!) console.log(value.toUpperCase()); // safe console.log(value.length); // safe } else { // value is now number console.log(value.toFixed(2)); // safe } } |
Very common when:
- Dealing with form inputs (string | number)
- API responses (unknown → narrow to string / number / object)
- Union types with primitives
B. instanceof checks (for classes)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class Cat { meow() { console.log("meow"); } } class Dog { bark() { console.log("woof"); } } function makeSound(animal: Cat | Dog) { if (animal instanceof Cat) { animal.meow(); // TS knows it's Cat } else { animal.bark(); // TS knows it's Dog } } |
Very common when:
- Working with class hierarchies
- DOM elements (HTMLElement vs HTMLInputElement)
- Custom error classes (AppError vs ValidationError)
C. Property / key existence checks (in operator)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
type Admin = { role: "admin"; permissions: string[] }; type Guest = { role: "guest" }; type User = Admin | Guest; function showDashboard(user: User) { if ("permissions" in user) { // user is now Admin console.log("Welcome admin!", user.permissions.join(", ")); } else { // user is now Guest console.log("Limited view for guests"); } } |
Very common when:
- Discriminated unions (see next point)
- Optional / variant object shapes
- JSON / API data that sometimes has extra fields
D. Discriminated unions + literal property check (the king in 2026)
This is the most powerful and most used pattern in modern TypeScript.
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
type SuccessResponse = { status: "success"; data: { id: number; name: string }; timestamp: Date; }; type ErrorResponse = { status: "error"; message: string; code: number; }; type ApiResponse = SuccessResponse | ErrorResponse; function handleApiResponse(res: ApiResponse) { if (res.status === "success") { // res is now SuccessResponse console.log("Data received:", res.data.name); } else { // res is now ErrorResponse console.log("Failed:", res.message, "(code:", res.code, ")"); } } |
Key requirements for discriminated union narrowing:
- A literal property (usually called kind, type, status, _tag, tag, etc.)
- The property has literal types (“success” | “error”, not just string)
- All members of the union have the same property name
3. User-defined / custom type guards (when built-in checks are not enough)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function isStringArray(value: unknown): value is string[] { return ( Array.isArray(value) && value.every(item => typeof item === "string") ); } function process(items: unknown) { if (isStringArray(items)) { // items is now string[] items.map(s => s.toUpperCase()); // safe & autocompleted } else { console.log("Not a string array"); } } |
Modern style (TS 5.5+ inferred type predicates)
|
0 1 2 3 4 5 6 7 8 9 |
function isNumberArray(value: unknown) { return Array.isArray(value) && value.every(v => typeof v === "number"); // ↑ TS 5.5+ infers: value is number[] } |
Very common in:
- Form validation
- API response parsing (before Zod/valibot)
- State / Redux action narrowing
- Third-party library data
4. Quick reference table (keep this in your mind)
| Guard type | Syntax example | Narrows to… | Most common use-case |
|---|---|---|---|
| typeof | typeof x === “string” | string / number / boolean / … | Primitives, form values, unknown → primitive |
| instanceof | x instanceof Error | specific class | Custom errors, DOM nodes |
| in operator | “age” in obj | object with that property | Optional fields, variant shapes |
| Discriminated union | res.kind === “loading” | matching union member | State machines, API results, Redux actions |
| Custom type guard | isUser(obj): obj is User | whatever you declare | Complex runtime checks, parsing |
5. Very common real-world patterns in 2026
Pattern 1: API fetch with unknown → narrowed
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
async function fetchUser(id: number): Promise<User | null> { const res = await fetch(`/api/users/${id}`); const data = await res.json(); // unknown if (isUser(data)) { return data; // User } return null; } function isUser(value: unknown): value is User { return ( value != null && typeof value === "object" && "id" in value && "name" in value && typeof (value as any).id === "number" && typeof (value as any).name === "string" ); } |
Pattern 2: Loading / Success / Error state (very frequent)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
type State = | { status: "idle" } | { status: "loading" } | { status: "success"; data: Todo[] } | { status: "error"; error: string }; function render(state: State) { switch (state.status) { case "idle": return <div>Ready</div>; case "loading": return <Spinner />; case "success": return <TodoList todos={state.data} />; case "error": return <Alert message={state.error} />; } } |
6. Mini homework (try in playground today)
- Write a function processInput(value: unknown) that:
- If string → uppercase and log length
- If number → toFixed(2)
- If array of strings → join with “, “
- Else → “Unknown type”
- Create a discriminated union for Result<T> (success/error) and narrow it
- Write a custom guard isPerson(value: unknown): value is {name: string, age: number}
Any part confusing or want to go deeper?
- How type guards work with generics
- Combining guards with && / ||
- Type predicates vs assertion functions
- Real tRPC / Zod narrowing patterns
- Common mistakes people still make
Just tell me — we’ll zoom in exactly where you need it! 😄
