TypeDrop

A new TypeScript challenge every day. Sharpen your types.

TypeDrop delivers a fresh TypeScript challenge every day, generated by AI. Pick a challenge, open it in StackBlitz (preferred) or CodeSandbox (or clone it locally), and make the tests pass. No accounts, no setup — just you and the type system.

Learn more on GitHub →

2026-04-22 Medium

Typed API Response Cache

You're building the caching layer for a typed REST API client used across a large frontend monorepo. Raw responses arrive as `unknown` from fetch; your cache must validate them, store them with TTL-aware entries, and serve requests through a stale-while-revalidate strategy — with zero `any`.

Goals

  • Define `CacheEntry<T>`, `CacheStatus<T>`, and `RevalidationResult<T>` as fully-typed discriminated unions and generic types.
  • Implement `buildValidator` using a mapped-type `shape` parameter that narrows `unknown` to `T` without `any` or type assertions.
  • Implement all six `TypedCache<T>` methods (`set`, `get`, `delete`, `revalidate`, `getOrFetch`, `purgeExpired`) with correct TTL logic and typed return values.
  • Ensure every code path is covered by the discriminant checks so TypeScript's exhaustiveness analysis is satisfied.
challenge.ts
// Core types and main class signature

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

export type Validator<T> = (raw: unknown) => Result<T, string>;

export type CacheEntry<T> = { /* value, storedAt, ttl */ };

export type CacheStatus<T> =
  | { status: "hit";   value: T }
  | { status: "stale"; value: T }
  | { status: "miss" };

export type RevalidationResult<T> =
  | { revalidated: true;  key: string; value: T }
  | { revalidated: false; key: string; error: string };

export class TypedCache<T> {
  constructor(
    private readonly defaultTtl: number,
    private readonly validator: Validator<T>
  ) {}

  set(key: string, raw: unknown, ttl?: number): Result<T, string> { ... }
  get(key: string): CacheStatus<T> { ... }
  delete(key: string): boolean { ... }
  async revalidate(key: string, fetchFn: FetchFn): Promise<RevalidationResult<T>> { ... }
  async getOrFetch(key: string, fetchFn: FetchFn): Promise<Result<T, string>> { ... }
  purgeExpired(): number { ... }
}
Hints (click to reveal)

Hints

  • For `CacheStatus<T>`, give each variant a `status` string literal field — TypeScript will use it as the discriminant when you narrow with `if (s.status === 'hit')`.
  • In `buildValidator`, iterate over `Object.keys(shape)` and cast the key as `keyof T` — the mapped-type constraint on `shape` gives you the predicate for each field without needing `any`.
  • In `getOrFetch`, check the result of `this.get(key)` first: only skip the fetch when `status === 'hit'`; treat both `'miss'` and `'stale'` as needing a fresh fetch.

Or clone locally

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