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, and company.
  • A discriminated-union ApiResult<T> for success and failure.
  • Small decoders that accept unknown and prove the sample has the right shape.
  • A generic endpoint helper where TypeScript solves for T from 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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

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:

  • ApiUser has id, name, email, and company.
  • company is its own object type with name and catchPhrase.
  • The playground prints two loaded users on the first run.
  • Uncommenting brokenUser fails 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:

  • isCompany and isApiUser are type predicates.
  • isApiUserList checks every item in the array.
  • Changing id: 1 to id: "1" in one sample returns a decode_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.users produces ApiResult<readonly ApiUser[]>.
  • endpoints.firstUser produces ApiResult<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:

  • printUserList handles both success and failure.
  • printUserResult handles 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 build

The 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 Post response type with userId, id, title, and body.
  • Add postsByUser that filters decoded posts by userId.
  • Add a validation_error error kind for responses that are well-formed but unusable for your app.
  • Move the sample values into an ENDPOINT_SAMPLES object 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.

+50 XP on completion