The Typed API Client
Intermediate · 90 min build · 🏆 milestone project
You are ready to build a small typed API client, a layer that turns raw REST-style data into checked application values. The live playground uses fixed sample data so it can run here honestly; the real network adapter appears below as static tool code because this runtime does not run fetch.
The goal is not to build a full SDK. The goal is to make the shape of each response explicit, keep errors in the type system, and use one generic helper so every endpoint follows the same path.
What you'll build
You will build one API-client workspace with:
- JSONPlaceholder-style user response shapes:
id,name,email, andcompany. - A discriminated-union
ApiResult<T>for success and failure. - Small decoders that accept
unknownand prove the sample has the right shape. - A generic endpoint helper where TypeScript solves for
Tfrom the decoder. - A
satisfies-checked endpoint table. - Static tool code showing where real
fetch,tsconfig.json, and module files would live.
A typed client is a boundary around an API. Callers should receive either trusted data of the promised shape or a typed error they must handle.
Checkpoint 1: model the response shapes
Start with the shape you want application code to trust. Company and ApiUser are the response contracts for this project.
Acceptance criteria:
ApiUserhasid,name,email, andcompany.companyis its own object type withnameandcatchPhrase.- The playground prints two loaded users on the first run.
- Uncommenting
brokenUserfails before the program runs.
Checkpoint 2: decode unknown data
The sample values are deliberately typed as unknown. That keeps the boundary honest: the decoder has to inspect fields before the rest of the program receives ApiUser.
Acceptance criteria:
isCompanyandisApiUserare type predicates.isApiUserListchecks every item in the array.- Changing
id: 1toid: "1"in one sample returns adecode_error. - No code outside the decoder reads fields from
unknown.
Checkpoint 3: make the helper generic
Read endpoint<T> as "for some type T, this endpoint has a path, a sample, and a decoder for T." TypeScript solves for T from the decoder you pass in, then requestFromSample preserves that T in ApiResult<T>.
Acceptance criteria:
endpoints.usersproducesApiResult<readonly ApiUser[]>.endpoints.firstUserproducesApiResult<ApiUser>.- The endpoint table uses
satisfies EndpointTable. - Adding a new endpoint requires a matching decoder.
Checkpoint 4: handle errors as data
ApiResult<T> is a discriminated union. Callers must check ok before touching data, and failures carry a typed error.
Acceptance criteria:
printUserListhandles both success and failure.printUserResulthandles both success and failure.findUserById(usersResult.data, 99)prints a not-found error when called inside the success branch.- No caller assumes a request succeeded without checking
ok.
Static tool code: real fetch adapter
The playground does not call the network. In a real project, the typed core above can sit behind a small adapter like this:
type ApiError = {
kind: "network_error" | "decode_error";
message: string;
};
type ApiResult<T> =
| { ok: true; data: T }
| { ok: false; error: ApiError };
type Decoder<T> = (value: unknown) => value is T;
const API_BASE_URL = "https://jsonplaceholder.typicode.com";
async function request<T>(
path: string,
decode: Decoder<T>,
): Promise<ApiResult<T>> {
try {
const response = await fetch(API_BASE_URL + path);
if (!response.ok) {
return {
ok: false,
error: {
kind: "network_error",
message: "Request failed with status " + response.status + ".",
},
};
}
const body: unknown = await response.json();
if (!decode(body)) {
return {
ok: false,
error: {
kind: "decode_error",
message: "Response body did not match the expected shape.",
},
};
}
return { ok: true, data: body };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown network error";
return { ok: false, error: { kind: "network_error", message } };
}
}That function is generic for the same reason the playground helper is generic: the request path does not know the response type by itself. The decoder supplies evidence, and TypeScript carries the solved T into the success result.
Static tool code: project config
For this project shape, a clean tsconfig.json looks like the Section 9 baseline:
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"noEmit": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}The real workflow stays split:
npx tsc --noEmit
npm run buildThe first command checks the typed client. The second lets your framework or bundler produce runnable output.
Stretch goals
Choose one after the base client works:
- Add a
Postresponse type withuserId,id,title, andbody. - Add
postsByUserthat filters decoded posts byuserId. - Add a
validation_errorerror kind for responses that are well-formed but unusable for your app. - Move the sample values into an
ENDPOINT_SAMPLESobject and derive endpoint paths from it. - Split the static adapter, decoders, and app calls into separate module files.
How to get unstuck
If unknown feels frustrating, revisit The Special Types: any, unknown, never, void. If the ok checks feel noisy, compare them to Discriminated Unions. If the generic helper feels abstract, reread Your First Generic Function and say the signature out loud: "for some type T." If the config or static adapter feels like tooling instead of TypeScript, use tsconfig, Demystified and The Compiler Pipeline to separate checking from emitting.
Part II is complete when this client feels readable: object types describe the API, generics remove repetition, discriminated unions force error handling, and Section 9's tools explain how the project is checked. Part III goes deeper into deriving and transforming types so large clients can stay maintainable.