skip to content

In TypeScript, what return type is inferred for a function that always throws — and why does `function fail(m: string) { throw new Error(m); }` infer a different type from `const fail = (m: string) => { throw new Error(m); };`?

level: middleimportance: must knowfreq 55%

answer

  1. a body that cannot finish
  2. declaration and arrow disagree
  3. void unless you ask for never
  4. flow analysis ends at the call
  5. the call target needs an annotated name

basics

~20 s

A function expression or arrow function whose body always throws or loops forever infers never. A function declaration with the same body infers void instead, so you must annotate it : never explicitly to get the never return type.

solid answer

~50 s

When a function body cannot complete — it always throws, or contains an unterminated loop — the honest return type is `never`, because control never comes back to the caller. TypeScript only *infers* that for function expressions and arrow functions. A `function` **declaration** with an identical body infers `void`, a deliberate conservative choice, so you have to write `: never` on it yourself. The distinction matters because control-flow analysis uses `never` returns: a call to a never-returning function ends the flow, so code after it is treated as unreachable and a preceding narrowing survives. That effect has an extra requirement — the compiler only applies it when the call target is a name whose declaration carries an explicit type annotation. An arrow that merely *infers* `never` gets you the type but not the flow effect. This behaviour is the same across the TypeScript 5.x and 6.x lines.

code

typescript · 18 lines
typescript
// inference differs by form of declaration
function failDecl(msg: string) { throw new Error(msg); }        // => void
const failExpr = (msg: string) => { throw new Error(msg); };    // => never

// annotate explicitly to get the flow effect
function fail(msg: string): never { throw new Error(msg); }

function lengthOf(x: string | undefined) {
  if (x === undefined) fail("missing");
  return x.length; // x narrowed to string: the other path cannot continue
}

// inferred-only never does NOT narrow at the call site
function lengthOf2(x: string | undefined) {
  if (x === undefined) failExpr("missing");
  // return x.length; // Error: 'x' is possibly 'undefined'
  return x?.length ?? 0;
}

go deeper

for a junior

Know that a function which always throws does not return a value, and that TypeScript can express this with the return type never. Be able to write function fail(m: string): never.

for a middle

Explain the inference rule and the declaration-versus-expression asymmetry, and show what the never return buys at the call site: unreachable following code and narrowing that survives the guard.

for a senior

Demonstrate the call-site annotation requirement and how a wrong never annotation silently corrupts flow analysis across a codebase. Discuss where you standardise a fail/unreachable helper and how you keep it honest.

for a principal

Own the convention: which error paths in the codebase terminate via a never-returning helper versus returning a result type, and what that choice does to testability, logging, and how failures surface at service boundaries.

## Why a throwing function has a return type at all A return type describes the value the caller receives. If the body always throws — or spins in `while (true)` — the caller never receives anything, because control does not come back. The type that describes "no value ever arrives here" is `never`, the empty type. So `never` is not a marker meaning "returns nothing"; that is `void`, which means "returns, but the result is not meant to be used". `never` means "does not return". ## The asymmetry Give the same body to a declaration and to an arrow function and TypeScript infers two different things: ```ts function failDecl(msg: string) { throw new Error(msg); } // inferred: (msg: string) => void const failExpr = (msg: string) => { throw new Error(msg); }; // inferred: (msg: string) => never function loopDecl() { while (true) {} } // inferred: () => void const loopExpr = () => { while (true) {} }; // inferred: () => never ``` The rule the compiler applies to a function **expression** or **arrow function** is: if it has no return type annotation, has no return statements returning a value, and control-flow analysis shows the end of the body is unreachable, infer `never`. Function **declarations** are excluded from that rule and infer `void` unless you annotate them. The reason is conservatism about a construct that is hoisted and widely referenced. Silently upgrading every throwing declaration to `never` would change the meaning of a lot of existing code, since a `never` return participates in control-flow analysis at every call site. Whatever the history, the practical instruction is simple: **if you want a declaration to return `never`, say so.** ```ts function fail(msg: string): never { throw new Error(msg); } ``` ## What the never return actually buys you The payoff is control-flow analysis. When the checker sees a call to something whose return type is `never`, it knows execution cannot continue past that statement. Two things follow: code after the call is unreachable, and a narrowing established before the call survives afterwards. ```ts function fail(msg: string): never { throw new Error(msg); } function lengthOf(x: string | undefined) { if (x === undefined) fail("missing"); return x.length; // x is string here — the undefined branch could not continue } ``` Without the `never` return, `x` would still be `string | undefined` on the last line and `x.length` would error under `strictNullChecks`. ## The annotation requirement at the call site There is a second, less-known rule, and interviewers who use this question tend to know it. The flow effect only applies when the called function is referenced through a **name whose declaration has an explicit type annotation**. A value that merely *infers* `never` does not get it: ```ts const failNoAnn = (msg: string) => { throw new Error(msg); }; // type is (msg: string) => never, but the const has no annotation function lengthOf(x: string | undefined) { if (x === undefined) failNoAnn("missing"); return x.length; // Error: 'x' is possibly 'undefined' } ``` Annotate the declaration and it works: ```ts const failAnn: (msg: string) => never = (msg) => { throw new Error(msg); }; // now `if (x === undefined) failAnn("missing");` does narrow ``` The same applies to dotted names: `helpers.fail(...)` only ends the flow when `helpers` itself is declared with an explicit type. This is the same restriction that governs assertion functions, and it exists so the checker does not have to infer types it is in the middle of using to compute flow. ## Practical guidance - Write `: never` explicitly on any throw-helper you intend other code to rely on — `fail`, `unreachable`, `notImplemented`, a `panic` wrapper. - Annotate the *variable* too if the helper is a `const` arrow, not just the arrow's return. - Remember `never` is a subtype of everything, so a never-returning call is legal in an expression position: `const value = cache.get(k) ?? fail("missing")` type-checks, and the result type is just the non-never side. - Do not reach for `never` on a function that merely returns nothing useful. If it can complete, its return type is `void`. ## The runtime caveat All of this is erased. `: never` compiles to nothing; the only thing that actually stops execution is the `throw` or the infinite loop in the body. If a helper is annotated `never` but a refactor makes some path return normally, the compiler keeps trusting the annotation — a stale `never` silently teaches the checker something false about every call site. Keep the annotation and the body honest together.

  • How does a `never` return type differ from `void` for the caller?
    `void` means the function returns and the result is not meant to be used; execution continues past the call. `never` means the function does not return at all, so the checker treats the following code as unreachable and keeps any narrowing established before the call. A function that can complete on some path must not be annotated `never`.
  • You annotate a helper `: never`, but a later refactor adds a path that returns normally. What happens?
    The compiler reports the mismatch inside the helper — a function typed `never` cannot have a reachable end point or a value-returning `return`. If someone silences that with an assertion or `any`, the annotation becomes a lie the checker still trusts: call sites keep treating following code as unreachable, and narrowings survive that are no longer justified.
  • Can you call a never-returning function in the middle of an expression?
    Yes. Because `never` is assignable to every type, a call to it fits anywhere a value is expected — `const v = map.get(k) ?? fail("missing")` type-checks, and `v` gets the non-never type. It reads as an inline guard, and it also documents at the type level that the fallback path terminates.

saying these in an interview costs you the question

  • Assumes every throwing function infers never regardless of form
  • Says a never return type is the same as void
  • Thinks the never annotation causes the throw
  • Expects narrowing from an unannotated const arrow helper
  • Annotates never on a function that can still return

context