Quick answer
Some path through your function reaches the end without returning. Find that branch, then close it:
- Add a final
returnafter the conditionals — the usual fix. - Turn a bare
ifintoif/elseso both halves return. throwwhen reaching that point means a real bug.- Exhaustive
switch— narrow to a literal union; aneverguard indefaultfuture-proofs it.
Widening the return type to | undefined silences it, but pushes the problem onto every caller.
The exact error string
function grade(score: number): string {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
} // ← score = 50 reaches here, returns nothing
// error TS2366: Function lacks ending return statement and return type
// does not include 'undefined'.
Read it as two clauses, because both have to be true for the error to fire. "Lacks ending return statement" — the compiler traced the control flow and found a path to the closing brace with no return. "Return type does not include 'undefined'" — and you declared string, which can't represent the undefined that path actually produces. Fix either clause and the error clears; the rest of this page is about which one you should fix.
Why this only appears under strictNullChecks
With strictNullChecks: false, undefined is assignable to nearly every type, so a fall-through returning undefined still satisfies a declared string and nothing is reported. Turning the flag on removes that blanket assignability — which is precisely what makes the gap visible.
Worth being clear about the implication: enabling strictNullChecks didn't introduce this bug. The branch that returns nothing was always there; it just wasn't reportable. A wave of TS2366s after switching the flag on is the compiler handing you a list of pre-existing holes, not a list of new ones.
Fix 1: add a final return
The common case — a chain of conditionals that forgot the last one:
function grade(score: number): string {
if (score >= 90) return "A";
if (score >= 80) return "B";
if (score >= 70) return "C";
return "F"; // ✅ every path now returns a string
}
Ask what should happen in the case you didn't write down. If there's a sensible default, this is the whole fix. If there isn't one, that's a signal — go to Fix 3.
Fix 2: make the branches symmetric
A bare if whose body returns leaves an implicit "otherwise, fall off the end". Making the else explicit forces you to answer it:
// ❌ no branch handles the false case
// function label(active: boolean): string {
// if (active) { return "Active"; }
// }
// ✅ both halves return
function label(active: boolean): string {
if (active) {
return "Active";
} else {
return "Inactive";
}
}
Loops carry the same trap, and it's subtler because the code looks complete — the compiler can't know a for body ever executes, so the path where it runs zero times must still return:
// ❌ an empty array falls straight past the loop
// function firstEven(nums: number[]): number {
// for (const n of nums) {
// if (n % 2 === 0) return n;
// }
// }
// ✅ say what an empty (or all-odd) array means
function firstEven(nums: number[]): number | undefined {
for (const n of nums) {
if (n % 2 === 0) return n;
}
return undefined; // "not found" is a real outcome here
}
Note this is the one case where widening the return type is the honest fix: "no even number exists" is a genuine result the caller must handle, not a hole in your logic. Contrast that with grade() above, where every score has a grade and undefined would be meaningless.
Fix 3: throw when the path shouldn't happen
If reaching the end means something upstream is broken, say so. TypeScript's control-flow analysis knows a throw terminates the path, so no return is needed after it:
function requireEnv(name: string): string {
const value = process.env[name];
if (value !== undefined) return value;
throw new Error(`Missing required environment variable: ${name}`); // ✅
}
This keeps the return type as the clean string that callers want, instead of forcing every one of them to handle a | undefined that should never occur in a correctly-configured process. The same reasoning applies to a function typed never (such as a fail() helper) or an infinite loop — all three end the path, and all three satisfy the check.
Fix 4: exhaustive switch with a never guard
A switch that visibly covers every case can still trigger TS2366, which feels wrong until you see what the compiler is actually reasoning about:
// ❌ `status: string` — the compiler must assume some other string exists
// function color(status: string): string {
// switch (status) {
// case "ok": return "green";
// case "warn": return "amber";
// case "error": return "red";
// }
// }
The problem is the parameter type. status is a wide string, so the compiler can't prove those three cases cover every possible value — infinitely many others exist, and each one reaches the closing brace. Narrow it to a literal union and the compiler can track what's left; a default with a never guard then buys you an explicit compile-time exhaustiveness check on top:
type Status = "ok" | "warn" | "error";
function color(status: Status): string {
switch (status) {
case "ok": return "green";
case "warn": return "amber";
case "error": return "red";
default: {
// If a new Status member is added later, `status` is no longer `never`
// here and THIS LINE fails to compile — a compile-time reminder.
const exhaustive: never = status;
throw new Error(`Unhandled status: ${exhaustive}`);
}
}
}
The never assignment is the part worth stealing. Inside default, TypeScript has narrowed status down to the cases you didn't handle — which is never when you've handled them all. Add a fourth member to Status later and that line stops compiling, pointing you at every switch that needs updating. It turns "I hope I covered everything" into something the compiler enforces.
To be precise about what's doing the work here: once status is Status, the three cases already cover the union and the switch compiles clean without a default at all. The clause isn't closing a runtime fall-through — it's a future-proofing guard that fails the build the day someone widens the union and forgets this file.
Same rule, unfamiliar shape: async functions
TS2366 reads differently when the return type is a Promise, and plenty of people don't recognise it as the same control-flow rule:
// ❌ the false path resolves to Promise<undefined>, not Promise<string>
// async function getName(cond: boolean): Promise<string> {
// if (cond) {
// return "Pasindu";
// }
// }
// ✅ close the path exactly as you would in a synchronous function
async function getName(cond: boolean): Promise<string> {
if (cond) {
return "Pasindu";
}
return "anonymous";
}
An async function that falls off the end still returns a promise — one that resolves to undefined. That's Promise<undefined>, which isn't assignable to the declared Promise<string>, so you get the same TS2366 with the same two clauses. The Promise wrapper changes nothing about the rule, and none of the fixes change either: a final return, an explicit else, a throw (which rejects the promise rather than resolving it), or an exhaustive switch all work identically.
Same rule, unfamiliar shape: arrow functions and callbacks
The other place this catches people is inside a callback, where the function being checked is easy to overlook:
const items = [{ name: "alpha", active: true }, { name: "beta", active: false }];
// ❌ annotated `: string`, but the inactive branch returns nothing
// const labels = items.map((item): string => {
// if (item.active) {
// return item.name;
// }
// }); // ← TS2366 points at the arrow function
// ✅ both branches produce a string
const labels = items.map((item): string =>
item.active ? item.name : "(inactive)"
);
Note what makes the error appear: the explicit : string return annotation on the arrow. Drop it and TypeScript infers string | undefined instead, so the callback itself is fine — the mismatch just resurfaces later as TS2322 when you assign the resulting (string | undefined)[] to a string[]. That's an argument for annotating callback return types: it moves the error onto the branch that actually caused it.
The fix to be careful with: widening the return type
Adding | undefined always silences TS2366, which is exactly why it's worth a moment's thought:
// Silences the error — but is `undefined` a real outcome, or a hole?
function grade(score: number): string | undefined {
if (score >= 90) return "A";
if (score >= 80) return "B";
}
// Now every caller inherits the problem:
// const g = grade(85);
// g.toLowerCase(); → TS18048: 'g' is possibly 'undefined'
The test is whether absence is meaningful to a caller. For firstEven() in Fix 2, "not found" is real information and the wider type is correct. For grade(), every score has a grade, so | undefined just relocates the missing branch into every call site — where it shows up as TS18048 or, unchecked, as Cannot read properties of undefined at run time.
TS2366 vs noImplicitReturns
These overlap and get conflated, but they're separate checks with different triggers:
| TS2366 | noImplicitReturns | |
|---|---|---|
| Enabled by | strictNullChecks (part of strict) | Its own flag — not included in strict |
| Fires when | A path falls through and the return type excludes undefined | At least one path returns a value and another reachable path returns nothing |
Return type string | undefined | No error | Still errors if some paths return and others don't |
The "reachable path returns nothing" wording matters: a function that returns nothing on every path — an ordinary void-style function — is consistent, and neither check touches it. What noImplicitReturns objects to is the mix. So it's the stricter of the two, and catches the inconsistent-function case that TS2366 deliberately permits. It's worth enabling alongside strict if you want every function to be uniform about returning — note it is not turned on by strict, so you have to opt in explicitly.
Debugging checklist
- ✓ Trace to the closing brace — which input reaches it without hitting a
return? - ✓ A chain of
ifs with no final fallback? Add the lastreturn - ✓ A bare
ifwhose body returns? Give it an explicitelse - ✓ A
for/whilethat returns inside? The zero-iteration path still needs a return - ✓ Should that path be impossible?
throw— it satisfies the check and surfaces real bugs - ✓
switchover a widestring? Narrow the parameter to a literal union — that alone can make it exhaustive - ✓ Want the compiler to catch a future union member? Assign to
const _: never = valueindefault - ✓
asyncfunction? Same rule — the fall-through resolves toPromise<undefined> - ✓ Error pointing at a callback? Check the arrow's own return annotation, not the outer function
- ✓ Before widening to
| undefined: is absence meaningful to callers, or are you moving the hole? - ✓ Appeared en masse after enabling
strict? These are pre-existing gaps, now visible
Frequently Asked Questions
What does TS2366 mean?
TypeScript traced every path through your function and found at least one that reaches the closing brace without hitting a return. On that path the function returns undefined at run time, which contradicts a declared return type that does not include undefined. It is a control-flow finding, not a typo — the compiler is telling you a real branch produces no value.
Why does TS2366 only appear with strictNullChecks on?
Without strictNullChecks, undefined is assignable to almost every type, so a path that falls through and yields undefined still satisfies a declared return type of string. Turning strictNullChecks on removes that blanket assignability, which is exactly what makes the missing path visible. The bug existed before you enabled the flag — it just was not reportable.
Should I just add undefined to the return type?
Only if callers genuinely need to handle a no-value case. Changing the signature to string | undefined silences TS2366 but pushes an undefined check onto every caller, and skipping those checks produces TS18048 or a runtime crash instead. Prefer closing the gap inside the function with a default return or a throw, and widen the return type only when absence is a real, meaningful outcome.
Why does my switch statement trigger TS2366 when I covered every case?
TypeScript only treats a switch as exhaustive when it can prove the tested value has no remaining possibilities. If the value is typed as string rather than a union of literals, infinitely many unmatched values exist, so a path reaches the closing brace without returning. Narrowing the parameter to a literal union is usually the whole fix — once the cases cover every member, the switch is exhaustive with no default clause at all. Adding a default that assigns the value to a never-typed variable is a separate benefit: it fails the build if someone adds a union member later and forgets this switch.
Does TS2366 apply to async functions?
Yes, and the rule is identical. An async function that falls off the end still returns a promise, but one that resolves to undefined — that is Promise<undefined>, which is not assignable to a declared Promise<string>. The Promise wrapper changes nothing about the control-flow analysis, and the same fixes apply: a final return, an explicit else, or a throw, which rejects the promise rather than resolving it.
What is the difference between TS2366 and noImplicitReturns?
TS2366 comes from strictNullChecks and fires only when the declared return type cannot accommodate undefined. The separate noImplicitReturns compiler option is stricter: it flags a function where at least one path returns a value and another reachable path returns nothing, even when the return type would allow undefined. A function that returns nothing on every path does not trigger it — the objection is to the mix. They overlap but are not the same check.
Does a throw or a loop that never ends satisfy the check?
Yes. TypeScript's control-flow analysis understands that a throw statement, a call to a function returning never, or an infinite loop with no reachable exit terminates the path without falling through, so no return is required there. This is why throwing in a default clause is a clean way to close an unreachable branch — it satisfies the compiler while making the impossible case loud if it ever happens.
References
- strictNullChecks (TSConfig Reference)
- noImplicitReturns (TSConfig Reference)
- More on Functions (TypeScript Handbook)
More TypeScript errors
Browse the full TypeScript error reference — exact message, cause, and fix.