Readable TypeScript code: 14 patterns for humans and AI
AI is great at shipping code fast. It's also great at handling you a gnarly nested bowl of spaghetti if you let it. It's possible that in the future we can treat our code entirely as a black box that only the AI knows how it functions. But as of October 2025, that's currently not the case. And so beyond anything trivial, you're probably still going to need to crack open that black box of vibes. Over the past three decades, Jeez, I'm old. Over the past three decades, I've dedicated myself to the craft of writing code that is not just functional, but also understandable and maintainable. Over years of projects, refactorings, and late night debugging sessions, I've developed and refined a set of patterns and practices that I personally use to prioritize human readability. Because here's the thing, code is read far more than it's written. So, making it easy for the next person or probably future you or even AI to understand is super important. So, that's what I want to talk about in this video. A bunch of practical tips and tricks that I've learned over the years that help my code feel much more readable and maintainable. I have got a lot to say here, so I've decided to break things up a little bit into a couple of different videos as this script was getting really quite long. So, if you're interested in the other parts, then make sure uh to like and subscribe this video, and I will leave a link to them down below once they've been released. Oh, and by the way, most of this video is going to be focused on TypeScript and React as that's what I have mostly been doing over the last decade or so of my career. Uh but hopefully many of these principles will apply to other languages, too. Anyways, without further ado, grab yourself a lovely cup of tea and let's get into it. All right, so let's kick things off by talking about some general tips and tricks and general conventions that I like to follow um that uh help keep the code clear and readable. And one of the most important things about doing that is keeping your functions short and sweet. I find that this really helps keep your um logic on point and easy to reason about and test. If a function is getting too long, it's usually a sign that I need to go back and refactor something. The same goes for React components which are just functions after all. So, and I'm going to talk a lot more about React later in this video. Okay, next up is an important one for me that I use everywhere and it's early returns. Rather than having lots of nesting of if statements and loops of in my functions, I almost always prefer a early out more flat kind of structure. Note how by doing it this way gets rid of a bunch of brackets and spare lines by bringing the return onto the same line so it reads more like a sentence of English. This kind of line by line easy to read and understand code is generally what I strive for in my codebase. Just to hammer it in, here's another example. This one's got lots of nested if statements and a for loop which makes it really hard to reason about. So I would much prefer to write it like this. And I might even be tempted to write it in a more functional way like this. Now, naming things is probably one of the most important things you can do to make your code more readable. If done well, proper variable, function, and type names means that your codebase becomes self-documenting, not just for you, your teammates, but also for AI. So, naming things is such a big topic that I've decided to pull this one out of this video and into its own video. So, make sure you get subscribed as you're not going to want to miss that one. I did just want to mention it here in this video, though. As I said, it's a very important part of making your code more human readable. So, I didn't want you to think that I'd left it out. Now, you saw a little bit of this one before, but I generally prefer to try and avoid using curly brackets where possible, particularly for single line if statements. So, here we can see an simple example function with a few if statements. I find these these curly brackets here on the if statements kind of superfluous. And so I would rather write it like this. So yeah, we got rid of a line. We got rid of a bunch of extra noisy syntax through the curly brackets. I personally find this much cleaner and and easier to read than before. I think this is probably more of an obvious one these days. It didn't always used to be the case, but I always uh try and use const instead of var or let just about everywhere in my codebase. Not only does this help with reasoning about what can and can't change from under you when writing your code, it also greatly assists the TypeScript compiler, allowing it to cover your ass in more circumstances than others. I guess the only kind of exceptions to using const everywhere is in tight loops or a very small function where I don't want to have to worry about recursion uh or a while loop or something like that where the the scope of the mutation is scoped to a very small area. I do also bend the rules a little bit sometimes for uh arrays and sets particularly in performance critical code. For example, I'm working on this project here uh that I'll talk about in a future video for a Christmas lights um decoration that I'm working on at the moment. Get subscribe by the way if you want to see this uh where I've got numerous LED strings of lights that all have to update at 60 times a second. So performance critical code here. So with 10 strings updating uh 60 times a second, 200 LEDs, that's going to be a lot of updates. So uh so here I usually like to use uh arrays and sets and just direct mutation instead of copying. Now this one might just be me, but I'm personally not a fan of using the bangar operator to negate a boolean in a turnary operator. To me, this just makes things more confusing than it needs to be. instead just swap those two branches around. So now the reader of your code doesn't have to first mentally invert that boolean logic in their head. Similarly, I'm really allergic to using double bang for null conversion cuz famously like humans aren't very good at double negatives. So I just think that this is unnecessarily complex to understand. Instead, it's just best to use this boolean function that's built right into JavaScript to do this null checking for you. And while we're on this kind of topic of using operators incorrectly, in my opinion, I usually don't use the double amperand in React code to optionally render something. Instead, I prefer to explicitly write out the null case like this. In my opinion, this just reads better when you're scanning through the codebase. In C, you can call methods using named arguments like I've shown here. Uh this makes it really clear which parameter represents what at the call site and this is particularly useful when you've got many many parameters for your function. JavaScript unfortunately doesn't have this feature natively which makes it really tricky for the reader of your code to know exactly which of these arguments does what without using IDE features such as hovering over the function. So for this reason I almost always use an object instead of a list of arguments for a function like so. I think this really helps with the readability of the code at the expense of maybe a little bit of extra memory due to the object allocation here. Also, if the function um returns something as a result of its operation and it's not obvious from the name of the function what that return value represents, then I will also often return an object with a field in that object with the return value inside of it just to make it clear what that value represents. Okay, so this is a fairly large topic and I probably should do a whole video on this one. um but it's important enough that I thought would cover here and it's a it's a feature that I've borrowed heavily from the functional world. So whenever I have situations where I have different kinds of something, I will generally create what's known as a discriminated union to represent that. So here for example, we have a type that is a union of objects that is discriminated by this kind field here. I like to use the word kind instead of type like you might see other people use because I feel like type is kind of an overloaded term in Typescript world because it's it's type of something. So now when we pass this payment method into this function here process payment method we can do something different in each case of this kind check. The key thing to understand here is that TypeScript is really smart because as you progress down here through the function, you've eliminated the types that this object can be. So, TypeScript is does what's known as type narrowing to reduce the the the type that it could be until we end up with a type right at the bottom, which is never. And the cool thing that TypeScript lets us do is check against this never type here through this exhaustive check function that I wrote. In my opinion, this is super powerful. As your codebase grows, it's very easy to forget to handle one of the additional um shapes that your data can take, which can very easily lead to bugs. So, if you write your code in this way, then you can just leverage the compiler to save you whenever you add a new kind to this to the list of your types. Oh, and the great thing about convex and one of the big reasons I fell in love with it is that it convex makes it super easy to extend this discriminated union pattern to your database layer as well. So for example, here is a part of the schema for my Christmas lights project. Here you can see we have a table where each row is going to either be a string, folder or switch. Then on the front end, I just discriminate over that kind to render the appropriate inspector. And this works just really well and helps keep your code super clean. I just like to use it wherever I can. You'll also note that in this code, I prefer to use a series of if statements with early returns rather than switch statements. So, for example, here's what it would look like as a switch statement. For whatever reason, I just prefer a series of if statements. Maybe it's this separation line here between the switch and the top of each case. I don't know. But I just prefer a series of if statements. Anyway, just to hammer this in a little bit more, here's another example convex type from one of my projects. This union here represents the upload state for a file. It can be created, uploading, uploaded, or errored. Uh it's clean and tur in my opinion. And here's another example. is for a competition entry that has the status is the discriminator instead of kind. So you can see how this can be thought of as a state machine really uh where each state contains only the data that needs to be for that specific state. And you can see how easy it would be just to add extra states to your database as your project requirements change over time which they inevitably will. And if you wanted to do this kind of uh thing in a relational database like Postgress and MySQL, you typically have to either have a bunch of nullable fields or a great many tables with some complex join or materialized view or something over them to be able to access all of the entries. Oh, one last thing before I stop gushing about my love of discriminate unions is that if you are like me and you like to work this way, then you might be interested to check out this little known routing library called type route. I've used it in many projects to great effect. So, at the top level, you define your routes like this using this create router function, which then gives you back a couple of type- safe helpers. then you can use them in your React components like this. So this should be fairly familiar to you given the examples I showed you before. It's basically doing discriminated union and type narrowing and this is all type safe thanks to the abilities of TypeScript and this type route library. Oh, by the way, if you were of the more functionally inclined person, you could also pull in another library TS pattern which could tidy things up even more. Another one I have used in the past though is a library called variant. This awesome little library tidies things up even more allowing you to cify this discriminated logic into your own helper functions like this match kind. In this example here, it does exactly what it sounds like. It matches the status by the given kind. And again, this is exhaustive. So if this ship blueprint type changes to add another kind, then the types set compiler is going to let us know that we need to handle it here as well. Okay, now let's talk about some React specific conventions I like to follow to keep my React code as human readable as possible. Firstly, the obvious one has got to be use Convex. And I'm not just saying that because I'm now an official Convex shill. Comx really does genuinely uh massively simplify the cognitive load when writing clear and maintainable React code. The first tip is something that I really like to do, which is to push my convex mutations and queries down as low into my components as possible without them actually entering the the common components like buttons, labels, etc. So here in this example where we have this parent component instead of passing this query down to the user profile subcomponent here I much prefer to write it like this where the user profile component itself takes no props and instead simply uses that uh query hook instead. And because convex dduplicates this data fetching logic for us uh you can safely use the same query all over your react app without any problems. The same concept goes for mutation. So rather than having a subcomponent uh that lets a parent know that something happened, then the parent caused a mutation itself, I prefer to do it this style where the subcomponent does the calling of the mutation itself. I find this localization of convex calls within the subcomponent helps minimize the size of your kind of con conceptual unit within your application. So less dependencies from outside compon components just helps with that that cognitive load. I personally generally like to keep one component per file in React so I can keep the files quite small and focused. This is in keeping with my general principle of trying to keep the number of lines down in a file. The more components types and other things in the same file, the more you kind of need to load into your head when you load this into your mental RAM. You can also see in this example here the era return technique that I talked about earlier coming into play in React code as well. Oh man, this one frustrates me so much at the moment with AI. For whatever reason, all the current leading models just absolutely love to hoist these handlers away from where they're being used up here into the component body like this. So that just means like all this noise here, this const, this variable name, these types, they're just cluttering things up. You just they just don't need them because they can just be aligned where they're being used. Yes, there are occasions where you might need to share some logic between multiple handlers, but I find these exceptions quite rare. And when you do have those exceptions, just create a function that can be used from those various handlers. I have got no idea why AI loves to do this. Uh, but it does annoy me no matter how many times I try and spell it out in the guidelines I give it. AI still insists on doing this. Hopefully one day it won't. Generally, when using convex mutations and React code, your inputs are usually quite short. So I find instead of doing this where we uh do try catch I prefer to inline the handler and remove lots of the brackets um by using this fluent promise style instead of the try catch. Often with convex your mutation is changing something in the database which causes something else to happen in your react tree because it's got subscription to the data. uh which generally means that you can get rid of this then here because usually it's not needed because something else will just update automatically. I also generally like to do a pass towards the end of the project just to sprinkle on a little bit of optimistic updates to make things just feel a little bit nicer for the user which in this case will mean that this loading indicator goes away like this. And finally I almost always like to create a generic error handler react hook uh which to catch errors like so. So now that allows us to shorten our example to something like this. And just thinking about it, we could probably even take this a step further if we want to get really clever. We could wrap our convex use mutation hook like this. So we can catch the error automatically. So now that's going to allow us to remove the error handling code from our component, leaving up with something like this, which in my opinion is super clean and nice. 95% of the time you should probably just store your state in your convex tables and let the reactivity just flow through your app naturally. But there are times when you do need some local state. This is the case in that Christmas lights project I'm working on at the moment. These LED strings need high frequency updates which means that I can't really put them into the comics database. It would just be way way way too many updates high frequency. So to avoid the prop drilling that might be associated with um passing the state down to the lower components, I like to use a um provider and React context at the parent level and then just have the children components access that context object. This dependency injection-l like pattern helps me keep the cognitive load down and makes the components more readable in my opinion. Okay, so I think I've now talked for long enough. Hopefully I haven't lost too many of you with this. There is, however, so much more I could talk about that I'm definitely going to have to do a follow-up video or several follow-up videos. In particular, I want to cover naming things, convex code layout, tooling, and AI. Oh, and I touched a little bit on it, uh, but some of my little TypeScript helpers that I use in almost every project. So, make sure you get subscribed if you want to see those topics land into your feed. In the meantime, I've left links to all the things I talked about in this video in the description down below. And that's just about it from me for today. Until next time, thanks for watching. Cheerio.
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.
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.
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);34// 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.
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.
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.
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.2functionTaskList(){3const tasks =useQuery(api.tasks.list);4if(tasks ===undefined)return<Spinner/>;5if(tasks.length===0)return<EmptyState/>;6return tasks.map((t)=><TaskRowkey={t._id}taskId={t._id}/>);7}89// Each row fetches its own detail. Convex deduplicates the underlying subscription.10functionTaskRow({ taskId }:{ taskId:Id<"tasks">}){11const task =useQuery(api.tasks.get,{ taskId });12if(task ===undefined)return<RowSkeleton/>;13return<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.
1functionProfile({ userId }:{ userId:Id<"users">}){2const user =useQuery(api.users.get,{ userId });3if(user ===undefined)return<Spinner/>;4if(user ===null)return<NotFound/>;5return(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 reason2functionDeleteButton({ taskId }:{ taskId:Id<"tasks">}){3const remove =useMutation(api.tasks.remove);4consthandleClick=()=>{5remove({ taskId });6};7return<buttononClick={handleClick}>Delete</button>;8}910// Inline. Reads in one pass.11functionDeleteButton({ taskId }:{ taskId:Id<"tasks">}){12const remove =useMutation(api.tasks.remove);13return<buttononClick={()=>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:
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.