cf.completefrontend
Open lab My progress
THE TYPESCRIPT FIELD GUIDE

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.

By the end, you can
  • 01
    Type requests from a route tableWrite a Routes type and a get whose path is checked and whose reply type is looked up with Routes[Path].
  • 02
    Read params from a pathUse a template literal type with infer to turn "/products/:id" into a required { id: string }.
  • 03
    Keep 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:

A route table and a get that reads it (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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.

Real-life analogyOrdering from a catalog

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
The main idea

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:

The two lookups inside getPop out in the code editor (opens in a new tab)TypeScript
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 Routes on line 8 is the union of the table's keys: "/products" | "/cart/count" (the keyof & typeof lesson covers it). So showPath accepts 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 shows type 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[].

A card that calls get with /products points at a window called Routes, which lists two rows: /products gives Product[], and /cart/count gives number. An arrow from the /products row leads to a card Product[].get("/products")Routes"/products": Product[]"/cart/count": numberProduct[]keyof Routes: the paths. Routes[Path]: the reply.
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:

Playground: ask the table for a path
Which paths does the table know?Pop out in the code editor (opens in a new tab)TypeScript
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);
Line 17 asks for/products
What happens
TypeScriptNo errors
reply's typeProduct[]
When it runs[ { id: 'p-7', name: 'Tea', price: 3 } ]
Try it yourself

"/products" is a key of Routes, so Path is "/products", and the reply's type is Routes["/products"]: Product[].

The code is checked by the real TypeScript compiler each time you type. Only line 17 changes. Reset puts /products back.

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:

ParamNames reads every :name in a pathPop out in the code editor (opens in a new tab)TypeScript
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:

  1. Line 2 asks: does Path look like `${string}:${infer Name}/${infer Rest}`? That pattern means "any text, a colon, a name, a slash, then the rest". ${string} matches any text, and infer Name captures the text between the colon and the next slash (the Template literal types and The infer keyword lessons cover both).
  2. If it matches, line 3 gives that Name, plus whatever ParamNames finds in the Rest. The type uses itself, so it keeps reading until the path runs out.
  3. Line 4 handles a param at the very end, with no slash after it: line 5 gives its name.
  4. 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.

A card with the path /users/:userId/orders/:orderId. Two arrows lead down to cards userId and orderId, the names after each colon. Two arrows lead from them to one card, the object type with userId: string and orderId: string."/users/:userId/orders/:orderId""userId""orderId"first :namefrom the rest{ userId: string; orderId: string }
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:

Params and buildPathPop out in the code editor (opens in a new tab)TypeScript
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:

Playground: pass params for /products/:id
Params checked against the pathPop out in the code editor (opens in a new tab)TypeScript
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" }));
Line 14 passes{ id: "p-7" }
What happens
TypeScriptNo errors
The request goes to/products/p-7
Try it yourself

The path has one param, id, so Params<"/products/:id"> is { id: string }. This object fits exactly.

TypeScript checks the code for real. Each button changes only the params object on line 14. Reset goes back to { id: "p-7" }.

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:

A query checked against its route (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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:

A request body checked against its route (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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.

Path params, queries, and bodies, side by side
QuestionPath paramsQueryBody
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:

One parser per routePop out in the code editor (opens in a new tab)TypeScript
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.

Two replies arrive as text: a list of names, and the text two for the cart count. Both go into a window called parsers. The names come out as a card string[], marked fits. The count comes out marked: throws Expected a number.what arrived["Tea", "Cake"]"two"parsers/products/nameslist of text?/cart/counta number?string[]fitsthrows: Expecteda number
Every reply goes through its route's parser before your code sees it. ["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:

A route with no parser (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
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.

Sharing the table with the server

So far, only the frontend reads the table. If the server is written in TypeScript too, both sides can import the same file, so they can't drift apart. Here is a project with three files:

api-types.tsPop out in the code editor (opens in a new tab)TypeScript
export type Product = { id: string; name: string; price: number }; export type Routes = {  "/products": Product[];  "/products/:id": Product;}; type ParamNames<Path> = Path extends `${string}:${infer Name}/${infer Rest}`  ? Name | ParamNames<Rest>  : Path extends `${string}:${infer Name}`    ? Name    : never; export type Params<Path> = { [Name in ParamNames<Path>]: string };

Lines 3 to 6 are the routes, one with a param. Lines 8 to 14 are ParamNames and Params from before, written without the extends string on Path; the template pattern still only matches strings. The file only holds types, so nothing from it is left when the code runs.

import type { Params, Product, Routes } from "./api-types"; const menu: Product[] = [  { id: "p-7", name: "Tea", price: 3 },  { id: "p-9", name: "Cake", price: 4 },]; export const handlers: { [Path in keyof Routes]: (params: Params<Path>) => Routes[Path] } = {  "/products": () => menu,  "/products/:id": (params) => {    const found = menu.find((item) => item.id === params.id);    if (!found) throw new Error(`No product ${params.id}`);    return found;  },};

Line 1 imports the types with import type, which is removed with the other types (the Modules & type imports lesson covers it). Line 8 is another mapped type: for each route, a handler, the server's function that answers it. Each handler gets that route's params and must return that route's reply. Line 11 can read params.id because the path is /products/:id.

import type { Params, Routes } from "./api-types";import { handlers } from "./server"; // Stands in for the network: a real client sends a request, and the server runs the handler.function get<Path extends keyof Routes>(path: Path, params: Params<Path>): Routes[Path] {  return handlers[path](params);} const tea = get("/products/:id", { id: "p-7" });//    ^?console.log(tea.name);console.log(get("/products", {}).length);

Lines 5 to 7 are the client's get. In a real app it would send a request over the network; here line 6 calls the server's handler directly, so the example runs without a server. The query on line 10 shows const tea: Product, and lines 11 and 12 print Tea and 2. A real client imports only types from api-types.ts, never the server's code.

A window api-types.ts holding Routes sits at the top. Arrows marked import type lead down to two windows: server.ts, whose handlers must fit Routes, and client.ts, whose get knows every reply.api-types.tsRoutesimport typeimport typeserver.tshandlers mustfit Routesclient.tsget knowsevery reply
Both sides import the same Routes. Change a reply in api-types.ts, and TypeScript marks the server code that no longer fits and the client code that reads it.

Now the server forgets a route:

server.ts, missing a handler (an error on purpose)Pop out in the code editor (opens in a new tab)TypeScript
import type { Params, Product, Routes } from "./api-types"; const menu: Product[] = [{ id: "p-7", name: "Tea", price: 3 }]; export const handlers: { [Path in keyof Routes]: (params: Params<Path>) => Routes[Path] } = {  "/products": () => menu,};

Line 5 is marked, on purpose: Property '"/products/:id"' is missing in type. The route is in the shared table, so the server must answer it. In the same way, if someone changes price to text in api-types.ts, both the server's menu, which gives numbers as prices, and any client code that uses the price as a number, like tea.price.toFixed(2), get errors.

Writing the table by hand works well for a small API. For bigger ones, tools make it for you. The table below compares the common ones. Read each row as one tool, then where its types come from, and what checks the data.

Ways to get route types, side by side
ToolWhere the types come fromWhat checks the data when it arrives
A route table you writeYou write Routes by hand, in a file both sides import.Parsers you write, one per route.
An OpenAPI generator, like openapi-typescriptA tool reads the API's OpenAPI description file and writes the types for you.Nothing by itself. Add parsers or a schema library.
tRPCThe client reads the types straight from the server's TypeScript code, with no file in between.The server checks each request's input, often with a Zod schema.
ZodYou describe each shape once as a schema, and z.infer turns it into a type.The schema itself, while the code runs.

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:

A product page with two typed requestsPop out in the code editor (opens in a new tab)TypeScript
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:

The table and get the cards usePop out in the code editor (opens in a new tab)TypeScript
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.

Error or fine?
  • 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);
Try it yourself
0 of 8 correct

Sort each call by what TypeScript says about it. Each one is checked on its own, after the lines above.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

The biggest mix-up is thinking the table checks the server. It doesn't. Here a reply breaks its promise:

The table trusts the replyPop out in the code editor (opens in a new tab)TypeScript
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-7 is 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 :name parts inside the path; the query comes after the ?.
  • "A param like :id can be a number." No. A path is text, so every param is a string. 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.

Exercise 1 · Warm-upPredict a reply's type

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.

Starter codePop out in the code editor (opens in a new tab)TypeScript
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");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upFind the typo

    Which line will TypeScript mark?

    Starter codePop out in the code editor (opens in a new tab)TypeScript
    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"));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeAdd a route to the table

      Make line 13 work by adding the route. What does your fixed code print?

      Starter codePop out in the code editor (opens in a new tab)TypeScript
      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"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict the param names

        What type is Names on line 8?

        Starter codePop out in the code editor (opens in a new tab)TypeScript
        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">;

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFill in every param

          TypeScript will mark line 14. Which param is missing? Then add it.

          Starter codePop out in the code editor (opens in a new tab)TypeScript
          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" }));

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWrite a parser for a new route

            TypeScript marks line 6. Add the missing parser. What does your fixed code print?

            Starter codePop out in the code editor (opens in a new tab)TypeScript
            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));

            Answer, then press Check. Spacing and letter case don’t matter.

              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.

              The type-safe APIs quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In get<Path extends keyof Routes>, what can Path be?

                Choose an answer to see the explanation.

              2. Question 2 of 7Which line does TypeScript flag in this order client?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                type 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.

              3. Question 3 of 7What does ParamNames give for this team path?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                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<"/teams/:teamId/members/:memberId">;

                Choose an answer to see the explanation.

              4. Question 4 of 7The cart's count came as text. What does this print?

                Read the code, then predictPop out in the code editor (opens in a new tab)TypeScript
                type Cart = { count: number };
                
                const cart = JSON.parse('{ "count": "2" }') as Cart;
                console.log(cart.count + 1);

                Choose an answer to see the explanation.

              5. Question 5 of 7A request for /products/:id has params { id: "p-7" } and query { sort: "price" }. Where does it go?

                Choose an answer to see the explanation.

              6. Question 6 of 7You add "/cart/total": number to Routes, but not to parsers. What happens?

                Choose an answer to see the explanation.

              7. 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 Routes checks the path, and Routes[Path] gives the reply's type.
              • A template literal type with infer reads :name params 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.

              CompleteFrontend Clear concepts. Working examples.