Writing Self-Documenting TypeScript: Naming, Narrowing, and Knowing When to Stop
Source: Dev.to
There’s a quiet kind of technical debt that doesn’t show up in bundle size or test coverage
Code that requires a mental simulation to understand. You read it line by line, holding context in your head, reverse‑engineering what the author meant. It works — but it explains nothing.
TypeScript gives you unusually powerful tools to fight this. Not just for catching bugs, but for communicating intent. This post is about using those tools deliberately in UI projects — the kind with complex state, conditional rendering, and types that evolve fast.
1. Name Types Like You’re Writing Documentation
The first place self‑documenting code lives is in your type names. A good type name answers what this thing is, not just what shape it has.
Avoid
type Obj = {
id: string;
val: string | null;
active: boolean;
};Prefer
type FilterOption = {
id: string;
label: string | null;
isSelected: boolean;
};The second version tells a reader — immediately, before any implementation — that this is a filter option in a UI, it has a display label that might be empty, and it tracks selection state. No comment needed.
This extends to union members too. Instead of "a" | "b" | "c", name your values after what they mean:
type SortDirection = "ascending" | "descending";
type ModalState = "closed" | "opening" | "open" | "closing";Now every conditional branch in your JSX reads like a sentence.
2. Narrow Early, Use Confidently
One of the most common readability killers in React components is optional‑chaining soup:
const label = props.user?.profile?.displayName ?? props.user?.email ?? "Guest";This is safe, but it’s exhausting to read. It tells you nothing about when profile or displayName might actually be absent, and it forces every downstream use of props.user to carry the same uncertainty.
The fix: narrow early — ideally at the boundary of your component.
if (!props.user) return;
// From here, `props.user` is fully defined
const label = props.user.profile?.displayName ?? props.user.email;Now the narrowing is visible, intentional, and the rest of the component operates with confidence. The code says: “if there’s no user, we handle it here — otherwise, we proceed.”
This is especially valuable in UI projects where components receive data from multiple async sources. Guard at the top, render with clarity below.
3. Let Discriminated Unions Do the Talking
Optional booleans are one of the sneakiest sources of unreadable state:
type RequestState = {
isLoading: boolean;
data?: User[];
error?: Error;
};What does it mean when isLoading is false and both data and error are undefined? Nobody knows. You now need comments or tribal knowledge to interpret this.
A discriminated union makes every state explicit:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: Error };Now your rendering logic is a direct translation of your type:
switch (state.status) {
case "idle":
return ;
case "loading":
return ;
case "success":
return ;
case "error":
return ;
}Any future developer who adds a new status will be forced to handle it in every switch — the compiler becomes a reviewer.
4. Extract Named Predicates for Complex Conditions
Inline conditions are fine for simple cases. When a condition grows — or when the same condition is checked in multiple places — extracting a named predicate makes intent explicit.
Before
{props.items.length > 0 &&
!props.isLoading &&
props.currentUser?.role === "admin" && (
)}After
const canShowBulkActions =
props.items.length > 0 &&
!props.isLoading &&
props.currentUser?.role === "admin";{canShowBulkActions && }The variable name canShowBulkActions communicates purpose, not just mechanics. You can read the JSX without understanding the condition — and when you do need to understand it, it’s in one place.
For reusable predicates, a type guard is even better:
function isAdmin(user: User | null): user is User & { role: "admin" } {
return user?.role === "admin";
}5. Use type Aliases to Name Intent, Not Just Structure
A string prop that represents a color hex, an ISO date string, or a resource ID carries no semantic meaning by itself. A named alias gives it context:
type HexColor = string;
type ISODateString = string;
type ResourceId = string;
type EventCard = {
id: ResourceId;
color: HexColor;
startDate: ISODateString;
};These are “weak” aliases — the compiler still treats them as string — but they communicate purpose to readers and serve as documentation that doesn’t drift. If you want enforcement, you can use branded types:
type ResourceId = string & { __brand: "ResourceId" };Even without branding, the intent is clear, and the codebase becomes easier to navigate and maintain.
6. Knowing When to Stop
Here’s the uncomfortable part: TypeScript can be over‑engineered just as easily as it can be under‑engineered.
Signs you’ve gone too far:
- You’re writing generic constraints that require a comment to explain.
- You have utility types that wrap other utility types three layers deep.
- Your component props look like a type puzzle before you see any logic.
- You’re writing custom type guards for things a simple
ifwould handle clearly.
The goal isn’t maximal type safety; it’s code that a new team member can read with confidence.
Sometimes as boolean is wrong not because it’s a cast, but because the underlying type was already boolean and the cast just adds noise. The problem isn’t always the cast — it’s the confusion that prompted it.
A useful rule of thumb: if you have to explain a type, the type isn’t doing its job. Refactor the type, not the comment.
Putting It Together
Self‑documenting TypeScript isn’t a single technique — it’s a discipline of asking “what does this tell a reader?” at each step:
- Names should describe purpose, not structure.
- Narrowing should happen early and explicitly.
- Unions should make states visible and exhaustive.
- Predicates should be named when conditions carry meaning.
- Aliases should communicate what a primitive actually represents.
- Restraint should prevent the type system from becoming an obstacle.
Done well, your types become your documentation. Your compiler becomes your reviewer. And the next developer to open the file — including future you — spends their energy on new problems, not decoding old ones.
Have patterns you’ve found useful (or over‑engineered) in your own UI codebase? Drop them in the comments.