TypeScript: Narrowing - discussion (2023-02-25)
The Narrowing chapter is where TypeScript stops being "types as documentation" and starts being "types as control flow." In React apps, I mostly feel this at boundaries: query params, user input, network responses. If narrowing isn't centralized and narratable, it becomes scattered if statements that disagree with each other.
Do you use custom type guards/type predicates in app code, or do you prefer normalize-into-a-contract and avoid guards downstream? How do you keep narrowing decisions consistent across routes/components (shared helper, route doc, vault)? Do you log narrowing outcomes as tips lines, or is that overkill?
Comments (16)
Back to latestNormalize once, then avoid guards downstream.
Type guards everywhere just means everyone narrows differently.
We use a few type predicates, but only at edges and only when they produce a contract that's stored.
If a predicate is only used for a one-off branch, it tends to drift.
We log narrowing outcomes for user-facing boundaries (URLs/forms), not for internal branching.
txt
[tips] narrow=query.page raw="abc" result=fallback value=1 reason=notNumber
[tips] narrow=query.sort raw="" result=drop reason=empty
That log line saves you from the classic bug report: "I clicked a link and it showed weird results".
Concrete alternative: use asserts functions to crash early in dev.
We use asserts in route docs for invariants that should never be violated (like required params). It keeps types honest because you can't ignore the failure.
Do you ship asserts in prod too, or do you turn them into fallbacks?
I like assert in dev, fallback in prod, but it's easy to accidentally ship a crash.
We ship them but guard with a posture: in prod we turn it into lane=error + evidence key, not a throw.
In dev it throws so you fix the contract before it becomes user-facing.
Long-form: narrowing is a product decision disguised as a type decision.
If ?page=0 is invalid, do you clamp? do you fallback? do you show an error lane? Those choices affect UX and support load.
We treat narrowing as normalization with explicit reasons and evidence. It's not just "make TS happy".
Docs tie-in: the TS docs on boundaries pair well with narrowing rules, because the whole point is to keep surfaces small and stable.
Related: TypeScript and Types and Routing and Navigation.
We have a rule: no component is allowed to parse URL params. Components can read normalized values only.
It felt strict at first, but it eliminated inconsistent behavior across routes.
We used to sprinkle typeof checks everywhere; now we collect narrowing into one module and export contract shapes.
It also made tests easier because you can unit test narrowing without rendering UI.
One subtlety: narrowing isn't just for values, it's for identity.
If you compute a request key from raw params, you can get cache fragmentation. Normalize first, then compute identity.
Short take: the best narrowing is the one you never repeat.
We treat narrowing outcomes as evidence keys on the shell (data-qnorm=1).
It's a tiny thing but it turns link debugging from guesswork into observation.
Long-form counterpoint: type guards can be healthy if you keep them close to the domain and keep them tiny.
The unhealthy version is a big schema file that everyone edits. We had more success with small route-specific guards that feed into a shared contract type.
If you're going to log narrowing, keep it one line and keep it stable.
We had logs that included full raw objects and it became noise immediately.
A practical trick: use satisfies on normalized contract objects to keep them from drifting without over-annotating everything.
It keeps the contract explicit and prevents accidental widening.
If narrowing decisions impact UX, render them as evidence.
Otherwise you end up with "sometimes it works" bug reports and no way to reproduce them.
Takeaway: narrowing is an app boundary discipline, not a TS trick.
Centralize it, narrate it, and your React code stops being full of defensive conditionals.