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