In TypeScript, a helper is written as `function isString(value: string | number): boolean { return typeof value === 'string'; }`. Callers then do `if (isString(x)) { x.toUpperCase(); }` and the compiler rejects `x.toUpperCase()`. Why does the boolean return type not narrow, and what change fixes it?
answer
- the signature is the whole contract
- calls are opaque to control-flow analysis
- boolean says nothing about the argument
- one word in the return type
- name the parameter before is
basics
~20 sA boolean return type says nothing about which argument was checked, and the checker never looks inside a called function. Declaring the return type as value is string turns the helper into a type predicate, so callers narrow.
solid answer
~50 sTypeScript's narrowing is driven by what it can see in the condition itself, and a function call is a black box: only the signature crosses the boundary. `boolean` states that the call yields true or false and nothing about the relationship between that result and the argument, so the compiler has no licence to refine `x`. The fix is one word in the return type — `function isString(value: string | number): value is string` — which declares a type predicate: on a true result, the argument passed as `value` is narrowed to `string`, and in the else branch `string` is subtracted from the union, leaving `number`. Three rules apply when you write one: the name before `is` must be a parameter of that function (or `this` in a method), the asserted type must be assignable to that parameter's declared type, and the body still has to return a boolean.
code
typescript · 16 lines// Does not narrow: boolean says nothing about the argument.
function isStringLoose(value: string | number): boolean {
return typeof value === "string";
}
// Narrows: the return type names the parameter and the asserted type.
function isString(value: string | number): value is string {
return typeof value === "string";
}
function f(x: string | number): string {
if (isString(x)) return x.toUpperCase(); // x: string
return x.toFixed(2); // x: number
}
console.log(f("ok"), f(1.5), isStringLoose("x"));go deeper
Recognise the symptom: a helper that clearly checks the type, yet the compiler still complains at the call site. Remember the fix is in the helper's return type, not at the place the error appears.
Explain that narrowing is driven by the signature because a call is opaque to control-flow analysis, then write the corrected signature and state the rules — parameter name before is, asserted type assignable to the parameter type, boolean body.
Show judgment about where these live: a small set of reviewed guards at the system's untyped edges beats assertions scattered through call sites, and an explicit annotation beats relying on the compiler inferring one.
Frame it as an API boundary decision — a shipped predicate is a claim consumers build on and the compiler will never recheck, so decide whether your libraries expose guards, schema-validated parsers, or both, and hold them to one standard.
## What the checker sees at a call Control-flow narrowing in TypeScript is *intraprocedural*: inside a function body, the checker refines a reference when the condition it branches on has a shape it understands — `typeof x === "string"`, `x instanceof Date`, `"kind" in x`, an equality test against a literal, a truthiness test. When the condition is a call, none of that is visible. The compiler does not inline `isString` to discover that the body ran a `typeof` check. It reads only the declared signature. So ask what the signature actually promises: ```ts function isString(value: string | number): boolean ``` It promises "calling this with a `string | number` produces a boolean". There is no link expressed between that boolean and the argument, so on a `true` result the compiler is entitled to conclude exactly nothing about `x`. Rejecting `x.toUpperCase()` on a `string | number` is the correct behaviour, not a bug. ## The one-line fix ```ts function isString(value: string | number): value is string { return typeof value === "string"; } function f(x: string | number) { if (isString(x)) { x.toUpperCase(); // x: string } else { x.toFixed(2); // x: number } } ``` The return type now *is* the promise: "when this returns true, treat the argument you passed as `value` as a `string`". Control-flow analysis picks that up at every call site, in both branches — the true branch keeps the union members assignable to `string`, the false branch removes them. ## The rules for writing one **The subject must be a parameter of that function.** You write the parameter's name, not the caller's variable name — `value is string`, even though callers pass `x`. You may name any parameter, not only the first: `function hasKind(a: number, b: unknown): b is { kind: string }` is legal. A method may use `this` as the subject instead. What you cannot write is a predicate about a property path — there is no `value.inner is string` form; if you need that, take the inner value as the parameter, or assert an object type such as `o is { name: string }`. **The asserted type must be assignable to the parameter's declared type.** `function isCircle(s: Shape): s is Circle` is fine when `Circle` is part of `Shape`; `function isDate(s: string): s is Date` is an error — *A type predicate's type must be assignable to its parameter's type*. This is the compiler's only structural sanity check on the declaration; it says nothing about whether the body is right. **The body must still return a boolean.** A predicate function is an ordinary boolean function with a more informative declared return type; returning a non-boolean is an error. **Any function form can carry one.** Declarations, function expressions, arrow functions, class and object methods, and call signatures in an interface or type alias: ```ts const isString = (v: unknown): v is string => typeof v === "string"; interface Guard { (v: unknown): v is string; } ``` ## Why not just assert at the call site The alternative people reach for is `(x as string).toUpperCase()`. That silences the error without performing any check and has to be repeated at every use. The predicate keeps one real runtime check in one place and lets the type flow to all callers — the same effort, spent once, with the check actually running. ## A version wrinkle worth knowing Since TypeScript 5.5 the compiler can *infer* a type predicate for a function that has no return type annotation, when the body is a single return of an expression that narrows a parameter and that parameter is never reassigned. An unannotated `function isString(value: string | number) { return typeof value === "string"; }` therefore narrows on 5.5 and later. Writing `: boolean` explicitly opts out of that inference — which is exactly the situation in the question, and a good reason to prefer the explicit `value is string` annotation: it states the intent and works on every version. ## What the predicate still does not give you The compiler checks that the asserted type fits the parameter and that the body returns a boolean. It does not check that the boolean the body computes has anything to do with the type you asserted — the signature is taken on faith.
- Can the predicate talk about a property, as in `value.inner is string`?No — the subject must be a parameter name of that function, or `this` in a method. If you need to prove something about an inner value, either pass that value to the guard, or assert an object type such as `v is { inner: string }` and let property access narrow from there.
- What happens if you write `function isDate(s: string): s is Date`?It is an error: a type predicate's type must be assignable to its parameter's type, and `Date` is not assignable to `string`. That is the only structural check the compiler applies to the declaration — it never verifies that the body proves the claim.
- Does the predicate have to be on the first parameter?No. Any parameter can be the subject, by name: `function pick(index: number, value: unknown): value is string` is legal and narrows the second argument at call sites. A method may also use `this` as the subject.
- If a colleague removes the return type annotation entirely, does the helper still narrow?On TypeScript 5.5 and later, often yes — the compiler infers a predicate when the body is a single return whose expression narrows a parameter that is never reassigned. It is fragile though: adding a second return or reassigning the parameter silently drops the narrowing, so annotate explicitly.
saying these in an interview costs you the question
- Assumes the compiler inspects the helper's body at call sites
- Says the narrowing failed because of strict mode
- Reaches for `as string` at the call site instead
- Writes the caller's variable name before `is`
- Thinks the predicate makes the function return something other than a boolean