Build Your Own Bedrock · Part 10
Type-level URL parsing
Part 9 left one thread hanging. The contract's route is a string — "/sample/:pathVar/page?queryVar=:queryVar" — and strings are usually where type safety goes to die. Rename :pathVar to :id in most frameworks and nothing complains until a request 404s or a param comes back undefined. Bedrock closes this hole by parsing the route string inside the type system. Sixty-two lines, no dependencies, and half of it is types with no runtime at all.
The machinery
// Core/Data/UrlToken.ts
export type Tokens<T extends string> = UrlTokens<T> | QueryTokens<T>
// Url only provides string-based values (even for number)
// hence, we treat it as unknown so that we can decode it into other types
export type UrlRecord<R extends string> = Record<Tokens<R>, unknown>
export type UrlTokens<T extends string> = T extends `${infer Url}?${infer _}`
? UrlTokens<Url>
: T extends
| `${infer _}/:${infer Token}/${infer Rest}`
| `:${infer Token}/${infer Rest}`
? Token | UrlTokens<Rest>
: T extends
| `${infer _}/:${infer Token}`
| `:${infer Token}/`
| `:${infer Token}`
? Token
: never A template-literal type in an extends clause is a pattern match on a string, and infer captures the pieces. UrlTokens is a recursive function that runs at compile time: strip the query, then repeatedly bite off one /:token/ segment, unioning each token name with the tokens of the rest.
Trace it by hand
Take the sample contract's route and run the type yourself:
// Tracing UrlTokens from Core/Data/UrlToken.ts
UrlTokens<"/sample/:pathVar/page?queryVar=:queryVar">
// 1. `${infer Url}?${infer _}` matches at the "?"
// Url = "/sample/:pathVar/page" — the query is stripped
= UrlTokens<"/sample/:pathVar/page">
// 2. `${infer _}/:${infer Token}/${infer Rest}` matches:
// _ = "/sample", Token = "pathVar", Rest = "page"
= "pathVar" | UrlTokens<"page">
// 3. "page" contains no ":" — all three patterns fail
= "pathVar" | never
= "pathVar" Step 2 is the subtle one: infer positions match lazily, so _ takes everything up to the first /: and Token stops at the next /. The second alternative (`:${infer Token}/${infer Rest}`) exists for routes that begin with a token, and the three-pattern fallback handles a token in final position. The query half has its own little machine — split on &, extract each name=:token pair, drop an optional [] array suffix:
// Core/Data/UrlToken.ts
export type QueryTokens<T> = T extends `${infer _}?${infer Query}`
? SplitQueryTokens<Query>
: never
type SplitQueryTokens<T extends string> =
T extends `${infer Token}&${infer Rest}`
? ExtractQueryToken<Token> | SplitQueryTokens<Rest>
: T extends `${infer Token}`
? ExtractQueryToken<Token>
: never
type ExtractQueryToken<T extends string> =
T extends `${infer Token}?=:${infer _}`
? RemoveBracket<Token>
: T extends `${infer Token}=:${infer _}`
? RemoveBracket<Token>
: never
type RemoveBracket<T> = T extends `${infer Token}[]` ? Token : T For our route: everything after ? is "queryVar=:queryVar"; there's no &, so it goes straight to ExtractQueryToken, whose `${infer Token}=:${infer _}` branch yields "queryVar". Put both halves together: Tokens<Route> = "pathVar" | "queryVar", so UrlRecord<Route> = { pathVar: unknown; queryVar: unknown }.
Tying the string to the params
Now the constraint from the Api type in Part 9 — UrlParams extends UrlRecord<Route> — has teeth. The sample contract's params must have exactly the keys the route string mentions:
// Core/Api/_Sample.ts
export type UrlParams = {
pathVar: "PATH1" | "PATH2"
queryVar: "QUERY1" | "QUERY2"
}
// Rename the token in the route string only:
// "/sample/:id/page?queryVar=:queryVar"
// ✗ Type 'UrlParams' does not satisfy the constraint
// 'UrlRecord<"/sample/:id/page?queryVar=:queryVar">':
// property 'id' is missing in type 'UrlParams' That's the payoff: the route string and the params record can never drift, and the same guarantee protects the server when it mounts the route and the client when it fills the tokens in. A rename is a compile error on both sides — not a Friday-night incident.
The honest cost
Notice that UrlRecord maps every token to unknown, not string. That's deliberate honesty twice over. The type level knows which tokens exist, but it cannot know what they contain — at runtime a URL delivers only strings, typed by whoever edited the address bar. So token names are checked by the compiler, and token values still cross a wall from Part 7: every contract pairs its route with a urlDecoder, built from string-first decoders like this one:
// Core/Data/Number/Nat.ts
/** Commonly used for URL param parsing */
export const natStringDecoder: JD.Decoder<Nat> = JD.string.transform((n) => {
return throwIfNull(createNat(Number(n)), `Invalid nat string: ${n}`)
}) Type-level parsing and runtime decoding aren't competing tools; they split the job. The compiler proves the shape of the route. The decoder proves the content of each value — and hands you a Nat, not a string you promise is a number.
In your language
This part is the least portable in the series, and it's worth being honest about that: few type systems can pattern-match string literals. Haskell gets there with type-level symbols — Servant routes are exactly this idea, taken further. Idris and other dependently-typed languages do it natively. Rust can't parse strings in types, but a proc macro can generate the route string and the params struct from one definition, which buys the same no-drift guarantee. Everywhere else, recover the property by construction rather than checking: make one value the single source of truth for both the path and its params (as Elm URL parsers do), or generate both sides from a schema. What matters is that a token rename cannot happen in one place only.