Type-safe APIs & routes
Type a frontend's API from one route table: replies looked up by path, params read from /products/:id, typed queries and bodies, and checks at the edge.
- 01Type requests from a route tableWrite a
Routestype and agetwhose path is checked and whose reply type is looked up withRoutes[Path]. - 02Read params from a pathUse a template literal type with
inferto turn"/products/:id"into a required{ id: string }. - 03Keep both sides honestCheck replies at the edge with one parser per route, and share the table with the server so both sides agree.
One table that types every request
A shop's frontend talks to its server through many addresses, called routes: /products gives the product list, /cart/count gives how many items are in the cart. Each route sends back a different kind of data. This lesson writes all of that down once, in one type, and lets TypeScript check every request against it:
type Product = { id: string; name: string; price: number }; type Routes = { "/products": Product[]; "/cart/count": number;}; const server: Routes = { "/products": [{ id: "p-7", name: "Tea", price: 3 }], "/cart/count": 2,}; function get<Path extends keyof Routes>(path: Path): Routes[Path] { return server[path];} const products = get("/products");// ^?console.log(products[0].name);console.log(get("/cart/count"));console.log(get("/prodcts"));Lines 3 to 6 are the route table: an object type whose keys are paths and whose values are what each path sends back. Lines 8 to 11 stand in for the server. A real app would ask over the network and wait for a promise; this one answers at once from an object, so you can focus on the types (the practical section adds the waiting).
Lines 13 to 15 are get. It takes a path that must be one of the table's keys, and returns that path's reply. Line 17 asks for /products, and the query on line 18 shows const products: Product[]: TypeScript knows the reply is a list of products, so line 19 can read products[0].name and print Tea. Line 20 prints 2.
Line 21 has a typo, and TypeScript marks it, on purpose: Argument of type '"/prodcts"' is not assignable to parameter of type 'keyof Routes'. In plain words: "that path isn't in the table". Run it anyway, and it prints undefined, the kind of bug you would otherwise find on a broken page.
A mail-order catalog lists every item number with a picture of what you'll get. You order by number, and you know what's in the box before it arrives. If you write a number that isn't in the catalog, the shop refuses the order instead of sending a random box.
- In real life: The catalog: each item number and what you get
- In TypeScript:
Routes: each path and its reply - In real life: You order by item number
- In TypeScript:
get("/products") - In real life: You know what's in the box before it arrives
- In TypeScript: The reply's type,
Product[], is known before the request - In real life: A number that isn't in the catalog is refused
- In TypeScript:
get("/prodcts")is an error
Describe the API once, as a type that maps each route to its data, and let the client's types come from it. Then a wrong path, a missing param, or a misread reply is an error in your editor, not a bug on the page.
keyof and indexed access do the work
Nothing in the route table is new. get is built from two type operators you already know. Here they are on their own:
type Product = { id: string; name: string; price: number }; type Routes = { "/products": Product[]; "/cart/count": number;}; function showPath(path: keyof Routes) { return path;} type CountReply = Routes["/cart/count"];// ^?console.log(showPath("/cart/count"));keyof Routeson line 8 is the union of the table's keys:"/products" | "/cart/count"(the keyof & typeof lesson covers it). SoshowPathaccepts exactly those two paths, and line 14 prints/cart/count.Routes["/cart/count"]on line 12 looks up one value's type, and the query on line 13 showstype CountReply = number(the Indexed access types lesson covers it).
get joins them with a type parameter, Path (the Generic functions lesson covers these). Path extends keyof Routes means "Path must be one of the paths". When you call get("/products"), TypeScript sets Path to "/products", and the return type Routes[Path] becomes Routes["/products"]: Product[].
get("/products") looks its path up in the table. Path becomes "/products", and the reply's type is Routes["/products"]: Product[].Now type your own path. Only line 17 changes, and the query on line 18 shows the reply's type:
type Product = { id: string; name: string; price: number }; type Routes = { "/products": Product[]; "/cart/count": number;}; const server: Routes = { "/products": [{ id: "p-7", name: "Tea", price: 3 }], "/cart/count": 2,}; function get<Path extends keyof Routes>(path: Path): Routes[Path] { return server[path];} const reply = get("/products");// ^?console.log(reply);/productsNo errorsProduct[][ { id: 'p-7', name: 'Tea', price: 3 } ]"/products" is a key of Routes, so Path is "/products", and the reply's type is Routes["/products"]: Product[].
Each key gives its own reply type, and anything else is refused. Notice /products/p-7: a perfectly good address for one product, but the table has no such key. You can't write a key for every product ID. The next section fixes that.
Reading params from the path
Servers write routes for one item with a placeholder, like /products/:id. The :id part is a path param: when you make the request, you fill it in, and /products/:id with id set to "p-7" becomes /products/p-7. TypeScript can read the param names out of the path itself, with a conditional type, infer, and a template literal type:
type ParamNames<Path extends string> = Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest> : Path extends `${string}:${infer Name}` ? Name : never; type One = ParamNames<"/products/:id">;// ^?type Two = ParamNames<"/users/:userId/orders/:orderId">;// ^?type None = ParamNames<"/products">;// ^?Read lines 1 to 6 one branch at a time:
- Line 2 asks: does
Pathlook like`${string}:${infer Name}/${infer Rest}`? That pattern means "any text, a colon, a name, a slash, then the rest".${string}matches any text, andinfer Namecaptures the text between the colon and the next slash (the Template literal types and The infer keyword lessons cover both). - If it matches, line 3 gives that
Name, plus whateverParamNamesfinds in theRest. The type uses itself, so it keeps reading until the path runs out. - Line 4 handles a param at the very end, with no slash after it: line 5 gives its name.
- Line 6: no colon anywhere, so no names:
never, the empty union.
The queries show the answers: "id" on line 9, "userId" | "orderId" on line 11, and never on line 13 for a path with no params.
ParamNames finds the text after each :, up to the next /: userId first, then orderId from the rest. Params makes each name a string property, like OrderParams on line 15.Names alone aren't enough; a request needs an object with a value for each one. A mapped type builds it (the Mapped types lesson covers them), and a small function fills the path in:
type ParamNames<Path extends string> = Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest> : Path extends `${string}:${infer Name}` ? Name : never; type Params<Path extends string> = { [Name in ParamNames<Path>]: string }; function buildPath<Path extends string>(path: Path, params: Params<Path>) { return path.replace(/:(\w+)/g, (_, name: string) => params[name as ParamNames<Path>]);} type OrderParams = Params<"/users/:userId/orders/:orderId">;// ^?console.log(buildPath("/products/:id", { id: "p-7" }));console.log(buildPath("/users/:userId/orders/:orderId", { userId: "u-1", orderId: "o-42" }));Line 8 says: for each name in ParamNames<Path>, make a property of that name whose value is a string. Every param is text, because a path is text. The query on line 15 shows the result for the order path: { userId: string; orderId: string; }. For /products, with no names, it's {}.
Lines 10 to 12 are buildPath. Line 11 finds each :name with a regular expression and swaps in that param's value (replace with a function is covered in String methods). The regular expression only finds the names, so as ParamNames<Path> tells TypeScript that name is one of them. Lines 16 and 17 print /products/p-7 and /users/u-1/orders/o-42.
Now try other params. Each button changes the object that line 14 passes:
type ParamNames<Path extends string> = Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest> : Path extends `${string}:${infer Name}` ? Name : never; type Params<Path extends string> = { [Name in ParamNames<Path>]: string }; function buildPath<Path extends string>(path: Path, params: Params<Path>) { return path.replace(/:(\w+)/g, (_, name: string) => params[name as ParamNames<Path>]);} console.log(buildPath("/products/:id", { id: "p-7" }));{ id: "p-7" }No errors/products/p-7The path has one param, id, so Params<"/products/:id"> is { id: string }. This object fits exactly.
Only the exact object fits. A missing name, a misspelled name, a number, or an extra name are all errors, and each one would have sent the request to the wrong address.
Typed queries and request bodies
A route can take more than params. A query is the part after a ? in an address, like ?sort=price&page=2, for options such as sorting. Give each route's entry a query as well as its response, and look both up the same way:
type GetRoutes = { "/products": { query: { sort?: "price" | "name"; page?: number }; response: string[] }; "/search": { query: { q: string }; response: string[] };}; function urlFor<Path extends keyof GetRoutes>(path: Path, query: GetRoutes[Path]["query"]) { const search = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { if (value !== undefined) search.set(key, String(value)); } const text = search.toString(); return text ? `${path}?${text}` : path;} console.log(urlFor("/products", { sort: "price", page: 2 }));console.log(urlFor("/products", {}));console.log(urlFor("/search", { q: "green tea" }));console.log(urlFor("/products", { sort: "rating" }));Lines 1 to 4 describe two routes. /products takes an optional sort, which must be "price" or "name", and an optional page. /search needs a q, the search text. Line 6 types the query parameter as GetRoutes[Path]["query"]: two lookups in a row, first the route, then its query.
Lines 7 to 12 build the address with URLSearchParams, the browser's tool for query text (the URL & URLSearchParams lesson covers it). Lines 15 to 17 print /products?sort=price&page=2, /products, and /search?q=green+tea. Line 18 asks to sort by rating, and TypeScript marks it, on purpose: Type '"rating"' is not assignable to type '"price" | "name" | undefined'. The server doesn't sort by rating, and now the frontend can't ask it to.
Routes that create or change something, like adding to a cart, send a body: data in the request itself, as JSON text. The same table idea gives each one a body and a response:
type PostRoutes = { "/cart/items": { body: { productId: string; quantity: number }; response: { count: number } }; "/reviews": { body: { productId: string; stars: 1 | 2 | 3 | 4 | 5; text: string }; response: { id: string } };}; function postRequest<Path extends keyof PostRoutes>(path: Path, body: PostRoutes[Path]["body"]) { return { method: "POST", url: path, body: JSON.stringify(body) };} type AddReply = PostRoutes["/cart/items"]["response"];// ^?console.log(postRequest("/cart/items", { productId: "p-7", quantity: 2 }).body);console.log(postRequest("/reviews", { productId: "p-7", stars: 6, text: "Great" }).body);Lines 1 to 4 pair each route's request with its response: what you send, and what you get back. Line 6's postRequest takes the body that its route asks for, and line 7 builds the request a client would send. The query on line 11 shows what adding to the cart replies with: { count: number; }. Line 12 prints the body as JSON text. Line 13 gives a review 6 stars, and TypeScript marks it, on purpose: Type '6' is not assignable to type '1 | 2 | 3 | 4 | 5'.
Params, queries, and bodies are easy to confuse. The table below compares them. Read each row as a question, then each one's answer.
| Question | Path params | Query | Body |
|---|---|---|---|
| Where does it go in the request? | Inside the path: /products/:id becomes /products/p-7. | After a ? at the end: /products?sort=price. | In the request itself, as JSON text, with POST. |
| Is it required? | Yes. Every :name must be filled, or the address is wrong. | Often not. Sorting and paging have defaults, so they can be optional. | Yes, for routes that create or change something. |
| What type does each value have? | Always string, because a path is text. | Whatever the table says; it becomes text in the address. | Whatever the table says: numbers, lists, and objects survive JSON. |
| Where does its type come from? | From the path itself, with ParamNames and Params. | From the route's query entry. | From the route's body entry. |
Checking responses at the edge
The route table says what the server should send. It can't make the server send it. A reply arrives as text, and JSON.parse gives you unknown data (the Typing fetch & JSON lesson explains why, and how to check it). The table can make sure you check every route, though:
type Routes = { "/cart/count": number; "/products/names": string[];}; const parsers: { [Path in keyof Routes]: (data: unknown) => Routes[Path] } = { "/cart/count": (data) => { if (typeof data !== "number") throw new Error("Expected a number"); return data; }, "/products/names": (data) => { if (!Array.isArray(data) || !data.every((item): item is string => typeof item === "string")) { throw new Error("Expected a list of names"); } return data; },}; // What the server sent, as text. The count is wrong on purpose.const replies: Record<keyof Routes, string> = { "/cart/count": '"two"', "/products/names": '["Tea", "Cake"]',}; function get<Path extends keyof Routes>(path: Path): Routes[Path] { const data: unknown = JSON.parse(replies[path]); return parsers[path](data);} const names = get("/products/names");// ^?console.log(names.join(", "));try { console.log(get("/cart/count") + 1);} catch (error) { console.log(error instanceof Error ? error.message : error);}Line 6 is a mapped type over the table: for each path, a function that takes unknown data and gives back that path's reply type. A function like this is called a parser: it checks the data and returns it typed, or throws. Lines 7 to 10 check the count is a number. Lines 11 to 16 check the names are a list of text; the item is string in line 12 is a type guard, so every narrows the list to string[].
Lines 20 to 23 are what the server sent, with a mistake: the count is the text "two". Lines 25 to 28 are get: it parses the text, then runs the route's parser on line 27. The query on line 31 shows const names: string[], and line 32 prints Tea, Cake. Line 34 asks for the count, the parser throws, and line 36 prints Expected a number, instead of a wrong total somewhere on the page.
["Tea", "Cake"] comes out as a string[]; the count "two" throws Expected a number, like line 34.The mapped type also stops you from forgetting a parser. Here a new route is added to the table, and nothing else:
type Routes = { "/cart/count": number; "/cart/total": number;}; const parsers: { [Path in keyof Routes]: (data: unknown) => Routes[Path] } = { "/cart/count": (data) => { if (typeof data !== "number") throw new Error("Expected a number"); return data; },};Line 6 is marked, on purpose: Property '"/cart/total"' is missing in type, and the message goes on to list both types in full. In plain words: "the table has a route that parsers doesn't cover". Schema libraries like Zod do the same job: one schema per route checks the data and gives you its type.
A product page that loads through the client
Here is everything together, as a real client does it: the requests wait for their replies, params fill the path, and two requests run at the same time. fakeFetch stands in for fetch, so nothing touches the network:
type Product = { id: string; name: string; price: number };type Review = { stars: number; text: string }; type Routes = { "/products/:id": Product; "/products/:id/reviews": Review[];}; type ParamNames<Path> = Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest> : Path extends `${string}:${infer Name}` ? Name : never;type Params<Path> = { [Name in ParamNames<Path>]: string }; // Stands in for fetch: answers from this object instead of a server.async function fakeFetch(url: string): Promise<string> { const replies: Record<string, unknown> = { "/products/p-7": { id: "p-7", name: "Tea", price: 3 }, "/products/p-7/reviews": [{ stars: 5, text: "Lovely" }, { stars: 4, text: "Good" }], }; return JSON.stringify(replies[url]);} async function get<Path extends keyof Routes>(path: Path, params: Params<Path>): Promise<Routes[Path]> { const url = path.replace(/:(\w+)/g, (_, name: string) => params[name as ParamNames<Path>]); const data: unknown = JSON.parse(await fakeFetch(url)); return data as Routes[Path];} async function showProduct(id: string) { const [product, reviews] = await Promise.all([ get("/products/:id", { id }), get("/products/:id/reviews", { id }), ]); console.log(`${product.name}: $${product.price}, ${reviews.length} reviews`);} showProduct("p-7");Lines 4 to 7 are the table: a product, and its reviews. Lines 9 to 14 are ParamNames and Params. Lines 17 to 23 are fakeFetch, which answers with JSON text from an object, after a promise, like fetch.
Lines 25 to 29 are get, now async: it returns a Promise<Routes[Path]> (the Promises & async functions lesson covers this). Line 26 fills the params in, line 27 parses the reply, and line 28 says "trust that it fits" with as. In a real app, line 28 is where the route's parser from "Checking responses at the edge" goes.
Lines 32 to 35 ask for the product and its reviews at once with Promise.all (the Promise combinators lesson covers it). TypeScript knows product is a Product and reviews is a Review[], each from its own path. Line 36 prints Tea: $3, 2 reviews. Point at product or reviews to see their types.
Now sort some calls. Each one is checked after these lines:
type Product = { id: string; name: string; price: number }; type Routes = { "/products": Product[]; "/products/:id": Product; "/cart/count": number;}; type ParamNames<Path> = Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest> : Path extends `${string}:${infer Name}` ? Name : never;type Params<Path> = { [Name in ParamNames<Path>]: string }; declare function get<Path extends keyof Routes>(path: Path, params: Params<Path>): Routes[Path];Line 16 uses declare function: it describes get's parameters and return type without a body, which is all the checker needs.
get("/products", {});get("/products/:id", { id: "p-7" });get("/products/:id", {});get("/product/:id", { id: "p-7" });get("/products/p-7", {});const n: number = get("/cart/count", {});const name: string = get("/products", {}).name;get("/products/:id", { id: "p-7" }).price.toFixed(2);
Sort each call by what TypeScript says about it. Each one is checked on its own, after the lines above.
Common mix-ups
The biggest mix-up is thinking the table checks the server. It doesn't. Here a reply breaks its promise:
type Product = { id: string; name: string; price: number }; const reply = '{ "id": "p-7", "name": "Tea", "price": "3" }';const product = JSON.parse(reply) as Product;console.log(product.price + 1);Line 3 is the server's reply, with the price as the text "3". Line 4 says it's a Product with as, and TypeScript believes it. Line 5 prints 31: the text "3" joined with 1, not 4. Only a parser catches this.
- "The route table checks what the server sends." No. It describes what should arrive. Parsers or a schema library check what does arrive.
- "
/products/p-7is a key of the table." No. The keys are patterns like/products/:id. You pass the pattern and fill the ID in as a param. - "Path params and the query are the same thing." No. Params fill the
:nameparts inside the path; the query comes after the?. - "A param like
:idcan be a number." No. A path is text, so every param is astring. Turn numbers into text first. - "Sharing types means the client ships the server's code." No. The client uses
import type, which is removed before the code runs. Only types cross over. - "You need a library for type-safe APIs." No.
keyof, indexed access, mapped types, and template literal types are enough. Libraries like tRPC and OpenAPI generators make the types for you on bigger APIs.
Practice exercises
Each exercise is TypeScript. When you fix code, press Edit & run on the starter code: the errors update as you type, and the console shows what it prints. Try a guess first, then use the hints.
What type does TypeScript give count on line 17? Guess first. Then point at count, or press Edit & run and add a // ^? line under it.
type Product = { id: string; name: string; price: number }; type Routes = { "/products": Product[]; "/cart/count": number;}; const server: Routes = { "/products": [{ id: "p-7", name: "Tea", price: 3 }], "/cart/count": 2,}; function get<Path extends keyof Routes>(path: Path): Routes[Path] { return server[path];} const count = get("/cart/count");type Product = { id: string; name: string; price: number }; type Routes = { "/products": Product[]; "/cart/count": number;}; const server: Routes = { "/products": [{ id: "p-7", name: "Tea", price: 3 }], "/cart/count": 2,}; function get<Path extends keyof Routes>(path: Path): Routes[Path] { return server[path];} const count = get("/cart/count");// ^?Path is "/cart/count", so the return type is Routes["/cart/count"], which is number. The query on line 18 shows const count: number.
Which line will TypeScript mark?
type Product = { id: string; name: string; price: number };
type Routes = {
"/products": Product[];
"/cart/count": number;
};
const server: Routes = {
"/products": [{ id: "p-7", name: "Tea", price: 3 }],
"/cart/count": 2,
};
function get<Path extends keyof Routes>(path: Path): Routes[Path] {
return server[path];
}
console.log(get("/products").length);
console.log(get("/cart/cuont"));type Product = { id: string; name: string; price: number }; type Routes = { "/products": Product[]; "/cart/count": number;}; const server: Routes = { "/products": [{ id: "p-7", name: "Tea", price: 3 }], "/cart/count": 2,}; function get<Path extends keyof Routes>(path: Path): Routes[Path] { return server[path];} console.log(get("/products").length);console.log(get("/cart/count"));Line 18 asks for /cart/cuont, which isn't a key of Routes. With the typo fixed, it prints 1 and 2.
Make line 13 work by adding the route. What does your fixed code print?
type Routes = { "/cart/count": number;}; const server: Routes = { "/cart/count": 2,}; function get<Path extends keyof Routes>(path: Path): Routes[Path] { return server[path];} console.log(get("/cart/total"));type Routes = { "/cart/count": number; "/cart/total": number;}; const server: Routes = { "/cart/count": 2, "/cart/total": 7.5,}; function get<Path extends keyof Routes>(path: Path): Routes[Path] { return server[path];} console.log(get("/cart/total"));With the route in the table, get("/cart/total") is allowed. server is typed Routes, so TypeScript also asks for its answer, and it prints 7.5.
What type is Names on line 8?
type ParamNames<Path extends string> = Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest> : Path extends `${string}:${infer Name}` ? Name : never; type Names = ParamNames<"/shops/:shopId/items/:itemId">;type ParamNames<Path extends string> = Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest> : Path extends `${string}:${infer Name}` ? Name : never; type Names = ParamNames<"/shops/:shopId/items/:itemId">;// ^?The first branch takes shopId, then reads items/:itemId again and finds itemId at the end. The query shows type Names = "shopId" | "itemId".
TypeScript will mark line 14. Which param is missing? Then add it.
type ParamNames<Path extends string> =
Path extends `${string}:${infer Name}/${infer Rest}`
? Name | ParamNames<Rest>
: Path extends `${string}:${infer Name}`
? Name
: never;
type Params<Path extends string> = { [Name in ParamNames<Path>]: string };
function buildPath<Path extends string>(path: Path, params: Params<Path>) {
return path.replace(/:(\w+)/g, (_, name: string) => params[name as ParamNames<Path>]);
}
console.log(buildPath("/users/:userId/orders/:orderId", { userId: "u-1" }));type ParamNames<Path extends string> = Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest> : Path extends `${string}:${infer Name}` ? Name : never; type Params<Path extends string> = { [Name in ParamNames<Path>]: string }; function buildPath<Path extends string>(path: Path, params: Params<Path>) { return path.replace(/:(\w+)/g, (_, name: string) => params[name as ParamNames<Path>]);} console.log(buildPath("/users/:userId/orders/:orderId", { userId: "u-1", orderId: "o-42" }));The path has two params, userId and orderId, so Params asks for both. The object leaves out orderId, which would send the request to /users/u-1/orders/undefined. With it, the code prints /users/u-1/orders/o-42.
TypeScript marks line 6. Add the missing parser. What does your fixed code print?
type Routes = { "/cart/count": number; "/cart/total": number;}; const parsers: { [Path in keyof Routes]: (data: unknown) => Routes[Path] } = { "/cart/count": (data) => { if (typeof data !== "number") throw new Error("Expected a number"); return data; },}; console.log(parsers["/cart/total"](JSON.parse("7.5")).toFixed(2));type Routes = { "/cart/count": number; "/cart/total": number;}; const parsers: { [Path in keyof Routes]: (data: unknown) => Routes[Path] } = { "/cart/count": (data) => { if (typeof data !== "number") throw new Error("Expected a number"); return data; }, "/cart/total": (data) => { if (typeof data !== "number") throw new Error("Expected a number"); return data; },}; console.log(parsers["/cart/total"](JSON.parse("7.5")).toFixed(2));The mapped type asks for a parser for every route. A /cart/total parser that checks for a number fixes line 6, and line 17 prints 7.50.
Check your understanding
For each question, ask two things: what does the table say, and what really arrives when the code runs? The quiz shows its code without the checker, so the error list doesn't give the answers away.
Question 1 of 7In
get<Path extends keyof Routes>, what canPathbe?Choose an answer to see the explanation.
Question 2 of 7Which line does TypeScript flag in this order client?
Read the code, then predicttype Routes = { "/orders": string[]; "/orders/latest": string; }; const server: Routes = { "/orders": ["o-41", "o-42"], "/orders/latest": "o-42", }; function get<Path extends keyof Routes>(path: Path): Routes[Path] { return server[path]; } const latest = get("/orders/latest"); const all = get("/order");Choose an answer to see the explanation.
Question 3 of 7What does
ParamNamesgive for this team path?Read the code, then predicttype ParamNames<Path extends string> = Path extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<Rest> : Path extends `${string}:${infer Name}` ? Name : never; type Names = ParamNames<"/teams/:teamId/members/:memberId">;Choose an answer to see the explanation.
Question 4 of 7The cart's count came as text. What does this print?
Read the code, then predicttype Cart = { count: number }; const cart = JSON.parse('{ "count": "2" }') as Cart; console.log(cart.count + 1);Choose an answer to see the explanation.
Question 5 of 7A request for
/products/:idhas params{ id: "p-7" }and query{ sort: "price" }. Where does it go?Choose an answer to see the explanation.
Question 6 of 7You add
"/cart/total": numbertoRoutes, but not toparsers. What happens?Choose an answer to see the explanation.
Question 7 of 7What do tools like tRPC and OpenAPI generators do for this pattern?
Choose an answer to see the explanation.
Key takeaways
- A route table is an object type from paths to replies.
Path extends keyof Routeschecks the path, andRoutes[Path]gives the reply's type. - A template literal type with
inferreads:nameparams out of a path, and a mapped type turns them into a required object of strings. - Each route's entry can pair its request, a query or a body, with its response.
- The table describes replies; it doesn't check them. A mapped type of parsers makes sure every route has a check.
- A shared types file keeps the server's handlers and the client's calls in agreement. tRPC, OpenAPI generators, and Zod can make the types for you.
Remember the one-liner.
Write the API down once as a type, and let every request, param, and reply be checked against it.
Coming next: Variance & assignability. Why a list of dogs isn't always a list of animals, and how TypeScript compares function parameters.