When building TypeScript applications, whether on the frontend or backend, you've probably come across the never type at some point. Most of the time, it's easy to ignore. You can always just annotate a type yourself, so you hover over it in your editor, shrug, and move on.
But once you understand how it actually works, never becomes one of the most powerful tools in your TypeScript toolkit.
In this post, we'll break down what never really means, how it compares to unknown, and how to use both together in a real API handler.
What never Actually Means
At its core, never represents a value that can never exist.
The TypeScript docs describe it precisely: when you narrow a union type down so far that no possibilities remain, TypeScript uses never to represent that state, one that shouldn't exist. In type theory, it's called a bottom type: a subtype of every other type, but with nothing that can be assigned to it.
You'll see it show up in two main situations.
Functions that never return:
function throwError(message: string): never {
throw new Error(message)
}
function infiniteLoop(): never {
while (true) {}
}These functions don't complete normally, they either throw or run forever , so their return type is never, not void. The distinction matters: void means "returns nothing useful," while never means "does not return at all."
Impossible branches after narrowing:
function handle(value: string | number) {
if (typeof value === "string") {
// value is string
} else if (typeof value === "number") {
// value is number
} else {
// value is never — TypeScript knows this branch is unreachable
}
}How never Compares to unknown
never and unknown sit at opposite ends of TypeScript's type system, and understanding both together is what makes this really click.
unknown is the top type, it means "this could be anything." You're allowed to assign any value to unknown, but you can't use it for anything until you narrow it first, until you prove it as a particular type.
never is the bottom type, it means "this cannot exist." It's assignable to everything, but nothing can be assigned to it.
// unknown: everything flows IN, nothing flows OUT without proof
let a: unknown = "hello" // ✅ any value can be assigned to unknown
let b: string = a // ❌ you must narrow it first
// never: nothing flows IN, everything flows OUT
declare let x: never
let y: string = x // ✅ never is assignable to any type
x = "hello" // ❌ nothing can be assigned to neverA clean way to think about it:

One important correction from a common misconception: never being "assignable to every type" doesn't mean you can use it freely at runtime, it means the type system allows it structurally, because a never value can never actually be produced. If you somehow reach code that holds a never, something has already gone wrong.
The mental model:
unknown→ "I don't trust this yet — prove it."never→ "This should not exist — if you're here, something is wrong."
The Deeper Idea: Making Invalid States Unrepresentable
Before the example, it's worth sitting with the philosophy behind using never deliberately.
The idea is simple but powerful: design your types so that incorrect states literally cannot be expressed.
Most developers use TypeScript defensively, they write code, then add types to catch mistakes. The better approach suggests designing types first so that wrong code cannot compile at all.
never is how you encode "this must not exist in this context."
Example: Conflicting props:
// ❌ This allows both href and onClick, which makes no sense
type ButtonProps = {
href?: string
onClick?: () => void
}
// ✅ Exactly one is required, the other is forbidden
type ButtonProps =
| { href: string; onClick?: never }
| { onClick: () => void; href?: never }Example: API response states:
// ❌ Allows nonsense like { status: "success", error: "oops" }
type RequestState = {
status: "loading" | "success" | "error"
data?: string
error?: string
}
// ✅ Each state carries only what it should
type RequestState =
| { status: "loading"; data?: never; error?: never }
| { status: "success"; data: string; error?: never }
| { status: "error"; error: string; data?: never }In the second version, your types become executable documentation, a developer reading the type immediately understands the constraints, the compiler enforces them, and you've eliminated an entire class of runtime bugs.
Exhaustiveness Checking
The most practically useful application of never is exhaustiveness checking in switch statements. Here's how the TypeScript docs illustrate it:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number }
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2
case "square":
return shape.sideLength ** 2
default:
const _exhaustiveCheck: never = shape
return _exhaustiveCheck
}
}As long as every case is handled, the default branch is unreachable, so assigning shape to never compiles fine. But if you add a new shape:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; sideLength: number }
| { kind: "triangle"; base: number; height: number } // addedNow the default branch becomes reachable, shape is Triangle (not never), and TypeScript immediately errors:
Type 'Triangle' is not assignable to type 'never'.You've turned a potential silent runtime bug into a compile-time error. That's the power of never used intentionally.
A cleaner version of this pattern uses an assertNever helper:
function assertNever(x: never): never {
throw new Error("Unhandled case: " + JSON.stringify(x))
}
// Usage:
default:
return assertNever(shape)This serves double duty: TypeScript checks exhaustiveness at compile time, and if somehow a case slips through at runtime, you get a clear error instead of silent incorrect behavior.
Putting It Together: A Real API Handler
Here's where both types earn their keep. The pattern is:
unknownat the boundary, treat all external input as untrusted- Zod (or manual guards) to narrow
unknownto a safe type neverto guarantee your internal logic handles every case
import { z } from "zod"
// 1. Define the schema — this is your contract
const createUserSchema = z.object({
name: z.string().min(1),
age: z.number().int().positive(),
role: z.enum(["admin", "user"]),
})
type CreateUserBody = z.infer<typeof createUserSchema>
// 2. Exhaustiveness helper
function assertNever(x: never): never {
throw new Error("Unhandled role: " + x)
}
// 3. Role-based logic — never enforces completeness
function getPermissions(role: CreateUserBody["role"]): string[] {
switch (role) {
case "admin":
return ["read", "write", "delete"]
case "user":
return ["read"]
default:
return assertNever(role) // if you add a role and forget to handle it, this breaks at compile time
}
}
// 4. The handler
export async function handler(req: Request): Promise<Response> {
try {
// unknown at the boundary — we trust nothing coming in
const rawBody: unknown = await req.json()
// Zod narrows unknown → CreateUserBody
// Throws a ZodError with structured field-level messages if invalid
const body = createUserSchema.parse(rawBody)
// From here, body is fully typed and trusted
const permissions = getPermissions(body.role)
return new Response(
JSON.stringify({ success: true, data: { ...body, permissions } }),
{ status: 200 }
)
} catch (error) {
if (error instanceof z.ZodError) {
return new Response(
JSON.stringify({ success: false, errors: error.issues }),
{ status: 400 }
)
}
return new Response(
JSON.stringify({ success: false, message: "Internal server error" }),
{ status: 500 }
)
}
}What this demonstrates:
The external world is untrusted, so we treat req.json() as unknown. Zod validates the shape and infers a precise TypeScript type, you move from "could be anything" to "definitely this shape" in one line. Then assertNever in the switch guarantees that if someone adds "super-admin" to the schema and forgets to update getPermissions, the build fails immediately.
The flow: external world (unsafe) →
unknown→ validate → safe types →neverensures completeness.
Quick Reference
void vs never:
function returnsNothing(): void {} // completes, returns undefined
function neverReturns(): never { // does not complete
throw new Error()
}When to use unknown: validating external data, API responses, user input, anything crossing a trust boundary.
When to use never: exhaustiveness checks, forbidding impossible prop combinations, marking unreachable branches, functions that only throw.
What's the point?
Most people treat TypeScript as a safety net, something that catches mistakes after you write code. The better mental model is to use it as a design tool: write types that make incorrect code impossible to write in the first place.
unknown makes you prove what something is before you use it. never makes wrong states impossible to represent.
Together, they let you accept flexible input at the edges of your system while enforcing strict correctness at the core.