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.
Useful resources
Or clone locally
git clone -b challenge/2026-09-24 https://github.com/niltonheck/typedrop.git