DEV Community

Tarun Moorjani
Tarun Moorjani

Posted on

Writing Self-Documenting TypeScript: Naming, Narrowing, and Knowing When to Stop

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;
};
Enter fullscreen mode Exit fullscreen mode

Prefer:

type FilterOption = {
  id: string;
  label: string | null;
  isSelected: boolean;
};
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

This is safe, but it's exhausting to read. It tells you nothing about when profile or displayName might actually be absent. It also means every downstream use of props.user carries the same uncertainty.

The fix is to narrow early — ideally at the boundary of your component:

if (!props.user) return <GuestView />;

// From here, `props.user` is fully defined
const label = props.user.profile?.displayName ?? props.user.email;
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

Now your rendering logic is a direct translation of your type:

switch (state.status) {
  case "idle":    return <Placeholder />;
  case "loading": return <Spinner />;
  case "success": return <UserList data={state.data} />;
  case "error":   return <ErrorMessage error={state.error} />;
}
Enter fullscreen mode Exit fullscreen mode

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. But 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" && (
  <BulkActionBar />
)}
Enter fullscreen mode Exit fullscreen mode

After:

const canShowBulkActions =
  props.items.length > 0 &&
  !props.isLoading &&
  props.currentUser?.role === "admin";
Enter fullscreen mode Exit fullscreen mode
{canShowBulkActions && <BulkActionBar />}
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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" };
Enter fullscreen mode Exit fullscreen mode

But even without branding, named aliases are a low-cost, high-clarity win.


6. Knowing When to Stop

Here's the uncomfortable part: TypeScript can be over-engineered just as easily as 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 if would 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 the 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.

Top comments (0)