TypeScript: Narrowing - discussion (2022-06-25)
How do you keep narrowing from being distributed across the component tree, especially when URL params and remote data are wide or unknown?
Where do you narrow (route shell, fetch boundary, component boundary), and what do you store as the stable surface? Do you use runtime signals/evidence to prove which branch you were in, or rely on types/tests only? Any patterns for avoiding as any creeping into UI code?
Comments (10)
Back to latestNormalize once, store the normalized model, never narrow ad hoc in leaf components. Otherwise everyone reinvents a different truth.
We require narrowing functions to live next to the boundary and be named.
If you can't name the narrowing, it's probably not stable enough to be a contract yet.
Related: TypeScript and Types and the "boundary tax" framing in TypeScript Boundaries (Deep Dive #9).
A basic example that removes a ton of ad hoc checks:
ts
type RawUser = unknown;
type User = { id: string; name: string; role: 'member' | 'admin' };
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null;
}
export function narrowUser(v: RawUser): User {
if (!isRecord(v)) throw new Error('user:not-object');
const id = String(v.id ?? '');
const name = String(v.name ?? '');
const role = v.role === 'admin' ? 'admin' : 'member';
if (!id) throw new Error('user:missing-id');
return { id, name, role };
}
Then store userModel and render a signal like userRole so runtime aligns with type narrowing.
Counterpoint: throwing everywhere can be a bad UX if the boundary is noisy (third-party APIs, partial data).
We sometimes narrow into an error-lane model instead: either User or UserError and render signals for which lane you're in.
That's interesting. Do you keep the error lane in route state, or just render it locally?
The signals idea makes it feel a lot more systematic than try/catch and hope.
Route state if it affects navigation or retry posture; otherwise local is fine. The key is the lane is named and visible.
We banned as any in components and allowed it only in normalization files with tests.
It felt strict, but it forced us to keep boundaries real.
Signals saved us during refactors. We could see when we were suddenly narrowing into a different branch even though types still compiled.
We ended up adding a small render-visible signal from Testing and Debugging to make it screenshot-visible.
The biggest mistake is narrowing differently in two siblings. If both render the same model, make them read the same stored derived model.
We had a perf issue where narrowing was actually expensive parsing (dates, markdown).
Storing the normalized model was a big perf win and made signals stable at the same time.
If you don't store the normalized model, every component becomes a boundary and boundaries are where bugs live.
If your route signals can't tell you which lane you're in, your narrowing is effectively invisible.