skip to content

In TypeScript, a utility module exports `function isString(value: unknown): value is string`. What does the `value is string` return type mean, and what type does `value` have inside `if (isString(value)) { ... }`?

level: juniorimportance: must knowfreq 62%

answer

  1. the return type does the work
  2. boolean at runtime, more at compile time
  3. control-flow analysis reads the signature
  4. a true result narrows the argument

basics

~20 s

The return type value is string makes isString a user-defined type guard: it returns an ordinary boolean at runtime, but the compiler treats a true result as proof and narrows value to string inside the if-branch.

solid answer

~40 s

`value is string` is a **type predicate**. At runtime nothing special happens — the function returns `true` or `false` like any boolean function, and the annotation is erased from the emitted JavaScript. What it adds is a signal to control-flow analysis: wherever you call `isString(x)` and branch on the result, the compiler narrows *the argument you passed* to `string` in the true branch. So inside `if (isString(value))`, `value` has type `string` and `value.toUpperCase()` compiles even though the parameter was declared `unknown`. In the else branch the compiler subtracts `string` from the declared type when there is something to subtract — `string | number` becomes `number`, while `unknown` stays `unknown`. The point is reuse: you write the check once and every call site narrows.

code

typescript · 12 lines
typescript
function isString(value: unknown): value is string {
  return typeof value === "string";
}

function describe(input: unknown): string {
  if (isString(input)) {
    return input.toUpperCase(); // input narrowed to string
  }
  return String(input); // input is still unknown here
}

console.log(describe("ok"), describe(42));

go deeper

for a junior

Be able to read the signature out loud: the return type names a parameter and a type, and a true result means the compiler treats that argument as that type inside the branch. Say plainly that it still returns a boolean.

for a middle

Explain why the narrowing has to live in the signature at all — control-flow analysis does not look inside a called function — and describe what both branches get, including that subtraction only happens for unions.

for a senior

Show where you place predicates in a real system: at untyped boundaries such as parsed JSON or worker messages, one per shape, so that no call site needs a type assertion to use the value.

for a principal

Own the policy question: which boundaries are allowed to hand out narrowed types at all, who writes those guards, and what review standard they are held to, since every predicate is a claim the compiler will never re-examine.

## Why a predicate exists at all TypeScript's checker narrows types by reading the *code* of a condition: it recognises shapes like a `typeof` comparison, an `instanceof` test, or a discriminant equality check, and refines the variable inside the branch. A hand-written helper hides that check behind a function call, and calls are opaque to this analysis — the checker does not inline the body to see what it proved. Only the helper's **signature** crosses the call boundary. A type predicate is the mechanism for putting the narrowing *into* the signature so it survives the call. ## Reading the signature ```ts function isString(value: unknown): value is string { return typeof value === "string"; } ``` The return-type position holds `parameterName is Type` instead of `boolean`. The name on the left must be a parameter of that same function; the type on the right is what the compiler will assume about the corresponding argument when the call evaluates to `true`. The body still has to return a boolean expression — a predicate function is an ordinary boolean function with a richer declared return type. ## What the call site does ```ts function describe(input: unknown) { if (isString(input)) { return input.toUpperCase(); // input: string } return String(input); // input: unknown } ``` Narrowing lands on the *argument expression*, not on the parameter. It works when the argument is a reference the checker can track: a variable, a `const`, or a property path such as `payload.name` — `if (isString(payload.name)) payload.name.trim()` compiles. It does not work when you pass a fresh expression like `isString(makeValue())`, because there is no reference left to narrow, and it resets if you reassign the variable afterwards. ## Both branches, not just the true one In the true branch the compiler keeps the parts of the declared type compatible with the asserted type. If the declared type is `unknown` or `any`, you simply get the asserted type; if it is a union, you get the members assignable to it. The false branch does the opposite where it can: it *subtracts*. ```ts declare function isString(v: string | number): v is string; function f(v: string | number) { if (isString(v)) { v; // string } else { v; // number — string was removed from the union } } ``` Subtraction only happens when the declared type is a union containing something to remove. Starting from `unknown`, the else branch is still `unknown`, because "not a string" is not a type TypeScript can name. Candidates often expect `never` there — that is wrong. ## Nothing of this exists at runtime Types are erased. The emitted JavaScript for `isString` is the same function with the annotations stripped: ```js function isString(value) { return typeof value === "string"; } ``` That has two consequences worth saying out loud in an interview. First, a predicate costs nothing: it is not a cast, not a conversion, not a reflective check the compiler bolts on. Second, the annotation is a *promise the compiler takes at face value* — the type layer trusts what the signature claims about the boolean the body returns. ## Where they earn their keep Any place a value arrives untyped and you need to prove its shape once and use it many times: the result of `JSON.parse` (typed `any`), a `catch` variable typed `unknown` under `useUnknownInCatchVariables`, a message from a worker or socket, or a wide union you want to split. Writing `as string` at each use site would type-assert without checking and would have to be repeated; a predicate performs the real runtime check in one place and hands the narrowing to every caller. ## Habits that keep them readable Name them `isX` so the call site reads like a condition. Keep the body to a single boolean expression. Declare the parameter as wide as callers actually need — `unknown` for values from outside the program, the union type when you are splitting a known union — since the asserted type must be compatible with the parameter's declared type.

  • Does anything about `isString` change in the JavaScript that TypeScript emits?
    No. Types are erased, so the emitted function is `function isString(value) { return typeof value === "string"; }` — a plain boolean function. The predicate exists only for the checker, which is why it costs nothing at runtime and why it cannot enforce anything there.
  • You call `isString(payload.name)` rather than passing a plain variable. Does the narrowing stick?
    Yes. TypeScript narrows references, and a property access path like `payload.name` is a reference it tracks, so `payload.name.trim()` compiles inside the branch. It does not work for a call result such as `isString(getName())`, because there is no reference to attach the narrowing to.
  • If the parameter were declared `string | number` instead of `unknown`, what would the else branch give you?
    `number`. When the declared type is a union, the false branch subtracts the asserted type from it. Starting from `unknown` there is nothing to subtract, so the else branch stays `unknown` — it never becomes `never`.

It is a signed receipt rather than an inspection: the function still just says yes or no, but the signature tells the compiler what a "yes" is allowed to be taken as proof of.

saying these in an interview costs you the question

  • Thinks any function returning boolean narrows its argument
  • Calls the predicate a runtime cast or conversion
  • Expects the check to appear in the emitted JavaScript
  • Says the else branch always narrows to never
  • Believes the predicate narrows the parameter rather than the caller's value

context