TypeDrop

2026-09-11 Challenge

2026-09-11 Medium

Typed Paginated API Client with Retry & Result Aggregation

You're building the data-fetching layer for an analytics dashboard that pulls records from a paginated REST API. Pages must be fetched sequentially, transient errors should be retried with exponential back-off, and the final result must be a typed aggregate — all without a single `any` or unsafe cast.

Goals

  • Implement the `Result<T, E>` discriminated union and its `ok` / `err` convenience constructors.
  • Implement `withRetry` so it transparently wraps any async `Result`-returning function with exponential back-off and a configurable retry limit.
  • Implement `fetchAllPages` to drive a cursor-based pagination loop, wiring in `withRetry` on every page fetch and collecting all items into a flat array.
  • Implement `aggregateRecords` using the constrained generic `T extends Record<string, unknown>` and `keyof T` to group items into a `Map` by an arbitrary typed key.
challenge.ts
// Key types + main function signatures

export type Ok<T>  = { readonly tag: "ok";  readonly value: T };
export type Err<E> = { readonly tag: "err"; readonly error: E };
export type Result<T, E> = Ok<T> | Err<E>;

export type ApiError = { readonly code: number; readonly message: string };
export type Page<T> = { readonly items: T[]; readonly nextCursor: string | null };

/** Typed async callback: cursor → Result-wrapped page */
export type FetchPage<T> = (cursor: string | null) => Promise<Result<Page<T>, ApiError>>;

/** Retry wrapper: exponential back-off, returns first Ok or final Err */
export async function withRetry<T>(
  fn: () => Promise<Result<T, ApiError>>,
  maxRetries: number
): Promise<Result<T, ApiError>> { /* TODO */ }

/** Pagination driver: collects all items across pages via withRetry */
export async function fetchAllPages<T>(
  fetchPage: FetchPage<T>
): Promise<Result<T[], ApiError>> { /* TODO */ }

/** Group collected items by the string value of a typed key */
export function aggregateRecords<T extends Record<string, unknown>>(
  items: T[],
  groupKey: keyof T
): Map<string, T[]> { /* TODO */ }
Hints (click to reveal)

Hints

  • The `withRetry` delay can be expressed as `100 * 2 ** attempt` ms — use a small `sleep` helper built with `new Promise(resolve => setTimeout(resolve, ms))`.
  • In `fetchAllPages`, keep a `cursor: string | null = null` variable and update it to `page.nextCursor` each iteration; exit the loop when it is `null` after the first page is processed.
  • Inside `aggregateRecords`, write `String(item[groupKey])` to safely coerce the indexed value to a string without reaching for `any`.

Or clone locally

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