TypeDrop

2026-05-24 Challenge

2026-05-24 Hard

Typed Paginated API Client with Cursor-Based Aggregation

You're building the data-ingestion layer for an analytics dashboard that pulls user activity events from a cursor-paginated REST API. Pages arrive as `unknown`; your client must validate each page, fan out concurrent fetches up to a concurrency limit, aggregate results through a typed single-pass reducer, and surface a strongly-typed report — with zero `any`.

Goals

  • Implement branded constructors `makeCursor` and `makePageSize` that return `Result` types with precise error literals.
  • Implement `validatePage` that narrows `unknown` API responses to `Page<ActivityEvent>` using only type guards — no type assertions.
  • Implement `aggregateEvents` that computes a fully-typed `AggregationReport` in a single pass with an exhaustive `KindStats` mapped type.
  • Implement `fetchAllPages` with a rolling concurrency window and `runIngestion` that wires validation, pagination, and aggregation into one `Result`-returning async pipeline.
challenge.ts

// Core types & main orchestrator signature at a glance

export type Cursor   = string & { readonly __brand: "Cursor" };
export type PageSize = number & { readonly __brand: "PageSize" };

export type EventKind = "click" | "view" | "purchase" | "share";

export type Result<T, E extends string> =
  | { readonly ok: true;  readonly value: T }
  | { readonly ok: false; readonly error: E; readonly detail: string };

export interface Page<T> {
  readonly items:       readonly T[];
  readonly nextCursor:  Cursor | null;
  readonly totalCount:  number;
}

export type KindStats = {
  readonly [K in EventKind]: {
    readonly count:           number;
    readonly totalDurationMs: number;
    readonly totalRevenueUsd: number;
  };
};

export type PageFetcher =
  (cursor: Cursor | null, pageSize: PageSize) => Promise<unknown>;

// Ties everything together — validate config, paginate, aggregate
export async function runIngestion(
  fetcher: PageFetcher,
  config:  ClientConfig,
): Promise<Result<AggregationReport, "InvalidPageSize" | "FetchFailed">>;
Hints (click to reveal)

Hints

  • For `validatePage`, build a series of narrowing helper functions (e.g. `isActivityEvent`) that check each field individually — TypeScript needs explicit `typeof` / `in` guards, not casts.
  • For `KindStats`, initialise all four keys up front using an object literal that satisfies the mapped type — the compiler will catch any missing key at compile time.
  • For the concurrency window in `fetchAllPages`, think of a recursive "slot" pattern: start up to `concurrency` fetches, and as each resolves, immediately launch the next cursor if one is available — rather than batching pages in fixed groups.

Or clone locally

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