A fetch wrapper whose result type is inferred from the endpoint
Callers currently write const u = (await api("/users/1")) as User; and the assertion is often wrong. Implement request so the result type follows from the endpoint alone.
Requirements: the route registry below is the single source of truth. request("GET /users") must resolve to User[], request("GET /users/:id", { id: "1" }) to User, and passing the wrong params for a route must not compile. No any and no assertion at any call site.
interface Routes {
"GET /users": { params: void; result: User[] };
"GET /users/:id": { params: { id: string }; result: User };
"POST /users": { params: { name: string }; result: User };
}
async function request<K extends keyof Routes>(
route: K,
params: Routes[K]["params"],
): Promise<Routes[K]["result"]> {
// your code here
}
Write the implementation.
One route registry. request is generic in K extends keyof Routes, taking params: Routes[K]['params'] and returning Promise<Routes[K]['result']> — indexed accesses on the endpoint key, so the literal alone drives inference, with no assertion.
- ✗Making the caller supply the result type explicitly instead of deriving it from the route
- ✗Reaching for overloads where one generic key parameter already varies both types
- ✗Believing a string-literal argument cannot drive a type parameter
- →How would you let a route with
params: voidbe called with one argument only? - →Where would you add runtime validation so
resultstops being an unchecked promise?
The solution
interface User { id: string; name: string }
interface Routes {
"GET /users": { params: void; result: User[] };
"GET /users/:id": { params: { id: string }; result: User };
"POST /users": { params: { name: string }; result: User };
}
async function request<K extends keyof Routes>(
route: K,
params: Routes[K]["params"],
): Promise<Routes[K]["result"]> {
const [method, template] = (route as string).split(" ") as [string, string];
// substitute :id into the path; whatever is left becomes the query or the body
const rest: Record<string, string> = { ...(params as object ?? {}) } as Record<string, string>;
const path = template.replace(/:(\w+)/g, (_, key: string) => {
const value = rest[key];
delete rest[key];
return encodeURIComponent(value);
});
const init: RequestInit = method === "GET"
? { method }
: { method, headers: { "content-type": "application/json" }, body: JSON.stringify(rest) };
const res = await fetch(path, init);
if (!res.ok) throw new Error(`${route}: ${res.status}`);
return (await res.json()) as Routes[K]["result"];
}
What inference is doing here
const list = await request("GET /users", undefined); // User[]
const one = await request("GET /users/:id", { id: "1" }); // User
await request("GET /users/:id", { name: "x" });
// ~~~~~~~~~~~~~ id is missing — type error
K is inferred from the string literal at the call site, not from an annotation. Both indexed accesses — Routes[K]["params"] and Routes[K]["result"] — then resolve against that same K, so the argument and the result cannot drift apart.
⚠️ The single assertion lives inside the wrapper, at the boundary with res.json(), which returns Promise<any> by definition. No caller ever writes as — the unsoundness is confined to the one line where runtime validation belongs.