TypeScript: Everyday Types - discussion (2023-02-20)
The Everyday Types chapter is the kind of thing you skim until you're in the middle of a refactor and realize half the churn is self-inflicted: wide, mushy shapes at the edges and no shared vocabulary for what counts as a stable surface. I keep coming back to the forum's contract + evidence idea here: types can describe the contract, but only if you keep the contract small enough to actually narrate.
When you're typing UI posture/lanes, do you prefer string unions, discriminated objects, or something more structural? How do you keep boundary types wide while keeping route docs and hook returns narrow and stable? Do you treat evidence tokens as typed outputs, or do you let them be freeform strings?
Comments (14)
Back to latestString unions for lanes, discriminated objects for intent.
If someone can't explain it out loud, the type is too fancy.
We started typing evidence tokens explicitly and it made reviews dramatically calmer. When evidence was just string, people would invent a new phrase every time and tooling couldn't help.
ts
type Lane = 'idle' | 'pending' | 'ok' | 'error';
type Evidence =
| `lane:${Lane}`
| `net:http:${number}`
| `query:canon:${0 | 1}`
| `scope:locale:${string}`;
type Contract<T> = { lane: Lane; value?: T; evidence: Evidence[] };
The point isn't perfection, it's making the vocabulary finite so people stop smuggling meaning into prose.
Counterpoint: I'd rather keep evidence tokens untyped and validate at runtime.
Types are static; evidence is observed. We log evidence and ship it through pipelines that aren't TS-aware anyway.
I think both can work. The win for us was typing the high-value evidence (lanes, sources, canonicalization) and leaving the rest as freeform notes.
Otherwise it becomes a taxonomy project.
Long-form: the hardest part is not picking union vs object, it's preventing boundary drift. Teams add fields to the contract because it's convenient in one component, and then 6 weeks later the contract is a dumping ground.
Our rule: boundary types can be wide, but contracts must have an owner and must narrate themselves with a small [tips] story line.
txt
[tips] contract=SearchDoc lane=pending reason=query:commit evidence=lane:pending
[tips] contract=SearchDoc lane=ok reason=fetch:resolve evidence=lane:ok
If you can't write those lines, you probably shouldn't be adding that field.
We keep boundary types wide using unknown + a single parse/normalize function.
Then the internal contract surface is narrow and boring.
Concrete alternative: don't model intent as a big discriminated union.
We model intent as small, local unions per route doc. Global intent types always turned into a God type.
Docs tie-in: the through-line is "narrow surfaces, wide inputs" and keeping type boundaries honest.
Related: TypeScript and Types and Core Concepts.
We made our types more useful by making them reflect runtime normalization.
Example: instead of page?: number, we store page: number and guarantee it is normalized at the boundary. That single decision eliminated a ton of optional chaining nonsense.
I like discriminated objects for lanes because you can carry reason/source without adding parallel fields:
ts
type Lane<T> =
| { lane: 'pending'; reason: string }
| { lane: 'ok'; value: T; evidence: string }
| { lane: 'error'; error: { code: string; message: string }; evidence: string };
It's slightly more verbose, but it prevents the classic bug where you have lane='error' and value still set.
Short take: the best types are the ones that force you to delete fields instead of adding them.
We tried typing evidence tokens and it was nice, but we overdid it.
The compromise: type the top-level prefixes, keep suffixes freeform. That keeps the vocabulary small without turning it into a schema registry.
Long-form counterpoint: types can also mask bad architecture because they make it feel safe.
I care less about whether the type is perfect and more about whether the contract can be observed. If the UI doesn't render evidence and the logs don't narrate transitions, a beautiful type is just a fancy wrapper around mystery.
We made boundary parsing testable by pinning the normalization rules in a single file and writing tiny unit tests for it.
Then everything downstream can assume the narrow type is real.
Takeaway: pick the simplest type shape that still encodes invariants, then invest the rest of your energy in evidence and narration.
Everyday Types is the foundation, but the contract discipline is what keeps it from collapsing.