Concept
The beginner framing: beyond the familiar string/number/boolean, TypeScript has several more precise type-building blocks, literal types, unions, intersections, and a few special types (unknown, any, never) whose exact behavior matters a lot more than their similar-sounding names suggest.
Literal types: a value AS a type
let direction: "up" | "down" | "left" | "right";
direction = "up"; // ✅ fine
direction = "sideways"; // ❌ error, not one of the allowed literalsA literal type is a type containing exactly one specific value, "up" isn't just a string, it can be the type "up" itself. Combined with the union operator (|), literal types are how TypeScript expresses "one of these exact values," a common and precise alternative to enums (more on that below).
Unions and intersections
type Id = string | number; // UNION, could be EITHER type
type Loud = { volume: number };
type Fast = { speed: number };
type LoudAndFast = Loud & Fast; // INTERSECTION, has BOTH sets of properties
const car: LoudAndFast = { volume: 11, speed: 200 }; // must satisfy BOTHA union (|) means "this could be any one of these types." An intersection (&) means "this must simultaneously satisfy all of these types", for object types, that means the resulting type has the combined properties of both.
unknown vs. any vs. never: three easily confused types
let a: any = "hello";
a.toUpperCase(); // ✅ compiles, `any` disables type-checking entirely for this value
a(); // ✅ ALSO compiles, even calling a string as a function, no error!
let u: unknown = "hello";
u.toUpperCase(); // ❌ Error: 'u' is of type 'unknown', must narrow FIRST
if (typeof u === "string") {
u.toUpperCase(); // ✅ fine now, narrowed to string
}Confirmed by compiling both directly: any genuinely disables type-checking for that value, literally anything is allowed, including operations that will obviously fail at runtime (calling a string as if it were a function). unknown is the type-safe alternative for "I don't know this value's type yet", it accepts any value being assigned to it, but refuses to let you do anything with it until you've narrowed it to something more specific via a runtime check.
function fail(message: string): never {
throw new Error(message); // this function NEVER returns normally
}
type Status = "active" | "inactive";
function handle(status: Status) {
if (status === "active") return 1;
if (status === "inactive") return 2;
return assertUnreachable(status); // status is `never` HERE, both cases already handled
}
function assertUnreachable(x: never): never
never represents a value that can genuinely never occur, a function that always throws, or (more usefully) the type TypeScript assigns to a variable once every possible case has already been handled. This confirmed pattern (assertUnreachable) is a real, practical exhaustiveness-checking technique: if someone adds a new value to the Status union later and forgets to handle it in handle, the call to assertUnreachable stops compiling, a real compile error catching a real missed case.
Arrays and tuples
const scores: number[] = [10, 20, 30]; // ARRAY, any length, all elements same type
const point: [number, number] = [1, 2]; // TUPLE, FIXED length, position-specific types
const bad: [number, number] = [1, 2, 3]; // ❌ error, 3 elements, tuple expects exactly 2Confirmed: assigning a 3-element array to a [number, number] tuple fails to compile, tuples enforce both a fixed length and per-position types, unlike a regular array which only constrains the element type, not the count.
Enums vs. union-of-literals: a deliberate recommendation
// enum, generates actual runtime code (an object)
enum Color { Red, Green, Blue }
// union of literals, ERASED entirely at compile time, zero runtime footprint
type ColorLiteral = "red" | "green" | "blue";Both express "one of a fixed set of options," but modern TypeScript guidance generally favors the union-of-literals form for a few concrete reasons: it's purely erasable (no runtime object generated, consistent with the type-erasure model from TypeScript Basics), it works naturally with plain string values from APIs/JSON without a conversion step, and it avoids enum's well-documented interop quirks (numeric enums allowing any number to be assigned, reverse mappings). Enums aren't wrong, but a union of literals is the more common recommendation for new code, worth knowing as a deliberate choice, not just unfamiliarity with enums.
Try It
Predict the outcome before checking the solution.
function processInput(value: unknown) {
return value.toString(); // does this compile?
}Solution
No, this fails to compile with an error like 'value' is of type 'unknown'. Even though every value in JavaScript technically has a .toString() method, unknown requires an explicit narrowing check before TypeScript will let you call ANY method on it, it doesn't matter that .toString() happens to exist on virtually everything; unknown categorically blocks property/method access until narrowed. The fix would be something like if (typeof value === "object" || typeof value === "string") { return value.toString(); } or a similar narrowing check first.
Implement It Yourself
Build a minimal exhaustiveness-checked handler using the never-based pattern:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
// if a THIRD shape kind is ever added and not handled above,
// `shape` here would NOT be `never`, and this line fails to compile:
Try adding a third variant like { kind: "triangle"; base: number; height: number } to the Shape union without adding a corresponding case, the const _exhaustive: never = shape line then fails to compile, since shape in the default branch would now include the un-handled triangle case, which isn't assignable to never.
Under the Hood
The never-based exhaustiveness pattern here is a direct, practical extension of narrowing, covered in full depth in Type Narrowing & Guards, this topic's switch example is discriminated-union narrowing in action, one instance of a broader mechanism. And the enum-vs-union-of-literals recommendation follows directly from the type-erasure model established in TypeScript Basics, a union of literals stays true to "types have zero runtime footprint," while enum is a deliberate, narrow exception that generates real runtime code.
Common Mistakes
1. Using any as a shortcut to silence a type error, without realizing what it disables
function process(data: any) { // ❌ silences the immediate error, but disables ALL checking on `data`
return data.whatever.deeply.nested.access; // no error, even though this will crash at runtime
}any doesn't just relax one specific check, it disables type-checking entirely for that value, including for everything derived from it. unknown (with proper narrowing) almost always achieves the intended safety without this blanket opt-out.
2. Assuming a regular array and a tuple are interchangeable
function midpoint(p: number[]) { // ❌ accepts arrays of ANY length, even empty ones
return (p[0] + p[1]) / 2; // p[1] could be `undefined` if the array has fewer than 2 elements
}If a function genuinely expects exactly two numbers, [number, number] (a tuple) documents and enforces that at the type level, a plain number[] parameter accepts arrays of any length, silently allowing a caller to pass too few or too many elements.
3. Reaching for enum reflexively without considering the union-of-literals alternative
enum Direction { Up, Down } // generates a real runtime object, is that actually needed here?For most "one of a fixed set of values" cases, a union of string literals is erasable, JSON-friendly, and avoids enum's interop quirks, worth actively choosing rather than defaulting to enum out of habit.
Best Practices
- Prefer
unknownoveranyfor genuinely uncertain values (parsed JSON, external API responses), it forces safe narrowing rather than disabling checks entirely. - Use the
never-based exhaustiveness pattern inswitchstatements over discriminated unions, so adding a new case later produces a compile error if it's not handled anywhere it should be. - Reach for tuples when a fixed-length, position-specific structure is the actual intent (coordinates, key-value pairs) rather than a same-typed array.
- Default to union-of-literals over
enumfor new code, reservingenumfor cases where its specific runtime-object behavior is genuinely wanted.
Performance Tips
- Union-of-literal types have zero runtime footprint (fully erased), while
enumgenerates an actual runtime object, for code where bundle size or avoiding any extra runtime object matters even slightly, this is a concrete, measurable reason to prefer literals, not just a stylistic one. unknown's narrowing requirement has no runtime performance cost of its own, the narrowing checks you write (typeof, etc.) are the same checks you'd need to write for safety regardless of whether the type system required them.
