TypeScript: Narrowing - discussion (2022-10-01)
TypeScript narrowing feels like it should be straightforward, but in UI code it’s always mixed with “what did the route promise?” and “what did the runtime actually provide?”.
How do you structure UI surfaces so narrowing is obvious (discriminated unions, guard helpers, boundary assertions)? Do you narrow early in leaf components, or narrow once at the route/shell boundary and keep the rest of the tree boring? What narrowing patterns have actually reduced bugs for you (especially around optional props / undefined states)?
Comments (10)
Back to latestDiscriminated unions for UI states (loading/ok/empty/error) are the highest ROI narrowing technique I’ve seen.
I’m biased toward narrowing at the boundary.
If the route contract says “this is OK-state”, components should not repeatedly re-prove it with if (!x) return null everywhere.
Related: TypeScript and Types and Core Concepts Best Practices.
A simple custom guard removes a lot of noise in components:
ts
type Ok<T> = { kind: 'ok'; value: T };
type Err = { kind: 'err'; message: string };
type Result<T> = Ok<T> | Err;
export function isOk<T>(r: Result<T>): r is Ok<T> {
return r.kind === 'ok';
}
Then your UI reads like a contract: if ok, render value; if err, render message.
Counterpoint: boundary narrowing can hide reality when data is messy.
If you assert too aggressively, you can end up with runtime “undefined” that TypeScript promised couldn’t exist.
That’s fair. I think the boundary has to be both an assertion and an evidence marker.
If you assert, render a contract marker so you can see what you asserted on a given screen.
Yeah — otherwise the assertion is invisible and debugging becomes archaeology.
For props, I like making impossible states literally impossible.
If a component needs user, don’t accept user?: User. Make it a required prop and narrow earlier in the tree.
Optional chaining everywhere is a smell for me.
It often means we didn’t decide whether the value is required by the contract or truly optional in the UI.
We use narrow UI surfaces even when runtime inputs are wide.
ts
type CardProps =
| { mode: 'loading' }
| { mode: 'empty'; message: string }
| { mode: 'ok'; title: string; body: string };
No undefined props, no “maybe”. The render story is explicit.
A nice trick: when narrowing is hard, you probably need a better discriminant.
If you’re checking five fields to guess which shape you have, make a kind and move on.
Narrowing mistakes show up as “can’t reproduce” UI bugs. Anything that makes the state shape explicit is worth it.
My least favorite pattern is narrowing via truthiness (if (x)) when x can be 0 or ''.
It’s an easy way to create bugs that TypeScript won’t warn you about because it assumes you meant it.