Learning path · Level 8 · Tutorial 6

Validate Unknown Data at Typed API Boundaries

Build a typed API boundary that checks external JSON before returning application data and handles invalid input without bypassing the type checker.

  • Level: Intermediate
  • Duration: 25 min
  • 7 steps
Start tutorial Back to the path
Validate Unknown Data at Typed API Boundaries

What you will go through

  1. Keep Decoded JSON Unknown Keep external JSON untrusted at the boundary and distinguish runtime validation from TypeScript-only assumptions. 3 min
  2. Build a Trustworthy Product Predicate Implement a Product type predicate that earns TypeScript narrowing through complete runtime checks. 5 min
  3. Parse the Whole Collection Build an all-or-nothing parser that returns Product[] only after validating the collection root and every product entry. 4 min
  4. Handle Unknown Caught Errors Safely narrow unknown caught values before reading an Error-specific message. 3 min
  5. Return a Validated Async Result Connect response decoding and runtime validation to a typed asynchronous success-or-failure result. 5 min
  6. Verify the Boundary with Local JSON Change local JSON fixtures and observe how the typed API boundary accepts valid data or returns failure without relying on unchecked assumptions. 5 min
  7. Review and Apply the Boundary Checklist Review the full typed API boundary, audit unchecked assumptions, and justify a runtime-validated success result. 2 min

What you will learn

  • Assign external JSON to unknown and explain why static annotations do not establish its runtime shape.
  • Implement a type predicate and parser that check every required field before returning a typed record or collection.
  • Integrate validation into a typed asynchronous operation and narrow its success or failure result before use.
  • Handle an unknown caught error without assuming that every thrown value is an Error.
  • Manually verify acceptance and rejection using locally authored JSON with valid records, missing fields, and incorrect field types.
  • Explain why a type assertion or non-null assertion cannot replace runtime validation.

Before you start

  • Narrow Unions and Nullable Values
  • Preserve Type Relationships with Generics
  • Fetch and Validate JSON Responses
  • Handle Failures with JavaScript Exceptions

Step 1 of 7

Keep Decoded JSON Unknown

Keep external JSON untrusted at the boundary and distinguish runtime validation from TypeScript-only assumptions.

Start at the boundary

Decoded does not mean trusted

A response body can be valid JSON and still have the wrong application shape. It might be an array, a string, or an object with missing or incorrectly typed fields.

At the typed API boundary, keep decoded JSON as unknown. This states an important fact: the application has received a value, but has not yet established runtime evidence for its shape.

Unlike any, unknown blocks unchecked property access. Before application code can treat the value as product data, a later runtime validation step must inspect it.

A flow from untrusted decoded JSON through a runtime validation checkpoint to typed product records.

The boundary has three stages: decoded JSON, runtime validation, then typed application data.

Do not confuse types with evidence

Capture decoded JSON as unknown

Place the explicit annotation directly after decoding. It prevents a permissive library return type from spreading into the rest of the program.

Boundary capture

src/product-api.ts

typescript
export type Product = {
  id: number;
  name: string;
  inStock: boolean;
};

export async function decodeProductPayload(
  response: Response,
): Promise<unknown> {
  const raw: unknown = await response.json();
  return raw;
}

Checker assumptions are not runtime checks

These forms affect what the TypeScript checker permits. None examines a decoded response while the program runs. Runtime validation must provide that evidence later.

Compare each form at an external-data boundary.
FormEffect on TypeScriptRuntime inspection
`const raw: unknown`Requires evidence before useNo; intentionally preserves uncertainty
`value as Product`Claims the value is ProductNo
`Promise<Product>`Describes a promised resultNo
`value!`Removes null or undefined from the checker viewNo

Practice the boundary annotation

Practice: preserve uncertainty

Complete the boundary annotation.

typescript
async function decode(response: Response): Promise<unknown> {
  const raw:  = await response.json();
  return raw;
}

Check your reasoning

Boundary decision

A response could decode to { "id": "wrong" }. Which boundary choice both preserves uncertainty and correctly explains why assertions cannot make this payload valid?

Step 2 of 7

Build a Trustworthy Product Predicate

Implement a Product type predicate that earns TypeScript narrowing through complete runtime checks.

Define the evidence for one Product

One record needs evidence

A predicate is the first runtime gate for one candidate value. Its job is to accept a value only when the value actually has every required part of the Product contract:

  • id: a number
  • name: a string
  • inStock: a boolean

Start from unknown. Before reading fields, prove that the candidate is an object, is not null, and is not an array. Then check field presence and each field’s runtime type.

This is runtime validation: the JavaScript checks run against the actual decoded JSON value.

The predicate checklist

A diagram shows an unknown JSON value passing through object, non-array, id number, name string, and inStock boolean checks before becoming a verified product.

Every required field needs its own runtime check; an off boolean toggle represents the valid value false.

Tip

Check types, not truthiness

Do not use truthiness for required fields. A condition such as if (value.inStock) rejects false, even though false is a valid boolean for this contract. Likewise, 0 is a valid number and an empty string is still a string.

Check the declared type instead. Extra properties do not prevent a record from being a Product; this predicate validates the required contract rather than removing data.

Connect runtime checks to narrowing

Write the predicate

Add the contract and predicate to src/product-api.ts. The return annotation value is Product is a user-defined type predicate. When this function returns true, TypeScript narrows the caller’s value to Product.

The object guard comes first because property inspection is only safe after unknown has been narrowed to a non-null, non-array object. Each "field" in value check establishes presence, and its following typeof check establishes the declared runtime type.

Complete predicate

src/product-api.ts

typescript
export type Product = {
  id: number;
  name: string;
  inStock: boolean;
};

export function isProduct(value: unknown): value is Product {
  if (
    typeof value !== "object" ||
    value === null ||
    Array.isArray(value)
  ) {
    return false;
  }

  return (
    "id" in value &&
    typeof value.id === "number" &&
    "name" in value &&
    typeof value.name === "string" &&
    "inStock" in value &&
    typeof value.inStock === "boolean"
  );
}

Warning

The predicate makes a promise

The annotation value is Product does not make the implementation correct. TypeScript trusts the predicate’s declared result; it does not prove that you checked every field. A missing check can compile while letting invalid runtime data through.

A type assertion such as value as Product would only tell the checker to assume the contract. It would not inspect decoded JSON, so it cannot replace these checks.

Practice: complete the predicate

Complete the runtime checks

Complete the structural guard and the runtime type checks for this Product predicate.

typescript
export function isProduct(value: unknown): value is Product {
  if (
    typeof value !==  ||
    value ===  ||
    (value)
  ) {
    return false;
  }

  return (
    "id" in value &&
    typeof value.id ===  &&
    "name" in value &&
    typeof value.name ===  &&
    "inStock" in value &&
    typeof value.inStock === 
  );
}

Practice: find the missing evidence

Audit a predicate claim

Consider this predicate:

function isProduct(value: unknown): value is Product {
  if (
    typeof value !== "object" ||
    value === null ||
    Array.isArray(value)
  ) {
    return false;
  }

  return (
    "id" in value &&
    typeof value.id === "number" &&
    "name" in value &&
    typeof value.name === "string"
  );
}

True or false: This is a trustworthy isProduct predicate for the Product contract.

Step 3 of 7

Parse the Whole Collection

Build an all-or-nothing parser that returns Product[] only after validating the collection root and every product entry.

Define the parser contract

A collection needs two levels of checking

Decoded JSON may be an array, an object, a string, a number, or null. parseProducts is the next boundary: it accepts unknown and either returns a fully checked Product[] or throws new Error("Invalid product data.").

Decoding answers “is this valid JSON syntax?” Parsing answers “does this value have the application shape we require?” Those are different checks.

The parser is all-or-nothing. An empty array is valid. A collection with even one invalid entry is rejected rather than filtered. Extra properties are allowed: validation confirms the required Product fields; it does not sanitize records.

From decoded JSON to typed collection

A diagram showing decoded JSON passing through an array check and per-record checks before becoming a typed product collection, with any failed record rejecting the collection.

The root must be an array, and every entry must pass isProduct before the parser returns.

Tip

Boundary rule

Do not silently keep only the valid entries. Filtering would make a damaged external payload look like a successful, incomplete result.

Check the root, then each entry

Implement the all-or-nothing parser

src/product-api.ts — complete file

typescript
export type Product = {
  id: number;
  name: string;
  inStock: boolean;
};

export function isProduct(value: unknown): value is Product {
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    return false;
  }

  return (
    "id" in value &&
    typeof value.id === "number" &&
    "name" in value &&
    typeof value.name === "string" &&
    "inStock" in value &&
    typeof value.inStock === "boolean"
  );
}

export function parseProducts(value: unknown): Product[] {
  if (!Array.isArray(value)) {
    throw new Error("Invalid product data.");
  }

  const entries: unknown[] = value;
  const products: Product[] = [];

  for (const entry of entries) {
    if (!isProduct(entry)) {
      throw new Error("Invalid product data.");
    }

    products.push(entry);
  }

  return products;
}

Tip

Why use an unknown[] variable

Array.isArray identifies the root, but its library typing can expose array elements too permissively. Assigning to unknown[] makes each entry earn its Product type through isProduct. After that predicate succeeds, products.push(entry) is safe.

Practice the safe structure

Complete the parser checks

Complete the parser safeguards without using a type assertion.

typescript
export function parseProducts(value: unknown): Product[] {
  if (!(value)) {
    throw new Error("Invalid product data.");
  }

  const entries:  = value;
  const products:  = [];

  for (const entry of entries) {
    if (!(entry)) {
      throw new Error("Invalid product data.");
    }

    products.push(entry);
  }

  return products;
}

Predict the boundary outcome

Apply the all-or-nothing rule

Without using a type assertion or non-null assertion, write a compact parseProducts implementation or outline. It must show the safe check order, use an unknown[] entries variable and a Product[] accumulator, and reject an invalid entry.

Then predict what happens for these two decoded values:

  1. []
  2. [{"id":1,"name":"Notebook","inStock":false},{"id":2,"name":"Pen","inStock":"yes"}]

Write at least 250 characters (0/250).

Step 4 of 7

Handle Unknown Caught Errors

Safely narrow unknown caught values before reading an Error-specific message.

Treat Caught Values as Unknown

A caught value is external input too

A parser may throw an Error, but JavaScript allows any value to be thrown. A catch block can therefore receive a string, null, or an unrelated object—not only an Error.

Treat the caught value as unknown. Before reading error.message, obtain runtime evidence that the value is actually an Error. This keeps error handling from becoming a second unchecked boundary.

Narrow before reading a message

A decision flow where only an Error object reaches its own message; strings, null, and unrelated objects reach one fallback outcome.

The runtime check decides whether it is safe to read an error-specific property.

Guard the Error-Specific Property

Add one safe error-message helper

Add this helper to src/product-api.ts. instanceof Error narrows error only inside the if block. Outside that block, the function returns the documented fallback for every non-Error thrown value.

getErrorMessage

src/product-api.ts — add this complete helper

typescript
export function getErrorMessage(error: unknown): string {
  if (error instanceof Error) {
    return error.message;
  }

  return "Unexpected failure.";
}

Outcomes of the helper

The fallback is deliberate: it is not safe to assume that a thrown string, null, or { message: "..." } is an Error. Do not use a type assertion to skip this check; an assertion changes the checker’s assumption but does not inspect the runtime value.

Only an actual Error instance contributes its message.
Thrown valueRuntime resultReturned string
new Error("Offline.")Error instanceOffline.
"Offline."Not an ErrorUnexpected failure.
nullNot an ErrorUnexpected failure.
{ message: "Offline." }Not an ErrorUnexpected failure.

Practice the Safe Guard

Complete the narrowing check

Complete the condition so this helper can safely return a caught Error's message and otherwise return its fallback.

typescript
export function getErrorMessage(error: unknown): string {
  if (error  Error) {
    return error.message;
  }

  return "Unexpected failure.";
}

Match Values to Safe Results

Apply the helper’s contract

Match each thrown value to the string returned by getErrorMessage.

Tap an item and then its matching pair.

Step 5 of 7

Return a Validated Async Result

Connect response decoding and runtime validation to a typed asynchronous success-or-failure result.

Place validation inside the async boundary

One boundary, two outcomes

A typed API boundary turns an uncertain asynchronous operation into one of two explicit outcomes:

  • success contains Product[] only after runtime validation.
  • failure contains a message instead of untrusted data.

loadProducts returns Promise<Result<Product[]>>. A resolved promise can therefore still represent failure; callers must inspect result.ok before reading variant-specific properties. HTTP status, JSON decoding, and application-shape validation are separate checks, but each can lead to the failure variant.

The async boundary pipeline

Diagram showing decoded JSON passing through validation before a success path, with HTTP, decoding, and invalid-shape problems exiting to failure.

Only data that passes parseProducts can travel to the success outcome.

Construct success only after parsing

Add the loader

In src/product-api.ts, keep the Product, parseProducts, and getErrorMessage exports built in earlier steps. Add this result contract and asynchronous boundary below them. The raw value remains unknown after decoding; parseProducts is the runtime gate before success is constructed.

src/product-api.ts — async boundary addition

typescript
export type Result<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

export async function loadProducts(
  url: string,
): Promise<Result<Product[]>> {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      return { ok: false, error: `HTTP ${response.status}` };
    }

    const raw: unknown = await response.json();
    const products = parseProducts(raw);

    return { ok: true, data: products };
  } catch (error: unknown) {
    return { ok: false, error: getErrorMessage(error) };
  }
}

Tip

Why every path stays safe

fetch rejection, invalid JSON, and parseProducts throwing all reach catch. getErrorMessage safely converts the unknown caught value into a message. An unsuccessful HTTP response is returned earlier as a message such as HTTP 404, without attempting to decode its body.

Consume the narrowed outcome

Narrow the result at the call site

A caller does not receive a bare Product[]; it receives a result union. Check its discriminant first. Inside each branch, TypeScript narrows to the matching variant, so the available property is safe to use.

src/main.ts

typescript
import { loadProducts } from "./product-api.ts";

async function showProducts(): Promise<void> {
  const result = await loadProducts("/products.json");

  if (result.ok) {
    console.log("Success:", result.data);
    return;
  }

  console.error("Failure:", result.error);
}

void showProducts();
Expected output
Success: [{ id: 1, name: "Notebook", inStock: false }, { id: 2, name: "Pen", inStock: true }]

With a valid local fixture, the browser console prints a Success label and the validated array. With a failed request, invalid JSON, or invalid product shape, it prints a Failure label and a message.

Practice: protect both sides of the boundary

Complete the validated async flow

Complete the typed API boundary and its consumer. Preserve the rule that unchecked decoded JSON cannot be placed directly into the success result.

typescript
export async function loadProducts(
  url: string,
): Promise<Result<Product[]>> {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      return { ok: false, error: `HTTP ${response.status}` };
    }

    const raw:  = await response.json();
    const products = ;

    return { ok: true, data: products };
  } catch (error: unknown) {
    return { ok: false, error:  };
  }
}

async function showProducts(): Promise<void> {
  const result = await loadProducts("/products.json");

  if () {
    console.log("Success:", );
    return;
  }

  console.error("Failure:", );
}

Step 6 of 7

Verify the Boundary with Local JSON

Change local JSON fixtures and observe how the typed API boundary accepts valid data or returns failure without relying on unchecked assumptions.

Create the runnable local check

Start with a local baseline

Use your completed src/product-api.ts from the earlier steps. Create the local fixture below in public/products.json, then replace src/main.ts with the complete invocation shown here. The loader receives the fixture through "/products.json"; application code does not import the JSON as trusted data.

public/products.json

public/products.json — baseline fixture

json
[
  {
    "id": 1,
    "name": "Notebook",
    "inStock": false
  },
  {
    "id": 2,
    "name": "Pen",
    "inStock": true
  }
]

src/main.ts

src/main.ts — complete file

typescript
import { loadProducts } from "./product-api.ts";

async function run(): Promise<void> {
  const result = await loadProducts("/products.json");

  if (result.ok) {
    console.log("success", result.data);
  } else {
    console.error("failure", result.error);
  }
}

void run();
Expected output
success [
  { id: 1, name: "Notebook", inStock: false },
  { id: 2, name: "Pen", inStock: true }
]

Browser developer tools may format the array differently. What matters is the "success" label and both validated records, including inStock: false.

Separate compiler checks from runtime checks

Use two kinds of evidence

First run npm run typecheck. It checks your TypeScript source, but it does not inspect the JSON that the browser will later fetch from public/products.json.

Then run npm run dev, open the local site, and inspect the browser console. After every fixture edit, save the file and reload the browser. The console result is runtime evidence from the typed API boundary.

A successful type check can therefore coexist with either a runtime success or a runtime failure after you edit the fixture.

Runtime validation decides the branch

A diagram shows local JSON passing through a validation gate and then branching to either a successful product array or a failure console result.

The TypeScript source stays the same while a changed JSON payload can produce success or failure at runtime.

Tip

Keep the boundary fixed

Do not change Product, add an assertion, or weaken the predicate to make a failing fixture pass. Keep the typed API boundary unchanged; each fixture is evidence about whether its runtime shape actually satisfies the contract.

Try acceptance and rejection cases

Fixture matrix

Replace the entire contents of public/products.json with one fixture at a time. Save and reload after each replacement. All rows except the last are syntactically valid JSON; their outcome depends on runtime validation of the application shape.

Local fixture cases

Use each complete value as the full contents of public/products.json.

Expected console branch for each locally authored payload
CaseComplete fixture contentsExpected console branch
Baseline with false[{"id":1,"name":"Notebook","inStock":false},{"id":2,"name":"Pen","inStock":true}]success
Missing field[{"id":1,"name":"Notebook"}]failure: Invalid product data.
Incorrect field type[{"id":1,"name":"Notebook","inStock":"false"}]failure: Invalid product data.
Mixed collection[{"id":1,"name":"Notebook","inStock":false},{"id":2,"name":"Pen"}]failure: Invalid product data.
Invalid root{"id":1,"name":"Notebook","inStock":false}failure: Invalid product data.
Empty collection[]success
Invalid JSON[{"id":1,"name":"Notebook","inStock":false},]failure with a JSON parsing message

Verify and explain the boundary

Report your verification

Run the baseline, empty collection, missing-field, incorrect-type, mixed-collection, invalid-root, and invalid-JSON cases. Run npm run typecheck without changing the TypeScript source, and inspect the console after each browser reload.

Write one concise report of what you observed. Include the console branch for every case and explain why the type-check result did not establish that the edited JSON had the Product collection shape.

Observed versus expected outcomes

Report the observed type-check result and console outcome for every fixture case. State what happened for the baseline containing false and for the empty collection. State what happened for the missing field, incorrect type, mixed collection, invalid root, and invalid JSON. Finish by explaining why unchanged TypeScript can type-check successfully while runtime validation rejects edited JSON.

Write at least 300 characters (0/300).

Step 7 of 7

Review and Apply the Boundary Checklist

Review the full typed API boundary, audit unchecked assumptions, and justify a runtime-validated success result.

See the complete boundary flow

Two kinds of evidence

A typed API boundary has one job: turn untrusted decoded JSON into either typed application data or an explicit failure.

A TypeScript annotation affects the checker, not the payload arriving at runtime. The reliable sequence is:

  1. Capture decoded JSON as unknown.
  2. Perform runtime validation.
  3. Return typed data only after validation succeeds.
  4. Return or handle a failure when it does not.

as Product[] and value! can silence a checker concern, but neither examines a JSON value. A successful npm run typecheck proves that your code follows declared types; it does not prove that a server or local JSON fixture sent those types.

The typed API boundary

A flow from decoded JSON through an unknown container and validation checklist to either a protected typed record or a failure branch.

Only values that pass runtime validation may cross into typed application data.

Audit assumptions before trusting success

Intentionally flawed boundary excerpt — src/product-api.ts

This intentionally flawed excerpt may type-check, but it has no runtime basis for its success result.

typescript
export type Product = {
  id: number;
  name: string;
  inStock: boolean;
};

export type Result<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

export function isProduct(value: unknown): value is Product {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof value.id === "number"
  );
}

export async function loadProducts(url: string): Promise<Result<Product[]>> {
  try {
    const response = await fetch(url);
    const raw = await response.json();
    return { ok: true, data: raw as Product[] };
  } catch (error: any) {
    return { ok: false, error: error.message };
  }
}

Warning

Audit the unchecked assumptions

This excerpt has several boundary leaks:

  • Its predicate checks only id; it has not established name or inStock.
  • It does not check response.ok.
  • Decoded JSON is accepted through a type assertion instead of a parser.
  • error: any assumes a message property exists.

A safe repair does not need a more persuasive assertion. It needs runtime evidence: an HTTP check, const raw: unknown = await response.json();, a parser that validates the root and every entry, and getErrorMessage(error) after catching error: unknown.

Summary

Reusable boundary checklist

  • Capture external decoded JSON as unknown.
  • Validate the root, every collection entry, and every required field.
  • Construct success only from parser output.
  • Convert failed HTTP, decoding, and validation paths into an explicit failure.
  • Narrow result.ok before a consumer reads data or error.
  • Never treat an assertion or compiler success as runtime validation.

Apply the pipeline

Route both outcomes through safe functions

Complete the two boundary calls in this src/product-api.ts function.

typescript
export async function loadProducts(url: string): Promise<Result<Product[]>> {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    const raw: unknown = await response.json();
    const products = (raw);

    return { ok: true, data: products };
  } catch (error: unknown) {
    return { ok: false, error: (error) };
  }
}

Final boundary application

Final boundary repair — src/product-api.ts

Assume Product and a complete isProduct(value: unknown): value is Product already exist in src/product-api.ts. This assertion-based parser is the boundary to repair.

typescript
export function parseProducts(value: unknown): Product[] {
  return value as Product[];
}

Final application: repair, predict, and justify

Rewrite parseProducts as a complete implementation without any, type assertions, or non-null assertions. It must reject a non-array root and any invalid entry with new Error("Invalid product data."); use an unknown[] entries variable and a Product[] accumulator.

Then predict what happens for this unseen decoded JSON value:

[{"id":3,"name":"Cable","inStock":"yes"}]

State whether the parser returns or throws, and explain exactly where runtime evidence establishes the type of a successful returned collection.

Write at least 280 characters (0/280).

Boundary checklist complete

Congratulations! You completed: Validate Unknown Data at Typed API Boundaries

You can now build a typed API boundary that accepts runtime-validated data and exposes explicit failures instead of unchecked assumptions.

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate