TypeDrop

2026-07-30 Challenge

2026-07-30 Medium

Typed Pagination Aggregator with Cursor-Based Fetching

You're building the data-loading layer for an analytics dashboard that must pull all records from cursor-paginated REST APIs (think GitHub, Stripe, or Notion). Each endpoint returns a typed page of items plus an opaque next-cursor, and you need a generic aggregator that fetches all pages sequentially, enforces a per-fetch timeout via AbortController, and returns a strongly-typed settled result — collected items or a structured error — without any unsafe escape hatches.

Goals

  • Define `Page<T>`, `PaginatorConfig<T>`, `FetchError`, and `AggregatorResult<T>` as fully typed, generic discriminated unions with no `any`.
  • Implement `aggregatePages<T>` to fetch pages sequentially, race each fetch against a per-page `AbortController` timeout, and return a typed `AggregatorResult<T>` — never throwing.
  • Handle all four `FetchError` kinds exhaustively: `timeout`, `aborted`, `network`, and `max_pages`.
  • Implement `paginatorFor<T>` as a typed builder that wraps a URL and `transform` function into a valid `PaginatorConfig<T>` with sensible defaults.
challenge.ts
// Core types and main function signature

type Widget = { id: number; name: string };

type Page<T> = {
  items: T[];
  nextCursor: string | null;
};

type FetchError =
  | { kind: "timeout";   pageIndex: number }
  | { kind: "aborted";   pageIndex: number }
  | { kind: "network";   pageIndex: number; message: string }
  | { kind: "max_pages"; limit: number };

type AggregatorResult<T> =
  | { status: "ok";    items: T[];       pagesFetched: number }
  | { status: "error"; reason: FetchError; pagesFetched: number; partialItems: T[] };

type PaginatorConfig<T> = {
  fetchPage: (cursor: string | null, signal: AbortSignal) => Promise<Page<T>>;
  pageTimeoutMs: number;
  maxPages: number;
};

async function aggregatePages<T>(
  config: PaginatorConfig<T>
): Promise<AggregatorResult<T>> { /* TODO */ }
Hints (click to reveal)

Hints

  • To race a fetch against a timeout, create an `AbortController`, schedule `controller.abort()` with `setTimeout`, then `Promise.race` the real fetch (passing `controller.signal`) against a promise that rejects on abort — check `signal.aborted` or catch `DOMException` with name `'AbortError'` to distinguish timeout from external abort.
  • Use `instanceof DOMException && err.name === 'AbortError'` inside your catch block to tell timeouts/aborts apart from generic network errors — then check the outer timeout controller's signal to know which it was.
  • The `transform` callback in `paginatorFor` is the boundary where `unknown` becomes `Page<T>` — keep it typed as `(raw: unknown) => Page<T>` and let callers own the narrowing, so your builder stays free of `any`.

Or clone locally

git clone -b challenge/2026-07-30 https://github.com/niltonheck/typedrop.git