Literal Types and Narrowing
Types That Represent Exact Values
Most type systems stop at "this is a string" or "this is a number." TypeScript goes further — it can track the exact value. The string "hello" isn't just string, it's the literal type "hello". The number 42 isn't just number, it's the literal type 42. This precision is what makes discriminated unions, exhaustive checks, and type-level string manipulation possible.
Think of regular types as categories — "string" is the category of all text, "number" is the category of all numbers. Literal types are specific items within those categories — "hello" is one specific string, 42 is one specific number. TypeScript lets you zoom in from the category level to the item level, and narrowing is the act of zooming in during runtime checks.
Literal Types and const vs let
// const infers literal types — the value can never change
const greeting = "hello"; // type: "hello" (not string)
const count = 42; // type: 42 (not number)
const active = true; // type: true (not boolean)
// let widens to base types — the value might change
let greeting = "hello"; // type: string
let count = 42; // type: number
let active = true; // type: boolean
This distinction is critical for function signatures:
function setDirection(dir: "north" | "south" | "east" | "west") {}
const direction = "north";
setDirection(direction); // OK — "north" is assignable to the union
let direction2 = "north";
setDirection(direction2); // ERROR — string is not assignable to "north" | "south" | "east" | "west"
as const — The Const Assertion
as const tells TypeScript to infer the narrowest possible type for an entire expression:
// Without as const
const config = {
host: "localhost", // string
port: 3000, // number
modes: ["dev", "prod"], // string[]
};
// With as const
const config = {
host: "localhost", // "localhost"
port: 3000, // 3000
modes: ["dev", "prod"], // readonly ["dev", "prod"]
} as const;
// Type is:
// {
// readonly host: "localhost";
// readonly port: 3000;
// readonly modes: readonly ["dev", "prod"];
// }
as const makes everything readonly AND narrows to literal types. This means you can't push to arrays, reassign properties, or mutate anything. If you need to mutate the object later, as const is wrong — use explicit literal type annotations instead:
// If you need host to be a literal but the object mutable:
const config: { host: "localhost" | "production"; port: number } = {
host: "localhost",
port: 3000,
};
config.port = 8080; // OK — port is still mutableNarrowing — How TypeScript Zooms In
Narrowing is TypeScript's ability to refine a type within a control flow branch. There are several narrowing mechanisms:
typeof Narrowing
function process(value: string | number | boolean) {
if (typeof value === "string") {
// value: string
return value.toUpperCase();
}
if (typeof value === "number") {
// value: number
return value.toFixed(2);
}
// value: boolean (only option left)
return value ? "yes" : "no";
}
instanceof Narrowing
function handleError(err: Error | TypeError | RangeError) {
if (err instanceof RangeError) {
// err: RangeError
console.log("Range:", err.message);
} else if (err instanceof TypeError) {
// err: TypeError
console.log("Type:", err.message);
} else {
// err: Error
console.log("Generic:", err.message);
}
}
in Operator Narrowing
type Fish = { swim: () => void };
type Bird = { fly: () => void };
function move(animal: Fish | Bird) {
if ("swim" in animal) {
// animal: Fish
animal.swim();
} else {
// animal: Bird
animal.fly();
}
}
Truthiness Narrowing
function greet(name: string | null | undefined) {
if (name) {
// name: string (null and undefined are falsy, removed)
console.log(`Hello, ${name}`);
} else {
// name: string | null | undefined (could be "", null, or undefined)
console.log("Hello, stranger");
}
}
Truthiness narrowing removes null, undefined, 0, "", false, and NaN. This means it also removes valid values like empty strings and zero. If empty string or zero are valid inputs, use explicit null checks instead:
function process(count: number | null) {
if (count) {
// WRONG — removes 0, which might be valid
}
if (count !== null) {
// CORRECT — only removes null, keeps 0
console.log(count.toFixed(2)); // count: number (including 0)
}
}Equality Narrowing
function compare(a: string | number, b: string | boolean) {
if (a === b) {
// a and b must be the same type — only string overlaps
// a: string, b: string
console.log(a.toUpperCase(), b.toUpperCase());
}
}
Control Flow Analysis — How It Actually Works
TypeScript tracks the type of every variable at every point in the code. Each branch refines the type:
function example(x: string | number | null) {
// x: string | number | null
if (x === null) {
// x: null
return;
}
// x: string | number (null eliminated by early return)
if (typeof x === "string") {
// x: string
return x.toUpperCase();
}
// x: number (string eliminated by the if)
return x.toFixed(2);
}
Production Scenario: Event Handler with Discriminated Events
type AppEvent =
| { type: "USER_LOGIN"; userId: string; timestamp: number }
| { type: "USER_LOGOUT"; userId: string; sessionDuration: number }
| { type: "PAGE_VIEW"; path: string; referrer?: string }
| { type: "PURCHASE"; productId: string; amount: number; currency: string };
function trackEvent(event: AppEvent) {
// Common property — always available
console.log(`Event: ${event.type}`);
switch (event.type) {
case "USER_LOGIN":
analytics.identify(event.userId);
break;
case "USER_LOGOUT":
analytics.track("session", { duration: event.sessionDuration });
break;
case "PAGE_VIEW":
analytics.page(event.path, { referrer: event.referrer });
break;
case "PURCHASE":
analytics.revenue(event.amount, event.currency, event.productId);
break;
}
}
// Type-safe event creators using as const
const events = {
login: (userId: string) => ({
type: "USER_LOGIN" as const,
userId,
timestamp: Date.now(),
}),
logout: (userId: string, sessionDuration: number) => ({
type: "USER_LOGOUT" as const,
userId,
sessionDuration,
}),
} satisfies Record<string, (...args: any[]) => AppEvent>;
trackEvent(events.login("user_123")); // Type-safe
-
Wrong: Using let when the value never changes — losing literal type inference Right: Use const for values that don't change — get literal types for free
-
Wrong: Trusting truthiness narrowing for values where 0 or '' are valid Right: Use explicit null/undefined checks: value !== null && value !== undefined
-
Wrong: Using as const on objects you need to mutate later Right: Use specific literal annotations on just the properties that need narrowing
-
Wrong: Casting with 'as' instead of narrowing with control flow Right: Use typeof, instanceof, in, or discriminant checks to narrow naturally
Type-Safe Route Params
- 1const infers literal types, let widens to base types — this affects whether values work with literal unions
- 2as const makes an entire expression readonly with literal types — use it for config objects, route maps, and enum-like constants
- 3Narrowing mechanisms: typeof, instanceof, in, equality checks, truthiness, and discriminant property checks
- 4Truthiness narrowing removes all falsy values (0, '', null, undefined, false, NaN) — use explicit checks when 0 or '' are valid
- 5TypeScript's control flow analysis tracks narrowed types through every branch, including early returns and assignments