The Case for Reading Less Code
Before editing code, do we really need to read it? Not as much as we might think. To safely fix a bug or change a feature, we need to learn certain things about the code—but ideally only those things. Extra reading doesn't just waste time; it also bloats our mental model. As that model grows, we're more prone to confusion and losing track of what actually matters.
We can't get away with reading nothing, but we can get much closer. The trick is to skip areas the computer already checks via build errors, types, and tests. That leaves our attention free for the spots where human error actually creeps in—and by identifying those danger zones, we can make the next person's job even easier.
Start from the Outside
When refactoring, the target location may be obvious. Otherwise, we're changing behavior with side effects: an exposed API in the backend, or something rendered to the screen on the frontend. For this example, imagine a mobile app using React Native and TypeScript, but the approach generalizes anywhere build or test errors exist.
Resist the urge to search for RelevantFeatureName. Even aside from the extra reading, that path breaks down when the code is called AlternateFeatureName, SubfeatureName, or LegacyFeatureNameNoOneRemembersAnymore. Instead, search for something external: user-visible strings, including accessibility labels, on the screen you care about. Try various string fragments, quotation marks, and UI inspectors until the string turns up—either in app code or a localization file. In the latter case, the localization key points to the relevant code.
Regression shortcut
If you're tackling a regression, git bisect can remove almost all reading. When it works, you can skip most steps below. That's why it's worth always tracking which bugs are regressions from previously working code.
Hunt the Component
If a simple copy edit is the job, we're done. Otherwise, we need a component whose data ultimately arrives from the server, disk, or user. Exact strings are gone, but a few strategies zero in without reading widely:
- Where does this component sit on screen relative to the text we know?
- What standard component type is it—button, text input, plain text?
- Does it have an easily searchable style parameter, like a distinctive color, corner radius, or shadow?
- Does a button launch this UI, and does that button carry searchable user-facing text?
These tactics ignore naming and structure choices because a developer can't sabotage them without breaking functionality. Good structure still helps. Well-abstracted components make positional searches quick—knowing something is in the footer of the screen trims the search space dramatically. A layout divided as <SomeHeader />, <SomeContent />, <SomeFooter /> beats a long flat list of mixed elements with trailing comments. Even odd naming inside those abstractions doesn't matter if we're after rough positioning, not semantics.
When still unsure, comment out larger or abstracted chunks until the specific item disappears. Then, when the right component is found, make the breaking change immediately, before writing the fix. Adding a new newText parameter to a parent's arguments breaks the build—exactly what we want. Likewise, a bug like "don't show x when y is present" can be encoded as the tagged union {mode: 'x', x: XType} | {mode: 'y'; y: YType}, making the invalid state impossible to construct and triggering compile errors.
Tagged unions go by other names in other languages—discriminated unions, enums with associated values, or sum types.
Ride the Errors Up and Down
Climb the callstack, fixing each caller "as if" the right input will arrive, until build errors stop. At each stage, we read errors, not source. Previous design choices only slow us down if they broke the error chain with something like a loose any.
At the top of the chain, adjust business logic to produce newText or correct the conditional that mis-sent x. The work may end there—or our change may ripple to features we never considered. So we sweep back down the callstack, applying whatever adjustments remain.
This downward pass is where structure starts to matter. Without it, we might comb the code manually to find any related sites. With it, we get useful signals: "because you changed this, you might also need to change that."
Let Tools Guard the Downswing
First defense on the way down is the linter. Deprecated libraries and non-obvious edge-case patterns can be flagged automatically—if prior developers invested in the linter. Otherwise, discover those rules manually by checking other callers of the same library or documentation for discouraged patterns.
Build errors provide the next wave. A function that now returns a new type will error out at other consumers, showing us what to update. Adding enum cases produces errors at exhaustive switches, prompting us to handle the new case. Heavy reliance on the type system pays off here. If it wasn't used, temporarily changing the emitted types flushes out every consumer that needs attention.
An exhaustive switch statement handles every enum case. Whether it's enforced depends on the environment; in TypeScript, strictNullChecks must be on and the switch must have a defined return type. With exhaustiveness enforced, default cases can be removed—and a new enum case becomes a build error, forcing us to revisit each switch.
Unit tests are the last wave. UI and integration tests, with their heavy mocking, bring more reading than we want and fail for noise reasons like timing and incomplete mocks. Unit tests sometimes look like added complexity because they push code toward smaller abstraction layers—yet that restructuring helps us, since we never need to read the app code anyway. If tests are clear and simple, we'd have already seen failures pointing at our changes. When they're not, fall back to git blame on modified lines and check commit messages, tickets, or pull request text for intent and potential regressions.
Comments never help throughout this process. On the upswing, they may exist but we skip them, noting them for later. On the downswing, they're invisible unless we already passed them manually—and even then, they could be stale, requiring a full read of the code underneath to verify. Anything important enough to comment deserves protection by a unit test or a build or lint error. Those checks only surface when the related code actually changes, exposing stale statements immediately and staying out of the way otherwise.
Pay It Forward
With less time spent reading, there's budget left to clean up. This is the chance to fix whatever forced manual reading on the way down—improving the code for future readers who'd rather not.
Extend the Linter
To enforce a standard like a specific library or shared pattern, codify it in the linter rather than expecting future developers to find it on their own. Such a change could shoot the scale of a larger refactor out, so consider making it a separate changeset.
Strengthen Types
Where practical, replace primitives with custom types. timeInMilliseconds: number invites mistakes that time: MillisecondsType would catch at compile time in an environment expecting seconds. For enums, enforce exhaustiveness so the compiler notifies us when a new case needs handling—and watch for non-independent arguments:
- Argument A must be null whenever B is non-null, and vice versa—like
errorandresponse. - If A comes in, B must too—like
eventIdandeventTimestamp. - Flag B can never be on when Flag A is off—like
visibleandhighlighted.
For all three, combine the fields so the type system only admits valid combinations:
- Use a tagged union:
{type: 'failure'; error: ErrorType} | {type: 'success'; response: ResponseType}. - Nest paired fields into one object:
event: {id: IDType; timestamp: TimestampType}. - Merge overlapping flags into a single enum:
'hidden' | 'visible' | 'highlighted'.
Write Tests That a Stranger Can Read
Unit tests are the primary documentation for most code. The problem is that most developers read tests only when something breaks. If a failure requires deep knowledge of UI setup, database state, the network, or async behavior, the reader has to reconstruct a large mental model just to interpret the result. That friction leads to blind test fixes: adjusting an assertion without understanding what changed.
The remedy is to separate what from how. The what code decides which side effects should happen; the how code executes them. If you push all the decision-making into pure functions, the side-effect code becomes trivial and the logic becomes testable without elaborate mocks.
Trivial logic is something like if (shouldShow) show(). Nontrivial (business) logic is something like if (newUser) show(): it encodes application-specific rules whose correctness you can't verify by inspection. Every comment you feel compelled to write is a sign that you've left nontrivial logic untested. Split it into its own function, and the test becomes the comment—one that shows up regardless of how carefully someone reads the code.
Resist the temptation to substitute integration or UI tests for unit tests. A need for extensive test harnesses usually indicates that the code requires too much reading: if a machine needs elaborate setup to run your function, a human needs the same mental setup to trace it. The fix is not to switch test types but to break the code into smaller, independently testable chunks.
When a test starts to get complicated, revise the application code instead of the test. Backend code that touches disk, the database, the network, or the clock should be how only. The decisions that trigger those calls should live in what functions with straightforward, mock-free tests.
Confirm Without Reading
After polishing the code, run it manually. This comes late in the process deliberately: much of the runtime bug surface has already been converted into lint, build, or test errors. In practice, trying the code for the first time often reveals that the edge cases are already handled.
If issues do surface, iterate a few more times, adjusting the code for better "unread"-ability as you go. Any change that requires more comments, more mocks, or deeper context to verify is moving the wrong direction.
Sometimes the explicit goal really is to read code—reviewing a colleague's work, confirming current behavior, or hunting a bug. You can still frame reading as writing. Ask questions that a compiler or test suite can answer:
- Can a developer do this accidentally, or does the linter reject it when we try?
- Is this bad argument combination passable at runtime, or is it stopped at build time?
- If we hardcode this value, which unit tests—and therefore which features—fail?
Posing these questions as code constraints moves the verification burden from the most expensive tool (human attention) to the cheapest ones (linters and test runners), and it leaves the answers documented in a form that can't be skimmed past.



