Mike Cann's avatar
Mike Cann
2 days ago

Readable TypeScript code: 14 patterns for humans and AI

Last week I watched an AI assistant generate a 200-line React component in about twelve seconds. It worked. It also looked like someone had taken a perfectly good function, dropped it down a flight of stairs, and called the result production code. Nested ternaries, hoisted handlers nobody needed, three different ways of checking for null in the same file. The code shipped fast and read slow, which is the worst tradeoff in software because code is read far more often than it's written.

The bar used to be "your teammates can follow it six months from now." The bar now is "your teammates and the AI agent picking up your ticket at 2am can follow it six months from now." Both audiences need the same things:

  • Intent that's obvious on first read
  • Control flow that doesn't require a whiteboard
  • Types that say what they mean

What follows are the top patterns I lean on for readable TypeScript code, drawn from years of shipping production TypeScript and React. Most of them are unglamorous, none require a new framework, and all of them survive contact with an AI assistant if you push back on its defaults.

Why readability still matters when AI writes half your code

Readable code matters more when AI is in the loop, not less. AI assistants are good at generating code and worse at generating code that the next reader, human or model, can follow without rereading it three times. If you let them, they'll hand you a gnarly nested bowl of spaghetti and move on.

The cost shows up later. Every minute you save on the first draft, you pay back twice when a teammate tries to extend the feature, or when you ask the agent to modify something it wrote last month and it can't reason about its own output. Readability is the property that keeps both costs low, because it's the property that lets the next reader make a correct change on the first attempt.

I've watched teams treat AI-generated code as finished work because it compiled and the tests passed. Six weeks later, the same teams were rewriting features they had shipped because nobody could remember what the agent was trying to do. The bar is the next reader, and readability is what clears it.

What makes TypeScript code readable

Readable TypeScript code is code where intent is obvious on first read, control flow is flat, and the type system carries the design rather than fighting it. That means short functions, early returns instead of nested conditionals, discriminated unions for variants, and explicit types where they help the reader more than they help the compiler. Readable code is code that a tired engineer at the end of a long day, or an AI agent two prompts deep into a task, can change without breaking. Cleverness doesn't factor in.

The patterns below are the building blocks, not style preferences. They're the difference between a codebase that scales with the team and one that gets quietly rewritten every eighteen months because nobody trusts what's already there.

General TypeScript patterns

1. Keep functions and components short

Short functions stay on point. They are easier to name, easier to test, and easier to delete when the requirement changes. React components are just functions, so the same rule applies to them.

If a function keeps growing past the point where you can see it without scrolling, that growth is the signal. Pull out the part that has its own clear job, give it a name, and let the original function read like a short list of intentions. The goal isn't a hard line count, since some functions need the room, but the moment you start scrolling to follow a single function is the moment to split it.

If you can't summarise what a function does in one sentence without using the word "and," it's probably doing two things. Split it along the "and."

2. Prefer early returns over nested conditionals

Early returns flatten control flow and read more like a paragraph of English than a nested set of if statements. The reader processes one condition at a time, top to bottom, and never has to hold an open brace in their head.

Before:

1function getDiscount(user: User): number {
2  if (user.isActive) {
3    if (user.tier === "gold") {
4      if (user.yearsActive > 2) {
5        return 0.25;
6      } else {
7        return 0.15;
8      }
9    } else {
10      return 0.05;
11    }
12  } else {
13    return 0;
14  }
15}
16

After:

1function getDiscount(user: User): number {
2  if (!user.isActive) return 0;
3  if (user.tier !== "gold") return 0.05;
4  if (user.yearsActive > 2) return 0.25;
5  return 0.15;
6}
7

A functional rewrite using a lookup table or pattern matching is also fine. Pick the one that reads best for the case in front of you, because the reader should not have to track three open braces to find the answer.

3. Drop curly braces on single-line conditionals

When a conditional has one statement, the braces are noise. if (!user) return null; says everything the braced version says in fewer lines.

I know this one is contested. Some teams require braces because adding a second line without them silently breaks the conditional. That position is defensible, especially in codebases where every change goes through review by an AI agent that might not notice the missing braces. My position is that single-line if reads cleaner and the risk is small if your formatter and linter are doing their jobs. Pick a side as a team and enforce it once, so the codebase reads consistently and reviewers stop arguing about the same diff every week.

4. Default to const and know when to break the rule

Use const everywhere by default. It tells the reader, and the TypeScript compiler, that the binding won't be reassigned, which narrows types better and reduces the "what changed under me" cognitive load.

The exceptions are real but narrow. Tight loops, recursive helpers, and performance-critical hot paths sometimes need direct mutation. If you're pushing ten frames of LED color data through a 200-LED strip at 60Hz, allocating a new array on every tick is a problem worth solving with mutation. For almost everything else, const and a fresh value beat let and reassignment.

When a reader sees const, they know that name points at one thing for the rest of the scope. With let, they have to scan downward to find out whether the value changes before they can reason about it, and that scan adds up across a file.

5. Avoid bang in ternaries and double-bang for null checks

A ! inside a ternary forces the reader to mentally invert the boolean before they can read the branches. Swap the branches instead.

1// Harder to read
2const label = !isLoggedIn ? "Sign in" : "Account";
3
4// Easier
5const label = isLoggedIn ? "Account" : "Sign in";
6

Double-bang has the same problem. !!value works, but Boolean(value) says what it means in one fewer mental step.

JSX has its own version of this. {user && <Profile />} looks like "render Profile when user exists," but if user happens to be 0 or an empty string, React renders that literal 0 or empty string instead of nothing. Use a ternary so the reader sees both branches, and the DOM doesn't end up with a stray 0 in it.

6. Use object parameters instead of long positional arg lists

JavaScript doesn't have named arguments the way some languages do. The closest equivalent is passing an object.

1// Positional. You have to hover to know what each arg means.
2sendEmail(user.email, "Welcome", template, true, false);
3
4// Object. Intent is obvious at the call site.
5sendEmail({
6  to: user.email,
7  subject: "Welcome",
8  template,
9  trackOpens: true,
10  highPriority: false,
11});
12

There's a tiny allocation cost, but the readability win at the call site is large. Apply the same logic to return values: if the meaning of the returned tuple isn't obvious from the function name, return an object with named fields instead. A two-element tuple is fine for something like useState. Past two, names beat positions every time.

7. Model variants as discriminated unions

When a value can be one of several shapes, model it as a discriminated union with a literal tag. I use kind rather than type for the tag, because type is already overloaded in TypeScript.

1type PaymentMethod =
2  | { kind: "card"; last4: string; expiresAt: string }
3  | { kind: "bank"; accountLast4: string; routing: string }
4  | { kind: "wallet"; provider: "apple" | "google"; deviceId: string };
5
6function describe(method: PaymentMethod): string {
7  if (method.kind === "card") return `Card ending ${method.last4}`;
8  if (method.kind === "bank") return `Bank account ${method.accountLast4}`;
9  return `${method.provider} Pay on device ${method.deviceId}`;
10}
11

Inside each branch, TypeScript narrows the type for you, with no casts, optional chaining hacks, or any. This is one of the highest-payoff patterns in TypeScript, and the one AI assistants underuse the most, because their training data is full of older code that models the same situations as a single type with a pile of optional fields and a comment explaining which ones go together.

8. Add an exhaustive check so the compiler catches new cases

Discriminated unions pair naturally with an exhaustive check. Add a helper that takes never, and the compiler will tell you every site that needs updating when you add a new variant.

1function assertNever(value: never): never {
2  throw new Error(`Unhandled variant: ${JSON.stringify(value)}`);
3}
4
5function describe(method: PaymentMethod): string {
6  if (method.kind === "card") return `Card ending ${method.last4}`;
7  if (method.kind === "bank") return `Bank account ${method.accountLast4}`;
8  if (method.kind === "wallet") return `${method.provider} Pay`;
9  return assertNever(method);
10}
11

Add a fourth variant to PaymentMethod, and TypeScript will fail the build at the assertNever line until you handle it. If you want a more ergonomic, functional take on the same idea, the ts-pattern library for exhaustive pattern matching gives you a match expression with exhaustiveness built in, and the variant helpers for constructing union members offer ergonomic constructors and discriminators for tagged unions.

9. Extend discriminated unions into the database layer

Variants don't stop at the function boundary. The cleanest TypeScript codebases I've worked in extend the same discriminated union into the database so the state machine lives in one place.

In a Convex schema definition, you can express a union directly:

1import { defineSchema, defineTable } from "convex/server";
2import { v } from "convex/values";
3
4export default defineSchema({
5  fileUploads: defineTable(
6    v.union(
7      v.object({ kind: v.literal("created"), filename: v.string() }),
8      v.object({ kind: v.literal("uploading"), filename: v.string(), progress: v.number() }),
9      v.object({ kind: v.literal("uploaded"), filename: v.string(), storageId: v.id("_storage") }),
10      v.object({ kind: v.literal("errored"), filename: v.string(), error: v.string() })
11    )
12  ),
13});
14

The same kind field that drives your React rendering also drives your schema validation. Add an "archived" variant, and TypeScript flags every read site and write site that needs updating, from the React component down to the mutation handler.

If your variants share most of their fields and differ in one or two optional values, nullable columns are fine and simpler. If the variants diverge significantly, with different required fields per case, model the union in your schema and let the type system carry the design end to end. That end-to-end consistency is what pays the patterns above forward, because the same union narrows your component code and validates your writes with no duplication between the two layers.

React-specific patterns

10. Push queries and mutations down to where they're used

Stop drilling fetched data through props and call your query in the leaf component that renders it. Each component owns the data it needs, and the parent stops being a switchboard.

1// Don't pass `tasks` down five levels.
2function TaskList() {
3  const tasks = useQuery(api.tasks.list);
4  if (tasks === undefined) return <Spinner />;
5  if (tasks.length === 0) return <EmptyState />;
6  return tasks.map((t) => <TaskRow key={t._id} taskId={t._id} />);
7}
8
9// Each row fetches its own detail. Convex deduplicates the underlying subscription.
10function TaskRow({ taskId }: { taskId: Id<"tasks"> }) {
11  const task = useQuery(api.tasks.get, { taskId });
12  if (task === undefined) return <RowSkeleton />;
13  return <div>{task.title}</div>;
14}
15

The same applies to mutations. Call useMutation from the leaf component where the button lives, not from the page-level container. Smaller components mean a smaller blast radius when something breaks. And the Convex client deduplicates identical query subscriptions, so the same data fetched in two places doesn't double your network traffic.

This pattern flips the usual React advice on its head. The conventional wisdom says lift state up, pass it down, and the parent becomes the source of truth. With a reactive query layer underneath, the database is already the source of truth, so the parent doesn't need to be. Lifting query calls up only adds a coordination problem that the framework was designed to remove.

11. One component per file with early returns in JSX

One component per file keeps mental RAM low. When you open a file, you should see one named thing, its props, and its rendering logic. Helper components used only inside it can stay in the file if they're small. Anything reused belongs in its own file.

Early returns work in JSX too. Handle the loading and missing cases first, and the happy path sits at the bottom of the function instead of buried in a chain of ternaries.

1function Profile({ userId }: { userId: Id<"users"> }) {
2  const user = useQuery(api.users.get, { userId });
3  if (user === undefined) return <Spinner />;
4  if (user === null) return <NotFound />;
5  return (
6    <article>
7      <h1>{user.name}</h1>
8      <p>{user.bio}</p>
9    </article>
10  );
11}
12

The happy path is the one you want to read most often, so it deserves the unindented baseline of the function. Edge cases live above it, handled and dismissed, so by the time you reach the main render you already know the data is loaded and valid.

12. Inline event handlers instead of hoisting them

AI assistants love to hoist event handlers. They will generate const handleClick = useCallback(() => { ... }, [deps]) for a button that fires a single mutation, then move on like they did you a favor. But they didn’t. They added six lines, one dependency array to maintain, and one extra place a reader has to look.

1// Hoisted for no reason
2function DeleteButton({ taskId }: { taskId: Id<"tasks"> }) {
3  const remove = useMutation(api.tasks.remove);
4  const handleClick = () => {
5    remove({ taskId });
6  };
7  return <button onClick={handleClick}>Delete</button>;
8}
9
10// Inline. Reads in one pass.
11function DeleteButton({ taskId }: { taskId: Id<"tasks"> }) {
12  const remove = useMutation(api.tasks.remove);
13  return <button onClick={() => remove({ taskId })}>Delete</button>;
14}
15

When two handlers really do share logic, extract that logic into a plain function the handlers call. Don't hoist the handlers themselves unless the framework forces you to, for example when you need referential stability for a memoized child. The default should be inline, and hoisting should be a deliberate decision you can justify, not a habit your assistant inherited from a tutorial written in 2019.

13. Use promise chains for short mutations and a hook for repeated boilerplate

For short mutation calls, the promise-style chain reads better than a try/catch wrapper:

1remove({ taskId })
2  .then(() => toast.success("Deleted"))
3  .catch((err) => toast.error(err.message));
4

For UI that should feel instant, layer in optimistic updates with the Convex client once the basics are working. Treat them as a polish pass, not a starting point, because the optimistic path only pays off once the underlying mutation is correct and the failure case is handled.

For the boilerplate that shows up in every component that calls a mutation, a small wrapper hook removes most of it. A community gist of the useErrorCatchingMutation wrapper hook wraps useMutation so error toasts and logging happen in one place, and your components stop carrying try/catch noise. Once the wrapper exists, your components go back to looking like the inline example above, and your errors are consistent across the app.

14. Use context for high-frequency local state only

Around 95 percent of state in a Convex-backed app belongs in tables. The reactive query layer means your components stay in sync without you wiring anything.

The exception is high-frequency UI state that would overwhelm any database. If you're driving a 200-LED Christmas-light visualizer at 60Hz, that data isn't going through a mutation per frame. Use a parent-level provider and React context to hold it, share it with the components that need it, and write to the database only at meaningful checkpoints, such as save, pause, or end of session.

Persistent state goes in the database, ephemeral high-frequency state goes in context, and form state goes in local component state. Most teams over-use context because they reach for it before they reach for the database. Flip that order and most of your context disappears.

A readability rubric you can apply to a pull request

When I review TypeScript PRs, I run them against four questions. I call this the PEER rubric, and it takes about ninety seconds per file:

  • Predictable control flow. Can I read each function top to bottom without backtracking? Are conditionals flat, with early returns handling the edge cases first?

  • Explicit variants. When a value can be one of several shapes, is that modeled as a discriminated union with a literal tag, or is it a single type with a pile of optional fields and an implicit "you'll figure it out" contract?

  • Early returns. In both TypeScript and JSX, are loading, empty, and error states handled before the happy path? Is the happy path the unindented baseline of the function?

  • Right-sized files. Is there one component per file? Are functions short enough to see without scrolling? When a file grows past a couple of hundred lines, is there an obvious split waiting to happen?

If a PR scores yes on all four, it ships. If it scores no on two or more, it goes back with comments pointing at which rubric item failed. The rubric isn't a substitute for judgment, but it reliably surfaces the gaps between readable TypeScript code and what AI-generated drafts tend to produce, and it gives reviewers a shared vocabulary so feedback stops feeling personal and starts feeling structural.

Frequently asked questions

What makes TypeScript code readable?

Code where intent is obvious on first read, control flow is flat, and the type system carries the design. In practice that means short functions, early returns, discriminated unions for variants, object parameters at the call site, and explicit types where they help a human reader more than they help the compiler. Readable code is code a tired engineer can change at the end of a long day without breaking it, and code an AI agent can extend without re-deriving its own intent.

When should I use a discriminated union instead of an enum or string literal?

Use a discriminated union whenever each case carries different data. An enum or string literal tells you which case you're in but not what data comes with it. A discriminated union tells you both, and TypeScript narrows the payload type for you inside each branch. If the cases have no associated data, a string literal union is fine and lighter. The deciding question is whether each variant has its own required fields, because that's what discriminated unions encode and string literals can't.

Should I use interface or type for object shapes?

Prefer type when you need unions, intersections, or mapped types, and interface for object shapes that may be extended through declaration merging or library augmentation. For most application code the distinction is small, and what matters more is that your team picks one and applies it consistently so reviewers stop relitigating the choice in every PR.

How do I keep React components readable as they grow?

Push queries and mutations down to the leaf components that need them, keep one component per file, use early returns in JSX for loading and empty states, and inline event handlers unless their logic is shared by more than one. When a component grows past what you can see without scrolling, split it along the seam where its responsibilities diverge, which is almost always more obvious in the rendered JSX than in the surrounding logic.

Where should data fetching live in a React component tree?

At the leaf component that renders the data, not at the top of the tree. Drilling props through five levels makes every intermediate component depend on data it doesn't use, which is the opposite of what you want when you're trying to change one of them. Calling useQuery in the leaf is cleaner, and the Convex client deduplicates identical subscriptions so calling the same query in multiple components doesn't multiply your network traffic.

Why do AI coding assistants generate less readable code, and how do I fix it?

AI assistants optimize for plausible-looking output, not for the conventions of the codebase they're working in. They reach for hoisted handlers, nested ternaries, and one-letter variable names because those patterns are common in their training data. The fix is to give them explicit constraints in your prompts or your rules file, review the output the same way you'd review a junior engineer's PR, and refactor anything that fails the PEER rubric above. Treat the first draft as a starting point, not a finished artifact.

Putting these patterns to work

Readable TypeScript code is what determines whether your codebase will absorb the next feature, the next teammate, and the next AI-generated PR without buckling. The patterns above are the ones I reach for first, and the ones I most often have to put back when an AI assistant strips them out. If your schema and your React components share the same discriminated unions, the same kind tags, and the same exhaustive checks, you have a codebase that reads in one direction from the database to the UI, and that's the readability win that keeps paying you back over a project's life.

The patterns aren't a checklist you complete once and forget. They're habits you practice on every PR, refactor, and paired session with an AI agent that wants to hoist a handler you never asked it to hoist. Push back on the defaults, keep functions short, model your variants, and return early. Practice them for a year and you end up with a codebase your team trusts.

If you want to talk about TypeScript patterns, schema design, or how to keep an AI assistant from undoing your conventions, the Convex Discord community for developers is where these conversations happen. If you want to see what it looks like when your database and your React types share the same union, you can start a Convex project with the React quickstart and try modeling a state machine end to end.

Build in minutes, scale forever.

Convex is the backend platform with everything you need to build your full-stack AI project. Cloud functions, a database, file storage, scheduling, workflow, vector search, and realtime updates fit together seamlessly.

Get started