Validating Route Parameters with TypeScript

After reading this you will be able to validate every dynamic segment once, at the router boundary, so components receive typed values and a malformed URL produces a deliberate not-found response instead of a crash three layers down.

← Back to Dynamic Route Segments

Prerequisites

Core Concept

Every route parameter arrives as a string. It arrives from a source you do not control — a shared link, a crawler probing patterns, an old bookmark, someone editing the address bar. params.id is not “the user’s id”; it is “whatever text sat between two slashes”, and its type in most routers is string | undefined, which is honest but unhelpful.

The failure mode this produces is a crash far from its cause. Number(params.page) yields NaN for /reports/page/abc, NaN flows into an array slice, the slice returns undefined, and a component throws on undefined.length — with a stack trace pointing at a list renderer that is entirely innocent. Meanwhile the URL that caused it is nowhere in the error.

Validating at the boundary inverts this. The router parses the raw params through a per-route schema exactly once. Success produces a typed object with genuine number, Date and union types, and every component downstream is written against that type with no defensive checks. Failure is handled in one place, immediately, with the offending URL still in hand.

The type-level payoff is the part worth insisting on: after validation, TypeScript knows params.page is a number and params.tab is "overview" | "activity". A component that tries to compare params.tab against a typo stops compiling. Neither guarantee is available while the params are a bag of strings.

Validating at the boundary versus checking downstream Two flows. Without boundary validation, raw strings pass through the loader into components and a malformed value throws deep in the tree. With boundary validation, the schema converts params to typed values once and rejects malformed input before the loader runs. Unvalidated — the string travels /reports/page/abc params.page = "abc" loader Number("abc") = NaN slice(NaN) returns undefined list component throws stack points at the wrong file Validated at the boundary /reports/page/abc params.page = "abc" schema.parse integer, min 1 fails here, once valid → typed params: { page: number } components need no defensive checks invalid → not-found view + 404 the offending URL is still in scope
Validation at the router boundary turns a distant crash into a deliberate not-found decision, with the URL still available for logging.

Implementation

A parameter schema is a record of small parsers. Each returns a typed value or a sentinel meaning “invalid” — a discriminated union is clearer than exceptions here, because invalid input is expected rather than exceptional.

// TypeScript 5.x — no dependencies

type ParseResult<T> = { ok: true; value: T } | { ok: false; reason: string };

interface ParamParser<T> {
  (raw: string | undefined): ParseResult<T>;
}

export const asInt =
  (opts: { min?: number; max?: number } = {}): ParamParser<number> =>
  (raw) => {
    if (raw === undefined) return { ok: false, reason: "missing" };
    // Reject "1.5", "1e3", " 1" and "0x10" — only plain decimal integers pass.
    if (!/^-?\d+$/.test(raw)) return { ok: false, reason: "not an integer" };
    const n = Number(raw);
    if (opts.min !== undefined && n < opts.min) return { ok: false, reason: `below ${opts.min}` };
    if (opts.max !== undefined && n > opts.max) return { ok: false, reason: `above ${opts.max}` };
    return { ok: true, value: n };
  };

export const asEnum =
  <const T extends readonly string[]>(allowed: T): ParamParser<T[number]> =>
  (raw) =>
    raw !== undefined && (allowed as readonly string[]).includes(raw)
      ? { ok: true, value: raw as T[number] }
      : { ok: false, reason: `expected one of ${allowed.join(", ")}` };

export const asIsoDate = (): ParamParser<Date> => (raw) => {
  if (raw === undefined || !/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
    return { ok: false, reason: "expected YYYY-MM-DD" };
  }
  const d = new Date(`${raw}T00:00:00Z`);
  // Rejects 2026-02-30, which Date happily rolls over to March.
  return Number.isNaN(d.getTime()) || !d.toISOString().startsWith(raw)
    ? { ok: false, reason: "not a real date" }
    : { ok: true, value: d };
};

Combining parsers into a route schema is where the types are earned. Mapped types derive the validated shape from the schema, so the two can never drift apart:

// TypeScript 5.x — derive the params type from the schema itself

type Schema = Record<string, ParamParser<unknown>>;
type Validated<S extends Schema> = {
  [K in keyof S]: S[K] extends ParamParser<infer T> ? T : never;
};

export function validateParams<S extends Schema>(
  schema: S,
  raw: Record<string, string | undefined>,
): ParseResult<Validated<S>> {
  const out: Record<string, unknown> = {};

  for (const key of Object.keys(schema)) {
    const result = schema[key](raw[key]);
    if (!result.ok) return { ok: false, reason: `${key}: ${result.reason}` };
    out[key] = result.value;
  }

  return { ok: true, value: out as Validated<S> };
}

Wiring it into the route definition puts the check ahead of the loader, so an invalid URL never issues a request:

// TypeScript 5.x — validation runs before loading, not after

const reportRoute = {
  pattern: "/reports/:year/:tab",
  params: {
    year: asInt({ min: 2000, max: 2100 }),
    tab: asEnum(["overview", "activity", "exports"] as const),
  },
  // `params` is fully typed here: year is number, tab is the union.
  load: (params: Validated<(typeof reportRoute)["params"]>) =>
    fetchReport(params.year, params.tab),
};

export function resolve(rawParams: Record<string, string | undefined>) {
  const validated = validateParams(reportRoute.params, rawParams);
  if (!validated.ok) {
    logInvalidUrl(location.pathname, validated.reason); // the URL is still here
    return { view: "not-found" as const };
  }
  return { view: "report" as const, load: () => reportRoute.load(validated.value) };
}
Parser families and what each one rejects Four parser families shown with the inputs they accept and the inputs they reject: integer parsers reject decimals and scientific notation, enum parsers reject any value outside the union, date parsers reject impossible calendar dates, and identifier parsers reject values that fail a format check. A parser is defined by what it refuses asInt accepts 42 -7 0 rejects 1.5 rejects 1e3 rejects 0x10 rejects " 42" → number asEnum accepts overview rejects Overview rejects overvie rejects "" → string union asIsoDate accepts 2026-07-30 rejects 2026-02-30 rejects 30-07-2026 rejects 2026-7-3 → Date asId accepts a1b2c3d4 rejects ../admin rejects a b rejects 1000-char blob → branded string
Each parser's contract is its rejection list; writing that list down first makes the implementation nearly mechanical.

Verification

// TypeScript 5.x — hostile inputs belong in the test, not in production logs
import { asInt, asEnum, asIsoDate, validateParams } from "./params";

const schema = { year: asInt({ min: 2000, max: 2100 }), tab: asEnum(["overview", "activity"] as const) };

console.assert(validateParams(schema, { year: "2026", tab: "overview" }).ok);
console.assert(!validateParams(schema, { year: "1999", tab: "overview" }).ok); // below min
console.assert(!validateParams(schema, { year: "20 26", tab: "overview" }).ok); // whitespace
console.assert(!validateParams(schema, { year: "2026", tab: "Overview" }).ok);  // wrong case
console.assert(!validateParams(schema, { year: undefined, tab: "overview" }).ok); // missing
console.assert(!asIsoDate()("2026-02-30").ok); // impossible calendar date

End to end, the assertion worth making is that a malformed URL produces the not-found view without a network request:

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("a malformed parameter never reaches the API", async ({ page }) => {
  let apiCalls = 0;
  await page.route("**/api/**", (route) => { apiCalls++; route.continue(); });

  await page.goto("/reports/not-a-year/overview");
  await expect(page.getByRole("heading", { name: /not found/i })).toBeVisible();
  expect(apiCalls).toBe(0); // validation short-circuited before the loader
});
Where each layer must validate, and why the client's check is not enough Three tiers each validate: the router boundary rejects malformed shapes before fetching, the API validates because the client can be bypassed, and the database constraint is the last line. Skipping any one moves the failure further from its cause. The same parameter, checked three times on purpose router boundary rejects wrong SHAPES saves a request entirely gives the user a not-found view API rejects wrong shapes AGAIN the client can be bypassed never trust a validated-looking id data layer rejects wrong VALUES the id is well formed but absent this is a 404, not an error
Shape and existence are different questions: the router can answer the first, only the data layer can answer the second.

Gotchas

  • Number("") is 0, and Number(" 12 ") is 12. Both are why a regex test belongs in front of the conversion rather than after it.
  • parseInt("12abc") returns 12. It is the wrong tool for validation; it is designed to be permissive.
  • new Date("2026-02-30") does not throw — it rolls over to 2 March. Round-tripping through toISOString is the cheapest reliable check.
  • Decode before validating. A parameter arrives percent-encoded, so %2E%2E looks harmless until it is decoded into ...
  • Bound the length. An unbounded id parameter is an easy way for a crawler to make your logs unreadable; a maximum length costs one comparison.
  • Validate optional parameters explicitly. undefined is a valid input for an optional segment and an invalid one for a required segment; the parser must know which it is.

FAQ

Should I use a validation library instead? If your project already uses one, absolutely — the pattern is identical and only the parser bodies change. The value is in validating at the boundary, not in the parsing mechanism.

Where exactly is “the boundary”? Immediately after matching and before the loader runs. Any later and a request has already gone out for a URL you were about to reject.

What should an invalid parameter render? The not-found view, paired with a real 404 status if you can produce one. An error screen is misleading: nothing failed, the URL was simply not one your application serves.

Does this help with security? It helps with correctness, which removes a class of injection-adjacent bugs, but it is not a substitute for server-side validation. The server must re-validate everything regardless of what the client accepted.

How do I keep the schema and the pattern in sync? Derive the parameter names from the pattern with a template literal type, so a schema key that does not appear in the pattern is a compile error. It is a small amount of type machinery for a permanent guarantee.