skip to content

In TypeScript, when is it worth writing an explicit return type annotation on a function, and when is letting the compiler infer it the better choice?

level: seniorimportance: must knowfreq 62%

answer

  1. checked against, versus reported from
  2. where the error is allowed to land
  3. the exported surface is a contract
  4. a type that refers to itself
  5. inference is often the narrower answer

basics

~20 s

Annotate the return type when the result should be checked against a contract you chose: exported API, recursive functions, and anywhere a wrong result must be reported at the definition rather than at distant call sites. Let local helpers and callbacks infer.

solid answer

~50 s

An explicit return type turns the function's result into something the compiler *checks* rather than something it *reports*. That buys three things worth paying for. First, errors land at the function instead of at every call site — a body that accidentally returns `undefined` on one branch is flagged where the bug actually is. Second, it pins the public contract, so a refactor cannot silently widen or change what an exported function hands back; the change is caught inside the function, not months later in a consumer. Third, it is effectively mandatory for a directly or mutually recursive function, since inference cannot resolve a return type that refers to itself. Against that, inference is frequently more precise than what you would write by hand, an annotation is one more thing that drifts, and a hand-written type can accidentally widen a usefully narrow inferred one. My working rule: annotate exported and recursive functions, and let everything internal infer.

code

typescript · 10 lines
typescript
type Node = { children: Node[] };

// Without ": number" the compiler cannot infer this: the function
// is referenced in one of its own return expressions.
function depth(node: Node): number {
  if (node.children.length === 0) return 1;
  return 1 + Math.max(...node.children.map(depth));
}

console.log(depth({ children: [{ children: [] }] }));

go deeper

for a junior

Know that the return type is optional because the compiler reads it from the body, and that you write one mainly on functions other files call.

for a middle

Explain the direction change — annotated means the body is checked against your type, inferred means the body defines it — and give the recursion case where the annotation is required.

for a senior

Argue the tradeoff with real consequences: errors localised to the definition and an exported contract a refactor cannot silently widen, weighed against annotations that drift and hand-written types wider than the inferred ones.

for a principal

Own it as policy — where the annotation boundary sits, whether lint enforces it on exported symbols only, and how that interacts with declaration emit and the stability of a published package's type surface.

## What the annotation changes Without an annotation the compiler *reports* the function's return type: it reads the body, unions the return expressions, and that becomes the signature. With an annotation the direction reverses — the compiler *checks* the body against the type you wrote, and the type you wrote is what callers see regardless of what the body happens to produce today. That difference in direction is the whole argument. Everything below follows from it. ## Reason one: errors land at the definition With an inferred return type, a mistake in the body is not an error at all — it just changes the function's type, and the complaint surfaces wherever a caller uses the result. On a widely-used helper that means a dozen errors, none of them in the file you edited. ```ts type Status = "open" | "closed"; function label(status: Status): string { if (status === "open") return "Open"; // error here: not every code path returns a string } ``` Annotated, the missing branch is an error on `label` itself: the function lacks an ending return statement and the declared return type does not include `undefined`. (That check depends on `strictNullChecks`; without it, `undefined` is assignable to `string` and the branch slips through.) Unannotated, the inferred type would quietly become `string | undefined` and the failure would move to whichever caller wrote `label(s).toUpperCase()`. ## Reason two: it pins the contract For an exported function the return type *is* the API. Inference means the API is whatever the current implementation computes, so an innocent-looking change to the body is an API change: - a callback that starts returning `T | null` silently widens the exported type; - returning the whole record instead of a projection leaks internal fields to every consumer; - an internal type used in the return position becomes part of your published surface whether you meant it to or not. Annotating states the contract independently of the implementation. The refactor that would have widened it now fails inside the function, which is where you can still make a decision about it. ```ts interface User { id: string; secretHash: string } function publicUser(u: User): { id: string } { return u; // returning more than promised is fine; callers see only { id: string } } ``` ## Reason three: recursion Inference cannot resolve a return type that depends on itself. A function referenced in one of its own return expressions has no fixed point to compute, and under `noImplicitAny` the compiler says so directly — the function implicitly has return type `any` because it lacks an annotation and is referenced in one of its return expressions. The annotation breaks the cycle: ```ts type Node = { children: Node[] }; function depth(node: Node): number { if (node.children.length === 0) return 1; return 1 + Math.max(...node.children.map(depth)); } ``` Mutual recursion between two functions has the same problem and the same fix. ## Reason four, a smaller one: build behaviour When a package emits declaration files, every exported function's return type has to appear in the output. If it is inferred, the compiler must compute it and name every type it references, which can drag awkward imports into the declaration output and, on a large surface, costs time. Explicit return types on the exported layer make that emit deterministic. This is a real effect, but it is the weakest of the four reasons — use it as supporting evidence, not as the argument. ## The case for inference Inference is not the lazy option; frequently it is the more accurate one. - **It is often narrower than what you would write.** Generic helpers, functions returning tuples, and functions whose result depends on narrowing all infer types that are tedious and error-prone to write out. Annotating them is how you accidentally widen your own code. - **It cannot drift.** An annotation is a second statement of the truth, and second statements go stale. When a small internal helper changes shape, inference propagates the change and the checker finds every consequence; an annotation has to be edited too. - **It is less noise.** On a small local helper the annotation restates a single-expression body word for word. The cost of inference is exactly the flip side of reasons one and two: a mistake becomes a type change instead of an error, and the blast radius is however far the value travels. ## Async functions An annotated async function's return type must be a promise type, so you write `Promise<T>` and never the bare `T`. That does not change any of the reasoning above; it only means the contract you pin is the promise's, and a mismatch between the annotation and what the body resolves to is caught in the body. ## A defensible policy Annotate: exported functions and public class methods, recursive and mutually recursive functions, and any function whose correct return type is a design decision rather than an implementation detail. Infer: local helpers, callbacks and arrows in contextual positions, and anything whose inferred type is more precise than what you would write. If you want the rule enforced rather than remembered, a lint rule can require return types on exported functions only — the variant that requires them everywhere is the one teams regret, because it forces hand-written approximations of types the compiler already knew exactly.

  • Would you enforce explicit return types on every function with a lint rule?
    No — only on exported ones. Requiring them everywhere forces hand-written approximations of types the compiler already knew precisely, widens generic helpers, and adds churn on every small refactor. The value is concentrated at the module boundary, where the type is a contract other code depends on; inside a module, inference is both more accurate and self-maintaining.
  • How does an explicit return type interact with a function whose body returns a union?
    The annotation must be at least as wide as every return expression, so `string | null` is fine where the body returns both. Annotating just `string` errors on the null branch — which is usually what you want, since it forces you to decide whether null is genuinely part of the contract or a bug on that path.
  • Can an explicit return type ever make the code worse?
    Yes, when it is wider than the inferred type. Annotating a helper as `string[]` where a tuple would have been inferred, or as a broad interface where a narrow union was inferred, throws away information every caller could have used. Reach for the annotation to state a decision, not to restate what the body already proves.

saying these in an interview costs you the question

  • Says explicit return types are always better and should be mandatory everywhere
  • Thinks the annotation changes what the function returns at runtime
  • Cannot explain why a recursive function needs one
  • Believes an inferred return type is unchecked or less safe than an annotated one
  • Annotates an async function with the resolved type instead of a promise of it

context