skip to content

In TypeScript, what type does `if (Array.isArray(x))` narrow `x: unknown` to, and why is that a hole in an otherwise strict codebase?

level: middleimportance: should knowfreq 38%

answer

  1. look at how the library declares it
  2. the predicate is wider than you want
  3. elements come back unchecked
  4. no strictness flag reports a library any

basics

~20 s

It narrows to any[], because the standard library declares the check with the predicate arg is any[]. Every element read out of it is any, so a strict codebase silently loses type safety inside the branch.

solid answer

~40 s

The standard library declares the check as `isArray(arg: any): arg is any[]`, so narrowing an `unknown` with it produces `any[]` — an array whose elements are `any`. Inside the branch, `x[0]` and everything derived from it type-check against anything, and no strictness flag catches it, because the `any` comes from a library declaration rather than from an implicit annotation in your code. That is the hole: the guard that was supposed to make an untrusted value safe hands you the least safe element type there is. Narrowing a *union* is unaffected — for `string[] | string` the guard filters to `string[]` normally. For unknown input, wrap it: a one-line predicate returning `x is unknown[]` keeps the array-ness and forces you to check the elements before using them.

code

typescript · 17 lines
typescript
function handleLoose(x: unknown) {
  if (Array.isArray(x)) {
    // x: any[] — element access is unchecked from here on
    return x[0];
  }
  return undefined;
}

function isUnknownArray(x: unknown): x is unknown[] {
  return Array.isArray(x);
}

function handleStrict(x: unknown): string[] {
  if (!isUnknownArray(x)) return [];
  // x: unknown[] — each element must still be checked
  return x.filter((item): item is string => typeof item === "string");
}

go deeper

for a junior

Know that this is the correct way to test whether a value is an array, that a typeof check cannot do it, and that it narrows a union such as string-or-array to the array member.

for a middle

Explain that the standard library declares it with a predicate of any-array, so narrowing an unknown yields any-array and element access stops being checked.

for a senior

Demonstrate the habit of reading library type declarations rather than assuming, and wrap the guard in a predicate returning unknown-array so untrusted input still forces a per-element check.

for a principal

Own where any is permitted to enter the system at all: treat library-sourced any as a boundary defect, and standardise on validated parsing at the edge so ad-hoc guards are not the last line of defence.

## The declaration behind the behaviour The built-in check is declared in the standard library as a type predicate: ```ts interface ArrayConstructor { isArray(arg: any): arg is any[]; } ``` Narrowing always produces the predicate's stated type, so applying it to an `unknown` gives `any[]`: ```ts function handle(x: unknown) { if (Array.isArray(x)) { // x: any[] x[0].whatever.deeply.nested; // compiles, no complaint } } ``` The branch is exactly where a careful codebase most wants precision — the value came from outside and has just been established to be an array — and it is the one place the checker stops helping. ## Why no flag catches it The strictness flags that police `any` are about *implicit* occurrences: a parameter or variable with no annotation that the compiler had to fall back on. Here the `any` is explicit, correct as a declaration, and lives in the library's own type definitions. Nothing in your source has an unannotated position, so nothing is reported. The `any` propagates outward from the branch through every expression derived from an element, and each of those expressions also stops being checked. This is the general shape of the `any` hazard — it is contagious, and one entry point disables checking for everything downstream of it. Why is the library declared that way? Because the check genuinely proves only array-ness. `unknown[]` would be the honest element type, but it would break a great deal of existing code that reads elements straight out of the narrowed value, so the declaration favours compatibility. ## Unions are the easy case When the input is a union, the guard behaves like any other predicate and simply filters the constituents: ```ts function lines(v: string[] | string): string[] { if (Array.isArray(v)) { return v; // v: string[] — element type preserved } return v.split("\n"); // v: string } ``` The element type survives because narrowing keeps the declared constituent rather than replacing it with the predicate's type. So the trap is specific to narrowing from `unknown` or `any` — that is, exactly the untrusted-input case. ## The fix Wrap the check once, in a helper whose predicate tells the truth: ```ts function isUnknownArray(x: unknown): x is unknown[] { return Array.isArray(x); } function handle(x: unknown) { if (isUnknownArray(x)) { // x: unknown[] for (const item of x) { if (typeof item === "string") { // item: string } } } } ``` Now the compiler forces a per-element guard before anything is used, which is the check you should have been writing anyway. The same wrapper is what a validation library gives you at its boundary; if the codebase already runs a schema validator on inbound data, that layer supersedes this one entirely. ## The other half: what the guard proves The check establishes array-ness and nothing else. It says nothing about length, about element types, or about whether the array is homogeneous. It also excludes `null` on its own, which makes it a cleaner alternative to an object-tag test when what you actually want from a nullable union is the array member. And, like every guard here, it is erased in one direction only: the call itself is real JavaScript that survives compilation, while the narrowed type is bookkeeping that ends at emit. ## Interview framing Name the declared predicate, state that narrowing an `unknown` therefore yields `any[]`, and explain why no flag reports it — that is the part that separates a candidate who has read the library declarations from one who has only used the API. Then give the one-line wrapper as the fix, and note that narrowing a union was never affected.

  • Why does no strictness flag report the `any` you get from this guard?
    Because those flags target *implicit* occurrences — positions in your own code that the compiler had to fall back on because there was no annotation. Here the `any` is explicit and comes from the standard library's own declaration, so there is nothing unannotated to report. It then propagates through every expression derived from an element, silently disabling checking downstream.
  • Does the same problem appear when narrowing `string[] | string`?
    No. With a declared union the guard filters constituents rather than substituting the predicate's type, so the branch is `string[]` with its element type intact. The hazard is specific to starting from `unknown` or `any` — which is unfortunately exactly the untrusted-input case where you reached for the guard in the first place.
  • When the value is a nullable union, why might you prefer this guard over a `typeof` object test?
    Because the object tag test keeps `null` in the true branch — the underlying runtime check cannot separate them — so you need a second condition before touching any property. This guard's predicate matches only arrays, so `null` lands in the false branch and the true branch is immediately usable.

saying these in an interview costs you the question

  • Assumes the narrowed element type is unknown rather than any
  • Expects noImplicitAny to flag the resulting any
  • Thinks the check also proves the elements share a type
  • Believes the guard is special-cased in the compiler rather than declared in the library
  • Uses an assertion to "fix" the element type instead of checking elements

context