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.
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) };
}
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
});
Gotchas
Number("")is0, andNumber(" 12 ")is12. Both are why a regex test belongs in front of the conversion rather than after it.parseInt("12abc")returns12. 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 throughtoISOStringis the cheapest reliable check.- Decode before validating. A parameter arrives percent-encoded, so
%2E%2Elooks 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.
undefinedis 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.
Related
- Dynamic Route Segments — how segments are declared, extracted, and handed to data fetching.
- Catch-All and Splat Routes Explained — the segment type whose value is a whole path and needs its own validation rules.
- Returning Real 404 Status Codes from a SPA — giving a rejected parameter the status code it deserves.