TypeDrop

2026-04-16 Challenge

2026-04-16 Medium

Typed API Rate Limiter

You're building the outbound API gateway for a SaaS integration platform. Multiple services make concurrent requests to third-party APIs with different rate limits; your gateway must validate raw endpoint configurations, enforce per-client token-bucket limits, execute requests with typed results, and return a structured dispatch report — with zero `any`.

Goals

  • Implement runtime brand constructors (`makeTokenCount`, `makeClientId`, `makeEndpointUrl`) that validate and narrow `unknown` inputs to nominal branded types.
  • Build a `TokenBucket` class with typed getters and a `tryConsume()` method, plus a `RateLimiterRegistry` that manages one bucket per (clientId × endpointUrl) pair.
  • Implement `validateEndpointConfig` that parses an `unknown` blob into a fully-typed `EndpointConfig`, throwing descriptive `TypeError`s for any violation.
  • Implement `dispatchBatch` using `Promise.allSettled` to concurrently dispatch all requests through the registry, returning a typed `DispatchReport` with per-client fulfillment counts.
challenge.ts
// Key types and main function signature

type TokenCount  = number & { readonly __brand: "TokenCount" };
type ClientId    = string & { readonly __brand: "ClientId" };
type EndpointUrl = string & { readonly __brand: "EndpointUrl" };

interface EndpointConfig {
  readonly url:       EndpointUrl;
  readonly method:    "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
  readonly rateLimit: TokenCount;
  readonly windowMs:  number;
}

interface ApiRequest<TPayload> {
  readonly requestId: string;
  readonly clientId:  ClientId;
  readonly endpoint:  EndpointConfig;
  readonly payload:   TPayload;
}

type RequestResult<TPayload, TResponse> =
  | { status: "fulfilled";    requestId: string; clientId: ClientId; payload: TPayload; response: TResponse; tokensRemaining: number }
  | { status: "rate_limited"; requestId: string; clientId: ClientId; payload: TPayload; retryAfterMs: number }
  | { status: "rejected";     requestId: string; clientId: ClientId; payload: TPayload; error: string };

type FetchFn<TPayload, TResponse> = (req: ApiRequest<TPayload>) => Promise<TResponse>;

// --- Main entry point ---
async function dispatchBatch<TPayload, TResponse>(
  requests:  ReadonlyArray<ApiRequest<TPayload>>,
  fetchFn:   FetchFn<TPayload, TResponse>,
  registry:  RateLimiterRegistry
): Promise<DispatchReport<TPayload, TResponse>>
Hints (click to reveal)

Hints

  • Branded types are just intersection types — your brand constructors do the runtime guard and then `return value as TokenCount` (the one place a cast is justified: inside the constructor whose sole job is establishing the invariant).
  • For `dispatchBatch`, consume the token *before* calling `fetchFn`; wrap the `fetchFn` call in a try/catch inside the `Promise.allSettled` worker so a rejected fetch still lands in `allSettled` as fulfilled (with a `rejected` result), keeping order stable.
  • Build `clientSummary` in a single `reduce` pass over the results array, using a `Map<ClientId, number>` — then wrap it in a `ReadonlyMap` cast when returning.

Or clone locally

git clone -b challenge/2026-04-16 https://github.com/niltonheck/typedrop.git