TypeScript — never & unknown Types
TypeScript has two special types that sit at opposite ends of the type hierarchy. never represents values that can never occur — the bottom type. unknown represents any value — the top type that is type-safe. Understanding both is essential for writing robust, type-checked code.
never is the type of values that never exist. Functions that always throw or have infinite loops return never. Variables of type never cannot be assigned any value — not even null or undefined.
| 1 | // A function that always throws returns never |
| 2 | function throwError(message: string): never { |
| 3 | throw new Error(message); |
| 4 | } |
| 5 | |
| 6 | // A function with infinite loop returns never |
| 7 | function infiniteLoop(): never { |
| 8 | while (true) { |
| 9 | // runs forever |
| 10 | } |
| 11 | } |
| 12 | |
| 13 | // Exhaustive switch — never is used for type safety |
| 14 | type Shape = |
| 15 | | { kind: "circle"; radius: number } |
| 16 | | { kind: "rectangle"; width: number; height: number } |
| 17 | | { kind: "triangle"; base: number; height: number }; |
| 18 | |
| 19 | function area(shape: Shape): number { |
| 20 | switch (shape.kind) { |
| 21 | case "circle": |
| 22 | return Math.PI * shape.radius ** 2; |
| 23 | case "rectangle": |
| 24 | return shape.width * shape.height; |
| 25 | case "triangle": |
| 26 | return (shape.base * shape.height) / 2; |
| 27 | default: |
| 28 | const _exhaustive: never = shape; |
| 29 | return _exhaustive; |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // If you add a new shape to the union and forget the case, |
| 34 | // TypeScript will error: Type '"pentagon"' is not assignable to type 'never' |
| 35 | |
| 36 | // never can be assigned to any type (bottom type property) |
| 37 | const str: string = throwError("oops"); // OK — never is assignable to string |
| 38 | const num: number = throwError("oops"); // OK |
| 39 | |
| 40 | // never cannot be assigned FROM any type |
| 41 | const neverVal: never = undefined as never; |
| 42 | // const x: string = neverVal; // Error — never is not assignable to string |
| 43 | // (unless strictNullChecks is off, where undefined IS assignable to string) |
| 44 | |
| 45 | // Void vs never |
| 46 | function log(msg: string): void { |
| 47 | console.log(msg); // returns undefined implicitly |
| 48 | } |
| 49 | |
| 50 | function crash(msg: string): never { |
| 51 | throw new Error(msg); // never returns |
| 52 | |
| 53 | // A function that returns void CAN be used where never is expected |
| 54 | // if TypeScript allows implicit coercion. In practice, void means |
| 55 | // "the return value is not used", while never means "this never completes." |
| 56 | } |
info
| 1 | // Real-world exhaustive discriminated union |
| 2 | type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; |
| 3 | |
| 4 | interface Route { |
| 5 | method: HttpMethod; |
| 6 | path: string; |
| 7 | handler: () => void; |
| 8 | } |
| 9 | |
| 10 | function registerRoute(route: Route): void { |
| 11 | switch (route.method) { |
| 12 | case "GET": |
| 13 | console.log(`GET ${route.path}`); |
| 14 | break; |
| 15 | case "POST": |
| 16 | console.log(`POST ${route.path}`); |
| 17 | break; |
| 18 | case "PUT": |
| 19 | console.log(`PUT ${route.path}`); |
| 20 | break; |
| 21 | case "DELETE": |
| 22 | console.log(`DELETE ${route.path}`); |
| 23 | break; |
| 24 | case "PATCH": |
| 25 | console.log(`PATCH ${route.path}`); |
| 26 | break; |
| 27 | default: |
| 28 | const _exhaustive: never = route.method; |
| 29 | throw new Error(`Unknown method: ${_exhaustive}`); |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // Adding "HEAD" to HttpMethod forces all switch statements to handle it |
| 34 | // Type '"HEAD"' is not assignable to type 'never' |
| 35 | |
| 36 | // Exhaustive check as a utility function |
| 37 | function assertNever(value: never): never { |
| 38 | throw new Error(`Unexpected value: ${JSON.stringify(value)}`); |
| 39 | } |
| 40 | |
| 41 | // Usage in function |
| 42 | function getStatusColor(status: "success" | "error" | "warning" | "info"): string { |
| 43 | switch (status) { |
| 44 | case "success": return "#22C55E"; |
| 45 | case "error": return "#EF4444"; |
| 46 | case "warning": return "#F59E0B"; |
| 47 | case "info": return "#3B82F6"; |
| 48 | default: return assertNever(status); |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | // Async exhaustive check |
| 53 | type EventKind = "created" | "updated" | "deleted" | "archived"; |
| 54 | |
| 55 | async function handleEvent(event: { kind: EventKind; data: unknown }) { |
| 56 | switch (event.kind) { |
| 57 | case "created": |
| 58 | await createRecord(event.data); |
| 59 | break; |
| 60 | case "updated": |
| 61 | await updateRecord(event.data); |
| 62 | break; |
| 63 | case "deleted": |
| 64 | await deleteRecord(event.data); |
| 65 | break; |
| 66 | case "archived": |
| 67 | await archiveRecord(event.data); |
| 68 | break; |
| 69 | default: |
| 70 | assertNever(event.kind); |
| 71 | } |
| 72 | } |
unknown is the type-safe counterpart to any. Like any, unknown can hold any value. Unlike any, you cannot use unknown without first narrowing its type.
| 1 | // unknown — can hold any value |
| 2 | let data: unknown = "hello"; |
| 3 | data = 42; |
| 4 | data = { name: "Alice" }; |
| 5 | data = [1, 2, 3]; |
| 6 | |
| 7 | // Cannot use unknown directly |
| 8 | // const len: number = data.length; // Error: 'data' is of type 'unknown' |
| 9 | |
| 10 | // Must narrow before use |
| 11 | if (typeof data === "string") { |
| 12 | console.log(data.length); // OK — narrowed to string |
| 13 | } |
| 14 | |
| 15 | if (Array.isArray(data)) { |
| 16 | console.log(data.length); // OK — narrowed to any[] |
| 17 | } |
| 18 | |
| 19 | // Type assertion as a last resort |
| 20 | const str = data as string; // No compile error, but no runtime check |
| 21 | |
| 22 | // Safe type assertion with runtime check |
| 23 | function asString(value: unknown): string { |
| 24 | if (typeof value === "string") return value; |
| 25 | throw new TypeError(`Expected string, got ${typeof value}`); |
| 26 | } |
| 27 | |
| 28 | // JSON.parse returns any — wrap it with unknown |
| 29 | function safeJsonParse<T>(json: string): unknown { |
| 30 | return JSON.parse(json); |
| 31 | } |
| 32 | |
| 33 | const parsed = safeJsonParse('{"name":"Alice"}'); |
| 34 | // parsed is unknown — must narrow |
| 35 | if (typeof parsed === "object" && parsed !== null && "name" in parsed) { |
| 36 | console.log((parsed as { name: string }).name); |
| 37 | } |
| 38 | |
| 39 | // unknown in function parameters — forces caller to be explicit |
| 40 | function processValue(value: unknown): string { |
| 41 | if (value === null || value === undefined) return ""; |
| 42 | if (typeof value === "string") return value; |
| 43 | if (typeof value === "number") return String(value); |
| 44 | if (typeof value === "boolean") return value ? "true" : "false"; |
| 45 | if (Array.isArray(value)) return value.map(String).join(", "); |
| 46 | return JSON.stringify(value); |
| 47 | } |
| 48 | |
| 49 | // API response typed as unknown |
| 50 | async function fetchJson<T>(url: string): Promise<T> { |
| 51 | const response = await fetch(url); |
| 52 | const data: unknown = await response.json(); |
| 53 | // Validate data matches T before returning |
| 54 | return data as T; |
| 55 | } |
best practice
| 1 | // Type narrowing — TypeScript eliminates branches as impossible |
| 2 | |
| 3 | // 1. typeof narrowing |
| 4 | function format(value: unknown): string { |
| 5 | if (typeof value === "string") return value.toUpperCase(); |
| 6 | if (typeof value === "number") return value.toFixed(2); |
| 7 | if (typeof value === "boolean") return value ? "yes" : "no"; |
| 8 | if (typeof value === "undefined") return "N/A"; |
| 9 | return String(value); |
| 10 | } |
| 11 | |
| 12 | // 2. instanceof narrowing |
| 13 | function handleError(error: unknown): string { |
| 14 | if (error instanceof TypeError) return `Type: ${error.message}`; |
| 15 | if (error instanceof RangeError) return `Range: ${error.message}`; |
| 16 | if (error instanceof Error) return `Error: ${error.message}`; |
| 17 | return "Unknown error"; |
| 18 | } |
| 19 | |
| 20 | // 3. Truthiness narrowing |
| 21 | function process(input: string | null | undefined): string { |
| 22 | if (!input) return "empty"; |
| 23 | return input.toUpperCase(); // narrowed to string |
| 24 | } |
| 25 | |
| 26 | // 4. Equality narrowing |
| 27 | function compare(a: string | number, b: string | boolean) { |
| 28 | if (a === b) { |
| 29 | // a and b are both string (the only common type) |
| 30 | return a.toUpperCase(); |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | // 5. in narrowing |
| 35 | interface Fish { swim(): void; } |
| 36 | interface Bird { fly(): void; } |
| 37 | |
| 38 | function move(animal: Fish | Bird) { |
| 39 | if ("swim" in animal) { |
| 40 | animal.swim(); // narrowed to Fish |
| 41 | } else { |
| 42 | animal.fly(); // narrowed to Bird |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // 6. Discriminated union narrowing |
| 47 | type Result<T> = |
| 48 | | { success: true; data: T } |
| 49 | | { success: false; error: string }; |
| 50 | |
| 51 | function handleResult(result: Result<string>) { |
| 52 | if (result.success) { |
| 53 | console.log(result.data.toUpperCase()); // narrowed to { success: true; data: string } |
| 54 | } else { |
| 55 | console.error(result.error); // narrowed to { success: false; error: string } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // 7. Assertion function narrowing |
| 60 | function assertString(value: unknown): asserts value is string { |
| 61 | if (typeof value !== "string") { |
| 62 | throw new TypeError(`Expected string, got ${typeof value}`); |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | const val: unknown = "hello"; |
| 67 | assertString(val); |
| 68 | console.log(val.length); // narrowed to string — no cast needed |
| 1 | // TypeScript type hierarchy (top to bottom): |
| 2 | |
| 3 | // unknown — the top type (accepts everything) |
| 4 | // any — escape hatch (bypasses type checking) |
| 5 | // object — non-primitive types |
| 6 | // {} — any non-null/undefined value |
| 7 | // string | number | boolean | symbol | bigint | null | undefined |
| 8 | // never — the bottom type (accepts nothing) |
| 9 | |
| 10 | // Assignability (top → bottom): |
| 11 | let x: unknown; |
| 12 | x = 42; // OK — unknown accepts anything |
| 13 | x = "hello"; // OK |
| 14 | x = null; // OK |
| 15 | |
| 16 | let y: any; |
| 17 | y = 42; // OK — any accepts anything |
| 18 | y = "hello"; // OK |
| 19 | y = null; // OK |
| 20 | |
| 21 | // never is assignable to everything |
| 22 | declare const n: never; |
| 23 | const a: string = n; // OK |
| 24 | const b: number = n; // OK |
| 25 | const c: object = n; // OK |
| 26 | const d: unknown = n; // OK |
| 27 | |
| 28 | // But nothing is assignable to never (except never itself) |
| 29 | // const e: never = 42; // Error |
| 30 | |
| 31 | // Practical comparison |
| 32 | type SafeParse = (input: unknown) => Result<string>; |
| 33 | type UnsafeParse = (input: any) => Result<string>; |
| 34 | type CrashParse = (input: never) => Result<string>; |
| 35 | // CrashParse can never be called — input can never be provided |
| 36 | |
| 37 | // never in conditional types |
| 38 | type NonNullable<T> = T extends null | undefined ? never : T; |
| 39 | type A = NonNullable<string | null | undefined>; // string |
| 40 | |
| 41 | // never in mapped types — filter out keys |
| 42 | type FilterNever<T> = { |
| 43 | [K in keyof T as T[K] extends never ? never : K]: T[K]; |
| 44 | }; |
| 45 | |
| 46 | type Mixed = { a: string; b: never; c: number; d: undefined }; |
| 47 | type Cleaned = FilterNever<Mixed>; // { a: string; c: number; d: undefined } |
| 48 | |
| 49 | // The any vs unknown decision |
| 50 | function processAny(value: any) { |
| 51 | // No type checking — dangerous |
| 52 | return value.foo.bar.baz; |
| 53 | } |
| 54 | |
| 55 | function processUnknown(value: unknown) { |
| 56 | // Must narrow — safe |
| 57 | if ( |
| 58 | typeof value === "object" && |
| 59 | value !== null && |
| 60 | "foo" in value |
| 61 | ) { |
| 62 | const obj = value as { foo: { bar: { baz: string } } }; |
| 63 | return obj.foo.bar.baz; |
| 64 | } |
| 65 | throw new TypeError("Invalid value"); |
| 66 | } |
info
1. Use never exhaustiveness checks in every switch statement over a discriminated union.
2. Replace any with unknown for function parameters, especially at API boundaries.
3. Create asserts value is T helper functions for runtime type validation.
4. Use never as the return type of functions that always throw or loop infinitely.
5. Use never in conditional types to filter out unwanted union members.
6. When working with JSON.parse, wrap the result as unknown and validate before use.