TypeDrop

2026-09-24 Challenge

2026-09-24 Hard

Typed Paginated API Client with Retry & Cancellation

You're building the data-fetching core for an analytics dashboard that pulls large datasets from a paginated REST API. The client must handle page-by-page iteration, per-request retry logic with exponential back-off, and cooperative cancellation — all with airtight generic typing so callers never lose the shape of the resource they're fetching.

Goals

  • Define branded types `ValidUrl` and `PageNumber` with runtime constructors that enforce their invariants.
  • Implement `withRetry` with exponential back-off that honours an `AbortSignal` between attempts and returns the correct `ApiError` discriminant on cancellation or exhaustion.
  • Implement `fetchPage` and `fetchAllPages` as generic functions that propagate `Result<T, ApiError>` without swallowing error variants or using type assertions.
  • Wire everything together in `createPaginatedClient` so the returned `PaginatedClient<T>` is fully typed and callers cannot conflate different resource shapes.
challenge.ts

// Core types at a glance

type ValidUrl  = string & { readonly __brand: "ValidUrl" };
type PageNumber = number & { readonly __brand: "PageNumber" };

type Ok<T>     = { readonly ok: true;  readonly value: T };
type Err<E>    = { readonly ok: false; readonly error: E };
type Result<T, E> = Ok<T> | Err<E>;

interface PageEnvelope<T> {
  readonly items:      readonly T[];
  readonly page:       PageNumber;
  readonly totalPages: number;
  readonly nextPage:   PageNumber | undefined;
}

type ApiError =
  | { kind: "network";   message: string }
  | { kind: "http";      status: number; message: string }
  | { kind: "parse";     message: string }
  | { kind: "cancelled"; message: string }
  | { kind: "exhausted"; attempts: number; lastError: ApiError };

// Main entry point
declare function createPaginatedClient<T>(
  config: ClientConfig<T>
): PaginatedClient<T>;
Hints (click to reveal)

Hints

  • For branded types, the trick is an intersection: `string & { readonly __brand: 'ValidUrl' }` — your runtime constructor validates then uses a type predicate or a single return-type annotation to produce the branded type.
  • In `withRetry`, implement the back-off delay as a small `sleep` helper that races a `setTimeout` promise against the AbortSignal's `'abort'` event so cancellation is truly cooperative.
  • `createPaginatedClient` should close over `config` and delegate each `fetchPage` call inside `fetchAllPages` through `withRetry` — keep the generics flowing by letting TypeScript infer `T` from `config`'s `parseItems` return type.

Or clone locally

git clone -b challenge/2026-09-24 https://github.com/niltonheck/typedrop.git