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