TypeScript: Narrowing - discussion (2023-01-26)
Narrowing is the difference between "we have types" and "the UI can't lie". Once you start treating routes and mutation intent as contracts, the best patterns are the ones that make illegal states unrepresentable *and* keep evidence rendering readable. I'd love to hear what narrowing moves people actually ship in React codebases.
Do you model async lanes as a discriminated union or keep parallel booleans around? How do you keep route params typed without resorting to as casts everywhere? What do you prefer for runtime boundaries: custom type guards, asserts, or schema validation helpers? Any patterns for narrowing event payloads / currentTarget in a way that's still ergonomic?
Comments (16)
Back to latestDiscriminated unions for lanes. Every time.
I used to think it was overkill until I had to explain "loading + error" simultaneously in a bug report.
We type the lane and the evidence together so the UI is forced to be honest:
ts
type Lane =
| { kind: 'idle' }
| { kind: 'pending'; startedAt: number }
| { kind: 'error'; message: string; retryAfterMs?: number }
| { kind: 'ok'; value: unknown; receivedAt: number };
function renderLaneEvidence(l: Lane) {
if (l.kind === 'pending') return `pending:${l.startedAt}`;
if (l.kind === 'error') return `error:${l.message}`;
if (l.kind === 'ok') return `ok:${l.receivedAt}`;
return 'idle';
}
Then we render data-lane={renderLaneEvidence(lane)} on the shell.
Counterpoint: unions are great until you start nesting them for every tiny widget and you can't read the types anymore.
We keep unions for anything that crosses routes or is persisted; local ephemeral stuff stays lightweight.
That threshold makes sense.
If you can't explain the union shape out loud, it's probably not a contract; it's just noise.
We use asserts at the route boundary to avoid cascading casts:
ts
function assertString(v: unknown, label: string): asserts v is string {
if (typeof v !== 'string') throw new Error(`${label}: expected string`);
}
Not glamorous, but it means the rest of the code doesn't have to pretend.
I like narrowing by stable discriminants (kind/type) because it also improves logging.
When a bug comes in, you can paste the object and immediately see which lane it is.
Event narrowing: I mostly use instanceof on FormData / URLSearchParams boundaries rather than trying to over-type DOM events.
Typing currentTarget perfectly is less valuable than keeping handler paths explicit.
One small win: treat intent objects as a union and narrow in the mutation handler.
It keeps the "why did this happen" story tight, and it matches the evidence posture from these guides.
We got a lot of leverage out of narrowing query params, especially for filter panels.
If you don't narrow, every panel becomes string | null | undefined soup.
Counterpoint: narrowing can give a false sense of safety if any sneaks in through JSON boundaries.
If the boundary isn't validated, the type checker is just being polite.
Yep. This is why I like asserts helpers right after parsing and before storing anything.
Once it's in a vault/store, you want it to be "typed forever".
We use unions for route keys too:
ts
type Panel = 'overview' | 'details' | 'debug';
type RouteState = { panel: Panel; focus?: 'search' | 'composer' };
It's basic, but it prevents "mystery panel" bugs.
We still do some casts, but the rule is: only cast in adapters.
If a component needs as, the boundary is in the wrong place.
Narrowing pairs really well with the docs' TypeScript + patterns pages.
Related: TypeScript and Types and Patterns Library.
My favorite narrowing pattern is boring: optional chaining + early returns.
It keeps handlers short and you don't end up with giant if pyramids.
For me the win isn't "type safety"; it's refactor safety.
When you tighten a union, the compiler tells you every UI lane you forgot to update.
If you're doing async flows, consider narrowing + evidence as one story: model the flow, render the flow.
Otherwise people end up staring at spinners with no idea what's happening.
Minor take: TS narrowing is also a UX tool. It forces you to decide what states are possible.
When you decide that, it's easier to design UI that doesn't contradict itself.