Type Narrowing
Refine variable types based on runtime conditions using typeof, instanceof, equality, and truthiness checks.
Type Narrowing in TypeScript
TypeScript's type narrowing empowers developers to write safer, more predictable code by refining broad types into specific ones. Master type narrowing with free flashcards and spaced repetition to solidify your understanding of type guards, discriminated unions, control flow analysis, and the never typeβessential concepts for building robust TypeScript applications.
Welcome to Type Narrowing π»
Welcome to one of TypeScript's most powerful features! Type narrowing is the process by which TypeScript refines a variable's type from a general type to a more specific type within a particular scope. This happens through various techniques that prove to the compiler what type you're actually working with at runtime.
Think of type narrowing like a security checkpoint π: you start with a general "person" type, but through various checks (ID verification, credentials), you narrow down to knowing exactly who they areβ"employee," "visitor," or "contractor." Similarly, TypeScript starts with broad types and narrows them down through your code's logic.
Core Concepts π―
What is Type Narrowing?
Type narrowing is TypeScript's ability to analyze your code's control flow and determine more specific types for variables based on conditional checks, type guards, and other runtime logic. When you perform checks like typeof, instanceof, or custom type guards, TypeScript intelligently updates its understanding of what type a variable can be.
function processValue(value: string | number) {
// At this point, value is string | number
if (typeof value === "string") {
// Inside this block, TypeScript knows value is string
console.log(value.toUpperCase());
} else {
// Here, TypeScript knows value must be number
console.log(value.toFixed(2));
}
}
Type Guards π‘οΈ
A type guard is any expression that performs a runtime check and guarantees a variable's type within a specific scope. TypeScript has several built-in type guards:
1. The typeof Type Guard
The typeof operator is perfect for primitive types:
function printValue(value: string | number | boolean) {
if (typeof value === "string") {
console.log(`String: ${value.toUpperCase()}`);
} else if (typeof value === "number") {
console.log(`Number: ${value.toFixed(2)}`);
} else {
console.log(`Boolean: ${value ? "true" : "false"}`);
}
}
π‘ Tip: typeof returns strings like "string", "number", "boolean", "object", "function", "undefined", and "symbol".
2. The instanceof Type Guard
The instanceof operator checks if an object is an instance of a specific class:
class Dog {
bark() { console.log("Woof!"); }
}
class Cat {
meow() { console.log("Meow!"); }
}
function makeSound(animal: Dog | Cat) {
if (animal instanceof Dog) {
animal.bark(); // TypeScript knows it's a Dog
} else {
animal.meow(); // TypeScript knows it's a Cat
}
}
3. The in Operator Type Guard
The in operator checks if a property exists on an object:
interface Fish {
swim: () => void;
}
interface Bird {
fly: () => void;
}
function move(animal: Fish | Bird) {
if ("swim" in animal) {
animal.swim(); // TypeScript knows it's a Fish
} else {
animal.fly(); // TypeScript knows it's a Bird
}
}
β οΈ Common Mistake: The in operator checks for properties at runtime, so be careful with optional properties that might be undefined.
Custom Type Guards (User-Defined Type Guards) π§
You can create your own type guards using type predicates. A type predicate is a return type in the form parameterName is Type:
interface Car {
drive: () => void;
wheels: 4;
}
interface Boat {
sail: () => void;
hull: string;
}
// Custom type guard function
function isCar(vehicle: Car | Boat): vehicle is Car {
return (vehicle as Car).drive !== undefined;
}
function operate(vehicle: Car | Boat) {
if (isCar(vehicle)) {
vehicle.drive(); // TypeScript knows it's a Car
} else {
vehicle.sail(); // TypeScript knows it's a Boat
}
}
π§ Memory Device: Think of "is" in type predicates as "proves this is"βthe function proves the type.
Discriminated Unions (Tagged Unions) π·οΈ
A discriminated union uses a common property (the discriminant) with literal types to distinguish between union members:
interface Circle {
kind: "circle"; // discriminant property
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
interface Triangle {
kind: "triangle";
base: number;
height: number;
}
type Shape = Circle | Square | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
// TypeScript knows shape is Circle here
return Math.PI * shape.radius ** 2;
case "square":
// TypeScript knows shape is Square here
return shape.sideLength ** 2;
case "triangle":
// TypeScript knows shape is Triangle here
return (shape.base * shape.height) / 2;
}
}
π‘ Best Practice: Always use literal types for discriminant properties. This makes exhaustiveness checking work properly.
Control Flow Analysis π
TypeScript performs sophisticated control flow analysis to track how types change through your code:
function processInput(input: string | null | undefined) {
// input is: string | null | undefined
if (!input) {
// input is: null | undefined (falsy values)
console.log("No input provided");
return;
}
// After the return, TypeScript knows input must be string
console.log(input.toUpperCase());
}
CONTROL FLOW NARROWING
ββββββββββββββββββββββββββββββ
β input: string | null β
ββββββββββββββ¬ββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββ
β if (input === null) β
ββββββββββ¬βββββββββββ¬βββββββββ
β β
ββββββββΌβββββ ββββΌβββββββ
β TRUE β β FALSE β
β input: β β input: β
β null β β string β
βββββββββββββ βββββββββββ
Truthiness Narrowing β
TypeScript narrows types based on truthiness checks:
function printName(name: string | null | undefined) {
if (name) {
// name is string (truthy)
console.log(name.toUpperCase());
} else {
// name is null | undefined (falsy)
console.log("Anonymous");
}
}
β οΈ Watch Out: Empty strings, 0, NaN, and false are also falsy! Use explicit checks when these are valid values:
function processNumber(value: number | null) {
// β Wrong: 0 is falsy, but it's a valid number!
if (value) {
console.log(value * 2);
}
// β
Correct: explicitly check for null
if (value !== null) {
console.log(value * 2); // Now 0 works correctly
}
}
Equality Narrowing π
Strict equality checks (===, !==) narrow types effectively:
function compare(x: string | number, y: string | boolean) {
if (x === y) {
// Both must be string (the only common type)
console.log(x.toUpperCase());
console.log(y.toUpperCase());
}
}
The never Type and Exhaustiveness Checking π«
The never type represents values that never occur. It's incredibly useful for exhaustiveness checking:
type Status = "pending" | "approved" | "rejected";
function handleStatus(status: Status) {
switch (status) {
case "pending":
return "Waiting for approval";
case "approved":
return "Request approved";
case "rejected":
return "Request rejected";
default:
// If we add a new status and forget to handle it,
// this will cause a type error
const exhaustiveCheck: never = status;
return exhaustiveCheck;
}
}
π§ Memory Device: Think of never as "this should NEVER happen"βif it does, you've missed a case.
Detailed Examples π
Example 1: API Response Handling
Let's see how type narrowing helps handle API responses safely:
interface SuccessResponse {
status: "success";
data: {
userId: number;
username: string;
};
}
interface ErrorResponse {
status: "error";
errorCode: number;
message: string;
}
type ApiResponse = SuccessResponse | ErrorResponse;
function handleResponse(response: ApiResponse) {
// Using discriminated union with the 'status' property
if (response.status === "success") {
// TypeScript knows response is SuccessResponse
console.log(`Welcome, ${response.data.username}!`);
console.log(`Your ID is: ${response.data.userId}`);
} else {
// TypeScript knows response is ErrorResponse
console.error(`Error ${response.errorCode}: ${response.message}`);
}
}
Why this works: The status property with literal types acts as a discriminant, allowing TypeScript to narrow the union type based on the value.
Example 2: Form Input Validation
Type narrowing shines when validating user input:
type FormValue = string | number | boolean | null;
function validateAndProcess(value: FormValue, fieldName: string) {
// First, check for null
if (value === null) {
throw new Error(`${fieldName} is required`);
}
// Now value is: string | number | boolean
if (typeof value === "string") {
// value is string
if (value.trim().length === 0) {
throw new Error(`${fieldName} cannot be empty`);
}
return value.trim();
}
if (typeof value === "number") {
// value is number
if (isNaN(value) || !isFinite(value)) {
throw new Error(`${fieldName} must be a valid number`);
}
return value;
}
// value is boolean
return value ? "yes" : "no";
}
Why this works: Each conditional check narrows the type, and TypeScript tracks the remaining possibilities through control flow analysis.
Example 3: Advanced Type Guards with Generics
Combine type guards with generics for reusable narrowing:
function isArray<T>(value: T | T[]): value is T[] {
return Array.isArray(value);
}
function processData<T>(data: T | T[]) {
if (isArray(data)) {
// data is T[]
console.log(`Processing ${data.length} items`);
data.forEach(item => console.log(item));
} else {
// data is T
console.log("Processing single item:", data);
}
}
processData("hello");
processData([1, 2, 3, 4]);
Why this works: The type predicate value is T[] tells TypeScript that when the function returns true, the value is definitely an array.
Example 4: Nullish Value Handling
Modern TypeScript patterns for handling nullable values:
interface User {
id: number;
name: string;
email?: string; // optional property
}
function sendEmail(user: User) {
// Using optional chaining and nullish coalescing
const email = user.email ?? "no-email@example.com";
// Type narrowing with explicit check
if (user.email !== undefined) {
// user.email is string (not undefined)
console.log(`Sending to: ${user.email.toLowerCase()}`);
} else {
console.log("User has no email address");
}
}
Why this works: TypeScript narrows optional properties when you explicitly check for undefined.
Common Mistakes β οΈ
Mistake 1: Using Loose Equality
// β Wrong: == doesn't narrow as effectively
function processValue(value: string | number | null) {
if (value == null) {
// This catches both null AND undefined
return;
}
// TypeScript might not narrow correctly
}
// β
Correct: Use strict equality
function processValue(value: string | number | null) {
if (value === null) {
return;
}
// Now value is definitely string | number
}
Mistake 2: Forgetting About Empty Strings and Zero
// β Wrong: Truthiness check excludes valid values
function displayScore(score: number | null) {
if (score) {
console.log(`Score: ${score}`);
}
// BUG: score of 0 is falsy, so it won't display!
}
// β
Correct: Explicit null check
function displayScore(score: number | null) {
if (score !== null) {
console.log(`Score: ${score}`);
}
}
Mistake 3: Mutating Variables After Narrowing
// β Wrong: Reassignment loses narrowing
function process(value: string | number) {
if (typeof value === "string") {
value = value.toUpperCase();
}
value = 42; // Now value could be anything!
value.toUpperCase(); // Error: number doesn't have toUpperCase
}
// β
Correct: Use const for narrowed values
function process(value: string | number) {
if (typeof value === "string") {
const upperValue = value.toUpperCase();
console.log(upperValue);
}
}
Mistake 4: Not Using Discriminated Unions
// β Wrong: Hard to narrow
interface Shape {
radius?: number;
sideLength?: number;
width?: number;
height?: number;
}
// β
Correct: Use discriminated unions
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
Mistake 5: Ignoring Exhaustiveness Checking
type Status = "pending" | "approved" | "rejected";
// β Wrong: No exhaustiveness check
function handleStatus(status: Status) {
if (status === "pending") {
return "Waiting";
} else if (status === "approved") {
return "Done";
}
// Forgot "rejected" - no error!
}
// β
Correct: Use never for exhaustiveness
function handleStatus(status: Status): string {
switch (status) {
case "pending":
return "Waiting";
case "approved":
return "Done";
case "rejected":
return "Denied";
default:
const exhaustive: never = status;
throw new Error(`Unhandled status: ${exhaustive}`);
}
}
π€ Did You Know?
TypeScript's control flow analysis is actually a form of abstract interpretationβa static analysis technique from computer science that traces all possible program execution paths. This is the same technology used in advanced compiler optimizations and security analysis tools!
Key Takeaways π
- Type narrowing refines general types into specific ones based on runtime checks
- Type guards (
typeof,instanceof,in) are built-in narrowing mechanisms - Custom type guards use type predicates (
param is Type) for complex scenarios - Discriminated unions use a common property with literal types for easy narrowing
- Control flow analysis automatically tracks type changes through your code
- The never type enables exhaustiveness checking in switch statements
- Use strict equality (
===) for reliable narrowing - Be careful with truthiness checksβthey exclude valid falsy values like
0and""
π§ Try This!
Create a function that accepts unknown (the type-safe counterpart of any) and narrows it safely:
function processUnknown(value: unknown) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else if (typeof value === "number") {
console.log(value.toFixed(2));
} else if (Array.isArray(value)) {
console.log(`Array with ${value.length} items`);
} else {
console.log("Unknown type");
}
}
This pattern is essential when working with external data sources!
π Further Study
π Quick Reference Card
| typeof | Narrows primitive types: string, number, boolean, etc. |
| instanceof | Narrows class instances |
| in | Checks property existence for narrowing |
| Type Predicate | param is Type for custom guards |
| Discriminated Union | Common property with literal types |
| never | For exhaustiveness checking |
| Truthiness | Narrows based on falsy/truthy, but beware 0 and "" |
| Equality | Use === for reliable narrowing |