Prerequisites
- Strong understanding of TypeScript basics ๐
- Knowledge of literal types โก
- Familiarity with union types ๐ป
What you'll learn
- Understand type widening behavior ๐ฏ
- Control type narrowing flow ๐๏ธ
- Use type guards effectively ๐
- Prevent unexpected type changes โจ
๐ฏ Introduction
Welcome to the fascinating world of type widening and narrowing in TypeScript! ๐ In this guide, weโll explore how TypeScript automatically adjusts types to balance flexibility with safety.
Think of type widening as zooming out ๐โก๏ธ๐ (making types more general) and type narrowing as zooming in ๐โก๏ธ๐ (making types more specific). Itโs like a camera lens that automatically adjusts focus based on what youโre looking at!
By the end of this tutorial, youโll understand exactly how TypeScript infers and refines types, giving you the power to write code thatโs both flexible and type-safe! Letโs dive in! ๐โโ๏ธ
๐ Understanding Type Widening and Narrowing
๐ค What is Type Widening?
Type widening is TypeScriptโs way of being helpful ๐ค - it automatically makes types more general when it makes sense. Itโs like upgrading from a specific ticket to a general admission pass!
// TypeScript widens the type
let message = "hello"; // Type: string (not "hello")
let count = 42; // Type: number (not 42)
let active = true; // Type: boolean (not true)
๐ก What is Type Narrowing?
Type narrowing is the opposite - TypeScript makes types more specific based on your code. Itโs like a detective ๐ต๏ธ gathering clues to determine exactly what type something is!
function process(value: string | number) {
if (typeof value === "string") {
// TypeScript knows value is string here
console.log(value.toUpperCase());
} else {
// TypeScript knows value is number here
console.log(value.toFixed(2));
}
}
๐ฏ Why Do They Matter?
Understanding widening and narrowing helps you:
- Write Safer Code ๐ก๏ธ: Prevent runtime errors
- Better IntelliSense ๐ป: IDE knows exact types
- Avoid Type Assertions ๐ฏ: Let TypeScript do the work
- Predictable Behavior ๐ฎ: Know what TypeScript will do
- Performance โก: Better type inference = faster compilation
Real-world example: Handling API responses ๐ - you need to narrow from unknown
to specific types safely!
๐ง Type Widening in Detail
๐ Basic Widening Rules
Letโs explore when and how TypeScript widens types:
// ๐ฏ Let declarations widen
let x = 5; // Type: number (widened from 5)
let y = "hello"; // Type: string (widened from "hello")
let z = true; // Type: boolean (widened from true)
// โ๏ธ Const declarations don't widen primitives
const a = 5; // Type: 5 (literal type)
const b = "hello"; // Type: "hello" (literal type)
const c = true; // Type: true (literal type)
// ๐จ But const with objects still allows mutation
const obj = { x: 5 }; // Type: { x: number }
obj.x = 10; // Allowed! Property type widened
// ๐๏ธ Array literals widen
const arr1 = [1, 2, 3]; // Type: number[]
const arr2 = ["a", "b"]; // Type: string[]
// Mixed arrays
const mixed = [1, "hello", true]; // Type: (string | number | boolean)[]
๐ Controlling Widening
You can control how TypeScript widens types:
// ๐ฏ Const assertions prevent widening
const config1 = {
host: "localhost",
port: 3000
}; // Type: { host: string; port: number }
const config2 = {
host: "localhost",
port: 3000
} as const; // Type: { readonly host: "localhost"; readonly port: 3000 }
// ๐๏ธ Literal type annotations
let status: "idle" | "loading" | "error" = "idle";
status = "loading"; // โ
OK
// status = "done"; // โ Error!
// ๐จ Function return widening
function getConfig() {
return {
mode: "production",
debug: false
};
} // Return type: { mode: string; debug: boolean }
function getConfigLiteral() {
return {
mode: "production",
debug: false
} as const;
} // Return type: { readonly mode: "production"; readonly debug: false }
// ๐ Enum-like patterns
const Colors = {
RED: "#ff0000",
GREEN: "#00ff00",
BLUE: "#0000ff"
} as const;
type Color = typeof Colors[keyof typeof Colors];
// Type: "#ff0000" | "#00ff00" | "#0000ff"
๐ญ Special Widening Cases
// ๐ Null and undefined widening
let nullable = null; // Type: any (in non-strict mode)
let undef = undefined; // Type: any (in non-strict mode)
// With strict null checks
let strictNull = null; // Type: null
let strictUndef = undefined; // Type: undefined
// ๐ฏ Fresh literal types
type Direction = "north" | "south";
const north = "north"; // Type: "north" (fresh literal)
let dir: Direction = north; // โ
OK
const other = "north"; // Type: "north"
let str: string = other; // Type widens to string
// let dir2: Direction = str; // โ Error! string not assignable to Direction
// ๐๏ธ Template literal widening
const prefix = "user";
const id = 123;
const key = `${prefix}_${id}`; // Type: string (widened)
const keyLiteral = `${prefix}_${id}` as const; // Type: "user_123"
๐ Type Narrowing in Detail
๐ Control Flow Analysis
TypeScript analyzes your code flow to narrow types:
// ๐ฏ typeof guards
function processValue(value: string | number | boolean) {
if (typeof value === "string") {
// value: string
console.log(value.charAt(0));
} else if (typeof value === "number") {
// value: number
console.log(value.toFixed(2));
} else {
// value: boolean (only option left)
console.log(value ? "YES" : "NO");
}
}
// ๐๏ธ instanceof guards
class Dog {
bark() { console.log("Woof! ๐"); }
}
class Cat {
meow() { console.log("Meow! ๐ฑ"); }
}
function petSound(pet: Dog | Cat) {
if (pet instanceof Dog) {
pet.bark(); // pet: Dog
} else {
pet.meow(); // pet: Cat
}
}
// ๐จ in operator narrowing
type Car = { drive: () => void };
type Boat = { sail: () => void };
function operate(vehicle: Car | Boat) {
if ("drive" in vehicle) {
vehicle.drive(); // vehicle: Car
} else {
vehicle.sail(); // vehicle: Boat
}
}
๐ Equality Narrowing
// ๐ฏ Strict equality narrows
function handleStatus(status: "success" | "error" | "pending") {
if (status === "success") {
// status: "success"
console.log("โ
All good!");
} else if (status === "error") {
// status: "error"
console.log("โ Something went wrong!");
} else {
// status: "pending"
console.log("โณ Please wait...");
}
}
// ๐๏ธ Discriminated unions
type Result =
| { kind: "success"; value: string }
| { kind: "error"; error: Error }
| { kind: "loading" };
function processResult(result: Result) {
switch (result.kind) {
case "success":
// result: { kind: "success"; value: string }
console.log(`Success: ${result.value}`);
break;
case "error":
// result: { kind: "error"; error: Error }
console.log(`Error: ${result.error.message}`);
break;
case "loading":
// result: { kind: "loading" }
console.log("Loading...");
break;
}
}
// ๐จ Truthiness narrowing
function printLength(str: string | null | undefined) {
if (str) {
// str: string (null and undefined are falsy)
console.log(`Length: ${str.length}`);
} else {
// str: null | undefined
console.log("No string provided");
}
}
๐ก๏ธ Custom Type Guards
Create your own type narrowing functions:
// ๐ฏ User-defined type guards
interface User {
id: number;
name: string;
email: string;
}
interface Admin extends User {
permissions: string[];
}
function isAdmin(user: User): user is Admin {
return "permissions" in user;
}
function greetUser(user: User) {
if (isAdmin(user)) {
// user: Admin
console.log(`Admin ${user.name} has ${user.permissions.length} permissions`);
} else {
// user: User
console.log(`Hello, ${user.name}!`);
}
}
// ๐๏ธ Array type guards
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: string[]
console.log(value.join(", "));
}
}
// ๐จ Assertion functions
function assertString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw new Error("Value must be a string!");
}
}
function uppercase(value: unknown): string {
assertString(value); // After this, value is string
return value.toUpperCase();
}
๐จ Advanced Patterns
๐ Assignment Narrowing
// ๐ฏ Let variables can be narrowed
let value: string | number = "hello";
// value: string | number (declared type)
if (Math.random() > 0.5) {
value = 42;
}
if (typeof value === "string") {
// value: string
console.log(value.toUpperCase());
}
// ๐๏ธ Const variables maintain their type
const config: { mode: "dev" | "prod" } = { mode: "dev" };
// config.mode is always "dev" | "prod"
// ๐จ Array element narrowing
const items: (string | number)[] = ["hello", 42, "world"];
for (const item of items) {
if (typeof item === "string") {
console.log(item.toUpperCase());
} else {
console.log(item.toFixed(2));
}
}
๐ Complex Narrowing Scenarios
// ๐ฏ Nested property narrowing
type Config = {
api?: {
url: string;
key?: string;
};
debug?: boolean;
};
function setupApp(config: Config) {
if (config.api?.key) {
// config.api exists and config.api.key is truthy
console.log(`Using API key: ${config.api.key}`);
}
if (config.debug === true) {
// config.debug is specifically true (not just truthy)
console.log("Debug mode enabled");
}
}
// ๐๏ธ Type predicates with generics
function isDefined<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
function processOptional<T>(value: T | null | undefined) {
if (isDefined(value)) {
// value: T (null and undefined removed)
console.log("Value is defined:", value);
}
}
// ๐จ Exhaustive checking with never
type Action =
| { type: "INCREMENT"; amount: number }
| { type: "DECREMENT"; amount: number }
| { type: "RESET" };
function reducer(state: number, action: Action): number {
switch (action.type) {
case "INCREMENT":
return state + action.amount;
case "DECREMENT":
return state - action.amount;
case "RESET":
return 0;
default: {
// If we get here, we missed a case
const exhaustive: never = action;
throw new Error(`Unhandled action: ${exhaustive}`);
}
}
}
๐ Best Practices
๐ฏ Widening Best Practices
// โ
Use const for literals you don't want widened
const API_KEY = "secret-key-123"; // Type: "secret-key-123"
const MAX_RETRIES = 3; // Type: 3
// โ
Use as const for object literals
const ROUTES = {
home: "/",
about: "/about",
contact: "/contact"
} as const;
// โ
Explicit type annotations when needed
let status: "idle" | "loading" | "error" = "idle";
// โ Avoid unnecessary widening
let port = 3000; // Type: number (too wide?)
// โ
Better:
const PORT = 3000; // Type: 3000
// or
let port: 3000 | 3001 = 3000; // Specific ports only
๐ Narrowing Best Practices
// โ
Use early returns for cleaner narrowing
function processUser(user: User | null): string {
if (!user) {
return "No user";
}
// user is narrowed to User for rest of function
return `Welcome, ${user.name}!`;
}
// โ
Combine multiple checks
function isValidEmail(value: unknown): value is string {
return typeof value === "string" &&
value.includes("@") &&
value.includes(".");
}
// โ
Use discriminated unions for complex types
type Response<T> =
| { status: "success"; data: T }
| { status: "error"; error: Error }
| { status: "loading" };
// โ Avoid type assertions when narrowing works
const value: unknown = "hello";
// โ Don't do this:
const str1 = value as string;
// โ
Do this:
if (typeof value === "string") {
const str2 = value; // Properly narrowed
}
๐ฏ Practice Exercise
Letโs build a type-safe data validator! ๐ช
// ๐๏ธ Your challenge: Build a validator with proper narrowing
type ValidationRule =
| { type: "required" }
| { type: "minLength"; value: number }
| { type: "maxLength"; value: number }
| { type: "pattern"; regex: RegExp }
| { type: "email" };
interface FieldValidator {
rules: ValidationRule[];
validate(value: unknown): string | null; // null means valid
}
// TODO: Implement the validator
class Validator implements FieldValidator {
constructor(public rules: ValidationRule[]) {}
validate(value: unknown): string | null {
// TODO: Implement validation with proper narrowing
// 1. Check if value is string
// 2. Apply each rule with proper type narrowing
// 3. Return error message or null
}
}
// TODO: Create validators for common fields
const emailValidator = new Validator([
{ type: "required" },
{ type: "email" }
]);
const passwordValidator = new Validator([
{ type: "required" },
{ type: "minLength", value: 8 },
{ type: "pattern", regex: /[A-Z]/ } // At least one uppercase
]);
// Test your implementation
console.log(emailValidator.validate("[email protected]")); // null
console.log(emailValidator.validate("invalid-email")); // "Invalid email"
console.log(passwordValidator.validate("weak")); // "Minimum length is 8"
๐ Conclusion
Congratulations! Youโve mastered type widening and narrowing in TypeScript! ๐ Letโs recap what youโve learned:
- ๐ Type Widening: How TypeScript makes types more general
- ๐ Type Narrowing: How TypeScript refines types based on code
- ๐ก๏ธ Type Guards: Built-in and custom ways to narrow types
- ๐ฏ Control Flow: How TypeScript analyzes your code
Key takeaways:
- Use
const
andas const
to prevent unwanted widening ๐ - Leverage control flow for automatic narrowing ๐
- Create custom type guards for complex scenarios ๐๏ธ
- Let TypeScriptโs inference do the heavy lifting ๐ค
Understanding type flow is crucial for writing robust TypeScript code. Keep practicing these patterns, and youโll write safer, more maintainable code with confidence! ๐