TypeScript Type Guards and Narrowing: Writing Type-Safe Conditionals
Master Discriminated Unions, Type Predicates, and Control Flow Analysis

noteUpdated March 2026: Originally published in 2021 and updated with modern TypeScript narrowing patterns and a new banner image. I cannot thank Google Gemini’s image creation enough LOL.
Previous in the series: TypeScript Strict Mode: Best Practices for Production Code
So here’s a frustrating moment from experience. You have a value that could be a string or a number. You write a check for typeof value === "string". Then TypeScript should remember that inside that if block, it’s definitely a string.
And it does. But that same behavior works for way more scenarios than most developers realize, I believe. Unions, APIs, discriminated types, custom validation…
Once TypeScript understands your intent, it narrows the type everywhere it matters in that scope. No manual assertions. No unsafe casts. Just pure type safety.
That’s type narrowing. And it’s where TypeScript gets genuinely smart.
Part 1: The Basics of Type Narrowing
typeof Narrowing
The most common type guard. Works with primitives: string, number, boolean, symbol, bigint, object, function, and undefined.
function process(value: string | number) {
if (typeof value === "string") {
// value is narrowed to string
return value.toUpperCase();
} else {
// value is narrowed to number
return value.toFixed(2);
}
}
// Truthiness checks also narrow
function printLength(str: string | null) {
if (str) {
console.log(str.length); // str is narrowed to string
} else {
console.log("No string provided");
}
}
// Even with logical operators
function combine(x: string | number, y: string | boolean) {
if (typeof x === "string" && typeof y === "string") {
return x + y; // Both are strings, safe to concatenate
} else {
return x.toString() + y.toString(); // Safe because we narrowed
}
}instanceof Narrowing
Use for class instances and built-in objects.
class User {
name: string;
constructor(name: string) {
this.name = name;
}
greet() {
return `Hello, ${this.name}`;
}
}
class Admin extends User {
permissions: string[];
constructor(name: string, permissions: string[]) {
super(name);
this.permissions = permissions;
}
}
function processAccount(account: User | Admin) {
if (account instanceof Admin) {
// account is narrowed to Admin
console.log(account.permissions);
} else {
// account is narrowed to User
console.log(account.greet());
}
}
// Works with built-ins too
function handleValue(value: Date | string) {
if (value instanceof Date) {
console.log(value.toISOString());
} else {
console.log(value.toUpperCase());
}
}Part 2: Discriminated Unions (Tagged Unions)
This is where type narrowing becomes powerful. A discriminated union is a pattern you create using utility types and conditional types from Part 1 of this series. It has a shared property (the “discriminator”) that TypeScript uses to narrow the type.
Basic Discriminated Union
interface SuccessResponse {
type: "success"; // discriminator
data: unknown;
}
interface ErrorResponse {
type: "error"; // discriminator
error: string;
}
interface PendingResponse {
type: "pending"; // discriminator
progress: number;
}
type ApiResponse = SuccessResponse | ErrorResponse | PendingResponse;
function handleResponse(response: ApiResponse) {
switch (response.type) {
case "success":
// TypeScript narrows to SuccessResponse
console.log("Data:", response.data);
break;
case "error":
// TypeScript narrows to ErrorResponse
console.log("Error:", response.error);
break;
case "pending":
// TypeScript narrows to PendingResponse
console.log("Progress:", response.progress);
break;
}
}Real-World Example: Result Types
interface Success<T> {
ok: true;
value: T;
}
interface Failure {
ok: false;
error: Error | string;
}
type Result<T> = Success<T> | Failure;
// Factory functions
function ok<T>(value: T): Result<T> {
return { ok: true, value };
}
function fail(error: Error | string): Result<never> {
return { ok: false, error };
}
// Usage with perfect narrowing
const result: Result<User> = fetchUser();
if (result.ok) {
// result.value is User
console.log(result.value.name);
} else {
// result.error is Error | string
console.log("Failed:", result.error);
}
// Or use a pattern match helper
function match<T, U, V>(
result: Result<T>,
onSuccess: (value: T) => U,
onFailure: (error: Error | string) => V
): U | V {
return result.ok ? onSuccess(result.value) : onFailure(result.error);
}
const message = match(
result,
user => `Hello, ${user.name}`,
error => `Error: ${error}`
);Literal Types as Discriminators
type LogLevel = "debug" | "info" | "warn" | "error";
interface DebugLog {
level: "debug";
message: string;
}
interface ErrorLog {
level: "error";
message: string;
stacktrace: string;
}
interface InfoLog {
level: "info";
message: string;
timestamp: number;
}
type Log = DebugLog | ErrorLog | InfoLog;
function formatLog(log: Log): string {
switch (log.level) {
case "debug":
return `[DEBUG] ${log.message}`;
case "error":
return `[ERROR] ${log.message}\n${log.stacktrace}`;
case "info":
return `[INFO] ${new Date(log.timestamp).toISOString()} ${log.message}`;
}
}Part 3: Custom Type Guards (Type Predicates)
Sometimes TypeScript’s built-in narrowing isn’t enough. You can write custom guards with is keyword.
Basic Type Predicate
interface User {
name: string;
email: string;
}
// Custom type guard
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"name" in value &&
"email" in value &&
typeof (value as any).name === "string" &&
typeof (value as any).email === "string"
);
}
// Usage
const data: unknown = { name: "Alice", email: "alice@ex.com" };
if (isUser(data)) {
// data is narrowed to User
console.log(data.name, data.email);
}
// Works with arrays too
const mixed: unknown[] = [1, "hello", { name: "Bob" }];
const users: User[] = mixed.filter(isUser); // Typed as User[]Advanced Type Predicates
// Generic type guard
function isNotNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
function process(items: (string | null)[]) {
const valid: string[] = items.filter(isNotNull); // Works!
}
// Array element guard — validates both the array and each element's type
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(item => typeof item === "string");
}
function processArray(value: unknown) {
if (isStringArray(value)) {
// value is string[] — both array and element types verified
value.forEach(item => console.log(item.toUpperCase()));
}
}
// Async type guard (returns Promise<boolean>)
async function isValidUser(id: number): Promise<boolean> {
const user = await database.findUser(id);
return user !== null;
}
// Note: Can't use in type predicates directly, but useful for validation
if (await isValidUser(123)) {
// proceed
}Real-World: API Validation Guards
interface ApiUser {
id: number;
name: string;
email: string;
role: "user" | "admin";
}
function isApiUser(value: unknown): value is ApiUser {
if (!value || typeof value !== "object") return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.id === "number" &&
typeof obj.name === "string" &&
obj.name.length > 0 &&
typeof obj.email === "string" &&
obj.email.includes("@") &&
(obj.role === "user" || obj.role === "admin")
);
}
// Use in API response handling
async function fetchUser(id: number) {
const response = await fetch(`/api/users/${id}`);
const json = await response.json();
if (isApiUser(json)) {
// json is safely typed as ApiUser
return json;
} else {
throw new Error("Invalid user data from API");
}
}Part 4: Control Flow Analysis
TypeScript tracks the type as it flows through your code, not just in branches.
Exhaustiveness Checking
type Status = "pending" | "success" | "error";
// Missing a case - TypeScript complains:
// error TS7030: Not all code paths return a value
function getStatusMessage(status: Status): string {
switch (status) {
case "pending":
return "Loading...";
case "success":
return "Done!";
// "error" case is missing!
}
}
// Use the never exhaustiveness pattern to future-proof:
function getStatusMessageSafe(status: Status): string {
switch (status) {
case "pending":
return "Loading...";
case "success":
return "Done!";
case "error":
return "Failed!";
default:
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}
// Now if you add a new status type later:
type StatusV2 = "pending" | "success" | "error" | "cancelled"; // new
function getStatusMessageV2(status: StatusV2): string {
switch (status) {
case "pending":
return "Loading...";
case "success":
return "Done!";
case "error":
return "Failed!";
// Missing: case "cancelled"
default:
// Error! Type '"cancelled"' is not assignable to type 'never'
// TypeScript forces you to handle 'cancelled'
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}Narrowing Through Never
function handleValue(value: string | number | boolean) {
if (typeof value === "string") {
// string
value.toUpperCase();
} else if (typeof value === "number") {
// number
value.toFixed();
} else {
// boolean
value.toString();
}
}
// With all cases covered, the else branch is typed as never:
function handleValue(value: string | number) {
if (typeof value === "string") {
value.toUpperCase();
} else if (typeof value === "number") {
value.toFixed();
} else {
// This compiles fine because value is already 'never' here
// The else branch is dead code since all cases are covered
const _never: never = value;
}
}Part 5: Advanced Narrowing Techniques
Assertion Functions
function assertIsUser(value: unknown): asserts value is User {
if (!isUser(value)) {
throw new Error("Not a user");
}
}
const data: unknown = { name: "Alice", email: "alice@ex.com" };
assertIsUser(data);
console.log(data.name); // TypeScript knows it's User hereConditional Narrowing
function process<T extends string | number>(value: T) {
if (typeof value === "string") {
// T is narrowed to string
value.toUpperCase();
} else {
// T is narrowed to number
value.toFixed();
}
}Optional Chaining & Nullish Coalescing
These operators are designed to work seamlessly with strict mode’s strictNullChecks. When enabled, they help you write safe code when dealing with potentially null or undefined values.
interface User {
name: string;
address?: {
street?: string;
};
}
const user: User = { name: "Alice" };
// Optional chaining narrows
const street = user.address?.street; // string | undefined
// Nullish coalescing provides a default
const defaultStreet = user.address?.street ?? "Unknown";
// Combined: safe navigation with defaults
const displayStreet = user?.address?.street?.toUpperCase() ?? "N/A";Part 6: Patterns for Production Code
The Result Pattern
tip.. from the trenches.
I’ve, sort of, standardized on this Result pattern for all async operations. It has become as automatic as error handling. You write it once, and every async function becomes predictable. Success or failure, it is always typed. No surprise exceptions at runtime.
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
async function getUserWithResult(id: number): Promise<Result<User>> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
return { ok: false, error: `HTTP ${response.status}` };
}
const data = await response.json();
if (isUser(data)) {
return { ok: true, value: data };
}
return { ok: false, error: "Invalid user format" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "Unknown error" };
}
}
// Usage
const result = await getUserWithResult(123);
if (result.ok) {
console.log("User:", result.value.name);
} else {
console.error("Failed:", result.error);
}The Maybe Pattern
type Maybe<T> = T | null | undefined;
function isSome<T>(value: Maybe<T>): value is T {
return value !== null && value !== undefined;
}
function isNone<T>(value: Maybe<T>): value is null | undefined {
return value === null || value === undefined;
}
// Usage
const values: Maybe<string>[] = ["hello", null, "world", undefined];
const validValues: string[] = values.filter(isSome);Common Narrowing Patterns
// Pattern 1: Check before using
const value: string | null = getValue();
if (value) {
console.log(value.length);
}
// Pattern 2: Early return
function processUser(user: User | null) {
if (!user) return;
console.log(user.name);
}
// Pattern 3: Logical AND
const user: User | null = getUser();
user && console.log(user.name);
// Pattern 4: Truthiness with double negative
if (!!user) {
console.log(user.name);
}Wrapping up
- Type narrowing is automatic - TypeScript infers based on your checks
- Discriminated unions are powerful - Use them for APIs and complex types
- Write custom type guards with
isfor complex validation - Use exhaustiveness checking to ensure all cases are handled
- Combine patterns - discriminated unions + type guards + control flow analysis = dramatically more robust code
The magic here is that you write the check once, and TypeScript remembers it for the entire scope. No manual casts, no as escapes, no unsafe territory. Once you internalize how control flow analysis works, you’ll start writing narrowing checks naturally. Not because you have to, but because they make your code clearer.
Use discriminated unions for APIs and state machines. Use type guards for validation and runtime safety. Combine them. Chain them. Build layers. The more patterns you layer together, the harder you make it to ship bugs.
I’ll probably wrap up this series in the next part about Declaration Merging and Module Augmentation. Some so-called advanced techniques for extending types from libraries and making your code more flexible without breaking encapsulation
Previous in the series: TypeScript Strict Mode: Best Practices for Production Code
Next in the series: TypeScript Declaration Merging and Module Augmentation
See ya there! 👋





