Narrow an unknown API response with type guards

The problem

A response body reaches your code as an unchecked value, and you need the compiler to accept a typed article list only after a real check has run. Asserting the type hides malformed payloads, and a bare cast gives no way to tell the reader what was wrong when the shape does not match.

Short answer

Write composable predicates that return a type predicate signature, build one that checks the collection element by element, and return a result union carrying either the narrowed value or the reason it was refused.

A response body is an unknown value until something proves otherwise. Type predicates are the construct that makes that proof visible to the compiler, and they compose: one small check per decision, then a collector that applies them and reports which entry refused to match.

Language: TypeScript
export type Article = {
  readonly slug: string;
  readonly title: string;
  readonly tags: readonly string[];
};

export type NarrowingResult<TValue> =
  | { readonly ok: true; readonly value: TValue }
  | { readonly ok: false; readonly reason: string };

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isStringArray(value: unknown): value is readonly string[] {
  return Array.isArray(value) && value.every((entry: unknown) => typeof entry === "string");
}

export function isArticle(value: unknown): value is Article {
  if (!isRecord(value)) {
    return false;
  }

  return (
    typeof value["slug"] === "string" &&
    typeof value["title"] === "string" &&
    isStringArray(value["tags"])
  );
}

export function parseArticleCollection(value: unknown): NarrowingResult<readonly Article[]> {
  if (!isRecord(value)) {
    return { ok: false, reason: "expected an object at the top level" };
  }

  const data = value["data"];

  if (!Array.isArray(data)) {
    return { ok: false, reason: "expected an array under data" };
  }

  const articles: Article[] = [];

  for (let index = 0; index < data.length; index += 1) {
    const entry: unknown = data[index];

    if (!isArticle(entry)) {
      return { ok: false, reason: `entry ${index} is not an article` };
    }

    articles.push(entry);
  }

  return { ok: true, value: articles };
}

export function describeCollection(value: unknown): string {
  const parsed = parseArticleCollection(value);

  if (!parsed.ok) {
    return parsed.reason;
  }

  const headings: readonly string[] = parsed.value.map((article) => article.title);

  return `${parsed.value.length} articles: ${headings.join(", ")}`;
}

The three sample bodies below line up with the three distinct answers, which is the point of returning a reason instead of throwing.

Language: Plain text
{ "data": [ { "slug": "static-first", "title": "Static first", "tags": ["astro"] } ] }
  -> ok true, value has one Article

{ "data": [ { "slug": "static-first" } ] }
  -> ok false, reason names entry 0

{ "items": [ ] }
  -> ok false, reason reports no array under data

Explanation

Each predicate has the form value is T, which is a function returning a boolean whose declared type tells the compiler what the boolean means. That declaration is the whole mechanism: when isArticle(entry) returns false the loop exits, and in the code after the if the local entry is narrowed to Article, so articles.push(entry) type-checks with no assertion. The checks are ordered from loosest to strictest — isRecord decides object versus array versus primitive, isStringArray decides collection membership, isArticle composes both — and because each one is a separate function, a different response shape later reuses the pieces instead of re-deriving them. Note the two null and array special cases inside isRecord: typeof null is "object" and typeof [] is also "object", so both must be excluded explicitly or an empty response body would pass as a record.

The reason this is safer than a cast is that the failure path is a value rather than an exception. NarrowingResult is a discriminated union keyed on the literal booleans ok: true and ok: false, so if (!parsed.ok) return parsed.reason; compiles only because the compiler now knows it holds the refused variant, and parsed.value is unavailable in that branch. describeCollection reads parsed.value.map only after that check, which is exactly the guarantee a cast cannot give: with value as readonly Article[] the compiler would accept the map call on a payload that was never checked, and the first missing title would appear as a runtime error in the render path. The loop also shows the boundary of the technique — an unchecked element is annotated as const entry: unknown = data[index] so that a mistaken later edit cannot read any properties off it, because the array test above yields elements of an unchecked type.

Two limits deserve honest treatment. A type predicate is an assertion the compiler trusts rather than verifies: the body of isArticle is type-checked only as an expression returning a boolean, so if you delete the tags test the signature still claims Article and the narrowing still happens. That is why the predicate is worth pairing with a test that feeds it a partial object, and why the field list in it should be reviewed whenever the Article type changes. The second limit is that structural checks accept more than the domain does. An object carrying all three required fields plus unrelated extras still passes, and slug is accepted as any string, so if the value must be a date, a URL, or an enum member, that constraint belongs in an added runtime check, not in the type.

Usage notes

  • Keep one predicate per shape decision and compose the larger ones from the smaller; a predicate that checks three unrelated things at once cannot be reused.
  • Return the result union instead of throwing when the caller has something useful to do with the reason, such as naming the failing entry in a log line.

Common mistakes

  • Testing only that the top-level property is an array, which lets an array of partially valid objects through and moves the crash into the render.

Caveats

  • A type predicate is an assertion the compiler trusts: the body of the function is not proved to match the declared predicate type, so each field test you rely on has to be written deliberately.
  • These checks accept plain JSON-shaped data. A value with extra properties still passes, and structural checks cannot prove a string is a date or a URL.

Prerequisite

Examples that treat this as a prerequisite

Derived at build time from the editorial graph; no edge is invented here.

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References