Code Stories

The Quality of AI Code

Before you begin: don’t start this one in a rush either. It’s a long read, and there’s almost no code to skim past — just ideas worth reading slowly and keeping. Come back when you have a quiet moment, and it’ll repay the attention.

It picks up exactly where the first post left off. If you haven’t read The Qualities of Quality Code, start there — everything below stands on its nine qualities.

A machine can now write the code. It is tempting to conclude that quality matters less than it used to. The opposite is true, and for a simple reason: there is so much more code. What took a week to write arrives in a minute, and all of it lands in an application that someone still has to read, extend and keep alive. The nine qualities were how we judged code when we typed it ourselves. They are how we have to judge it now that we don’t — and this post walks through what becomes of each one when the typing is delegated.

The question we ended on

The first post ended on a point I want to push further. We named nine qualities of quality code — beautiful, standard-following, modular, bug-free, finished, clean, documented, working, tested — and then noticed that not one of them says who, or what, does the typing. More and more, it isn’t us: we describe what we want, and an AI writes it. My claim was that this does not retire the nine qualities. It promotes them, because the job moves from writing the code to directing what gets written, and then judging what comes out.

This post is that claim, slowed down. And the clearest way I know to make it is to tell you about a kind of developer I used to meet long before anyone had heard of a large language model or AI-generated code.

The developer who couldn’t ship

Years ago I did support on a programming forum, where I met the same kind of developer again and again. I used to call him the copy-paste developer.

He always introduced himself the same way. He would post: “I want to do such-and-such — can you help?” I’d write a small sample showing how it could be done. He’d copy it, paste it into his application, and a little while later reply with three words:

“Your code doesn’t work.”

And here’s the thing: my code was fine. What didn’t work was the join between his application and my sample, and he could not see the seam, because he had not understood a single line of what he pasted. Often he had not even renamed the variables to match his own — my names sat there in the middle of his application, pointing at things that did not exist in it. He wasn’t building software. He was assembling it out of pieces he could not read. So the moment a piece didn’t fit, he was stuck. There was no understanding underneath to stand on.

Measure that developer against the nine qualities and he failed the most basic one. Not the subtle ones — not documented, not clean. He failed number eight. It didn’t even work. He could not get the thing to run.

That was the natural ceiling on the copy-paste developer, and for years it quietly protected us. His lack of understanding showed up immediately, as code that would not run. The defect was visible on day one.

AI clears the one bar he couldn’t

Now give that same developer an AI.

He types “I want such-and-such,” and out comes code that runs. Not a fragment that has to be joined to anything — a working answer, fitted to his request, usually on the first try. The one bar he could never clear on his own, the machine now clears for him most of the time.

Most of the time, and not always. I have watched an AI asked to fix one bug quietly break three things that were working, and hand the result back with exactly the same confidence it shows when it is right. It cannot tell the difference. So even quality number eight is not fully delegated: somebody still has to run the thing and look at it. Nothing here is automatic, not even the one quality the machine is supposed to have taken off your hands.

But let us be generous and grant the machine its best day. The code runs. So is the problem solved? The app works. Isn’t that the finish line?

It is not. It is not even close. “It works” was only ever one quality out of nine. The AI handed him that one and left the other eight exactly as unmet as before.

Look at what is still missing. Is the code beautiful and pleasant to read, or a dense screen he would close again the moment he opened it? Is it modular, with clean edges, or one tangled block that happened to compile? Is it clean — no dead code, no leftovers? Is it documented where it needs to be? Is it tested, so that it keeps working tomorrow? On every one of these the honest answer is: who knows? The machine optimized for the goal it was given — make it run — and ignored the other eight.

The copy-paste developer’s failure used to be loud: nothing ran. AI replaces it with a failure that is silent: everything runs, and the quality underneath is whatever it happened to be. The defect that used to appear on day one now waits for the day someone has to change the thing.

Who is the AI writing for?

Take the first question — is it readable? — because here my experience is not vague at all.

The first quality on the list was the most human one: code is written for a person, not for the machine. The machine only runs it. Another developer has to understand it. So: when an AI writes your code, who is it writing for?

Not the human. It writes for the compiler.

You see this most clearly in TypeScript. We write types for three reasons:

  1. The compiler catches our mistakes.
  2. The next reader sees the contract at a glance — what goes in, what comes out.
  3. The documentation writes itself. The contract is lifted straight out of the code into a generated reference, written once and never again by hand.

Only the first one is about the machine. Two of the three are about people. And those are the two the AI quietly drops.

It treats a type as a box to tick: whatever makes the type-checker stop complaining. So the types compile, and they are unpleasant to read. Here is the kind of thing you get — a return type spelled out inline, in place, at the top of the function:

export async function loadAccountSections(
  userId: string,
): Promise<
  Record<
    'profile' | 'email' | 'passkeys' | 'membership',
    | {
        status: 'ok';
        data: Partial<Omit<Awaited<ReturnType<typeof getAccountContext>>, 'user' | 'sections'>>;
      }
    | {
        status: 'error';
        error: string | { code: number; message: string } | null;
      }
  >
> {
  // ...
}

Awaited<ReturnType<typeof getAccountContext>> rather than a name. Omit and Partial stacked to carve out a shape it never bothered to define. An error that is a string, or an object, or null, depending on the day.

It compiles. It is even correct. But to learn what a single section actually holds, the reader has to open another file, mentally await a promise, subtract two fields, make everything that is left optional — and finish all of that before reaching the code they actually came for. Nothing has a name, so nothing can be referred to, reused, or documented.

The same contract, written for a person:

type SectionData = Partial<Omit<AccountContext, 'user' | 'sections'>>;

type SectionResult =
  | { status: 'ok'; data: SectionData }
  | { status: 'error'; error: SectionError };

type AccountSections = Record<SectionName, SectionResult>;

export async function loadAccountSections(userId: string): Promise<AccountSections> {
  // ...
}

Four names, and the signature now says what it does: give me a user, get back a result for each account section. The complexity has not disappeared — it has moved somewhere it can be read one piece at a time. And giving SectionError a name is the moment you notice that string | { code, message } | null was never a decision anyone made. It was three shapes that happened to accumulate.

The smaller sins run the same way. type and interface used interchangeably in the same file, as if the choice were a coin flip and not a convention. Annotations on values whose type is already obvious — const count: number = 0 — noise the reader must push through to reach the meaning.

And when the types get genuinely hard, watch what it reaches for. The double cast — value as unknown as Thing — which converts nothing and checks nothing. It only tells the compiler to stop asking. Or any, dropped in wherever the shape was inconvenient, which switches the type-checker off at exactly the point where it was about to become useful.

Now hold that against the three reasons. The cast tells the compiler to stop checking, so reason one is gone. A sprawling type, or an any, tells the next reader nothing about what the value really is — reason two, gone. And any is precisely what the documentation generator prints on the page, for everyone to read — reason three, gone. One lazy cast, and all three are defeated at once. The code keeps the syntax of TypeScript and throws away the point of it.

Satisfying the type-checker was never the goal. Explaining the code was — to the compiler, to the next human, and to the documentation that outlives both. The AI wrote for the audience it answers to, and that audience is a piece of software.

The same indifference reaches the look of the code. In the first post I made what sounds like a soft point: code can be beautiful or ugly, you feel which at a glance, and beauty is what invites you in to understand it. An AI feels none of that. It has no eye to please and no taste to offend. “Ugly” is not a category it can perceive. So unless you tell it otherwise, you get purely functional code — code that does the job and stops there, with no thought for whether a human would enjoy reading it, because no human was ever in the room.

This is quality number one — readability — failing in the quietest possible way. Nothing is broken. Everything runs. The code has simply stopped talking to you.

The style guide stops being paperwork

Quality number two was follow standards: the language’s own rules, and your team’s written style guide. In the first post I admitted this is the part everyone skips — the document nobody gets around to writing. With AI in the loop that changes completely, and it helps to see why we got away without it for so long.

A team of humans does not really need the document, because developers absorb style by reading each other. You join a project, you open the files, you see how the experienced people name things and shape a function, and you start doing it that way too — half the time without noticing that you decided anything. Style spreads through a team the way an accent spreads through a village. Give it a few good developers and a year, and the codebase has a house style that nobody ever wrote down and everybody follows.

An AI absorbs nothing on its own. It reads what you put in front of it, and infers nothing you did not. Yes, it can be pointed at your codebase, and the good tools will happily read it — but that is you handing it the material, not the machine going looking. Nothing is picked up by osmosis, nothing carries over from yesterday unless you carried it, and every file is written by a newcomer who has never met the others. So the unwritten guide — the one that lives in your developers’ hands — reaches it through no channel at all, and the code drifts into having no style: not a bad style, simply none, different in every file because nothing was ever holding it together. What a team used to pass on by example now has to be passed on in writing, because writing is the only channel the machine has.

And that is the happy part: a style guide is text, and an AI reads text. Hand it the document, tell it to obey, and it will. The relationship is nearly linear — the better and more specific your guide, the better the code that comes out. You are no longer hoping the model shares your taste; you are giving it your taste, in writing, and holding the output to it. Give it nothing, and you get the machine’s defaults: single-letter names that mean nothing to the next reader, dense expressions crammed in where a named value belonged, whatever it saw most often. Not because it chose badly, but because we never told it what we accept.

So the rule is blunt: no style guide, no quality. And I mean that as written. Without a guide you can still get code that is good — the first post’s word, and its lower bar: it runs, it reads well enough, it would pass. What you will not get is quality, which was never one lucky property but all nine of them held together at once. Good is what you get by luck. Quality is what you get by asking — and asking, for a machine, means writing it down.

Modularity: the hard one

The first two qualities can be seen in one place. Readability lives in a line. A standard lives in a file. You can spot both where they sit, fix them there, and a style guide steers them well.

Quality number three — modular — cannot be seen in one place at all. It is the shape of the whole application: how you split it into pieces, where the borders run, which piece is allowed to talk to which. That is the hardest thing to hand to a machine, because no single line shows it. It exists only in how all the lines are arranged.

Left alone, the AI will split your application into modules its own way. It is not reasoning about your problem or your borders. It is repeating the average of all the code it was trained on — a huge pile of public repositories, the good and the bad together. So the “architecture” you get back is that average, and the average codebase on the internet is not one you would want to inherit.

That leaves you hoping. Hoping it split things along meaningful lines and not convenient ones. Hoping each piece got one responsibility instead of five. Hoping it respected the rules we named in the first post: clear public APIs, sensible naming, and above all unidirectional data flow — data down, meaningful events up, nothing reaching sideways. An AI has no loyalty to any of that. It will wire a child straight into a sibling if that is what the nearest example did. The result will compile, and run, and look fine in a demo, and quietly become the spaghetti we spent the whole first post learning to fear.

This is the quality you cannot delegate by hoping. A guide can pin down what happens inside a line or a file. The architecture you have to decide yourself and impose: name the layers, draw the borders, say which direction data may flow, and hold every generated piece against that plan. The machine can lay bricks beautifully once you have drawn the wall. It will not draw the wall for you — and drawing it is exactly where the rest of this series is headed.

Bug-free, or just untested?

Quality four was bug-free. Quality nine was tested. In the first post I said that testing is what turns bug-free from a hope into a fact you can check. These two have to be talked about together here, because the machine hands you a convincing imitation of bug-free — and quietly skips the tests that would have exposed it.

Here is the counterfeit. The code runs, so it looks bug-free. But “it runs” only means it ran down the happy path, once, on the input you happened to try. It says nothing about tomorrow. Were the edge cases considered? The empty list, the missing field, the number where a name should be, the user who pastes a whole novel into a field built for one word? Did anyone think about what happens when the network dies halfway through? An AI writes the path you asked for, and the path you asked for is almost always the cheerful one. The awkward inputs — the ones real users supply by the thousand — were not in your prompt, so they are not in the code.

And the proof that would settle it, the test suite, the machine rarely writes unless you ask. Even when you do, tests an AI writes for its own code tend to be circular: they confirm that the code does what it does, not that it does what it should. So you are left with the most seductive illusion of all — code that works perfectly in the one moment you look at it, and has no defense at all for the moments you don’t.

Asking is not the end of it either: you still have to say which kind of tests — another decision the machine will not make for you. Testing is not one thing. Do you want fast unit tests only, or integration tests as well, or full end-to-end tests that drive the real application in a browser? Should a coverage tool be wired up and held to a minimum, and run in CI so that a drop blocks the merge? Ask an AI for “tests” and you will get some tests, of whatever kind it saw most often. Which kinds, how many, and enforced where: that is yours to decide, exactly like the architecture.

This is “bug-free” as an appearance, without “tested” as the proof. And an appearance is exactly what a bug hides behind. Untested AI code is not bug-free. It is a parachute nobody has ever opened: perfectly convincing, right up to the day you need it to hold.

What’s left to do?

Quality five was finished. Not the hand-waving “code is never finished,” but a question anyone can ask of a given version: what is left to do here? When the honest answer is “nothing,” it is done. Versions are how you draw that line again and again.

There is a tempting dream in the AI age. Write the whole plan up front, every feature sorted by version, hand it to the machine, say “build 2.1,” and watch it fall into place. And if you can write that plan in advance, you are far ahead; the discipline of writing it is worth it on its own. But the dream oversells the first pass. A plan names the headline features. It cannot capture every small requirement that only becomes visible once you are inside the feature, looking at it. Those you supply in the moment, as the real prompt, when the implementation actually starts.

And here is the catch that keeps “finished” a human’s job: the AI’s first pass will look finished. It runs, the feature is on the screen, the machine declares victory — and it has quietly skipped the requirements nobody wrote down.

A small example from my own work. I built a modal form with AI. First pass: click the backdrop, the modal closes. Textbook behavior. Except that whatever you had typed into the form was simply gone, discarded without a word. The machine had implemented “close on backdrop click” the way the pattern usually goes, and never paused to consider that a half-filled form is precious. So I came back and spelled out the missing half: close on backdrop click only if the form is not dirty; otherwise keep it open. You could fill a notebook with examples of this shape: the obvious behavior is there, and the exception any human would have thought of is not.

// First pass: textbook "close on backdrop click" — and your half-filled form vanishes.
function handleBackdropClick() {
  closeModal();
}

// The caveat a human supplies: a dirty form is precious, so guard the close.
function handleBackdropClick() {
  if (isDirty) return; // keep the modal open; don't discard unsaved input
  closeModal();
}

So what is left to do? stays a question only a person can answer, because only a person holds the picture of what done means for this feature. The AI will happily tell you it is finished. Measuring that claim against the scope you intended, and catching the silent omissions, is yours every single time.

Clean? It doesn’t tidy up after itself

Quality six was clean: no dead code, no commented-out blocks kept “just in case,” no debug scaffolding left behind in production. Git already remembers the old version, so you can delete without fear. Left to itself, the AI deletes almost nothing.

The reason is in how it works. A human treats the file as a finished thing and tidies it toward that state. The AI works turn by turn, and almost always by adding. Tell it “no, do it the other way,” and where you would have deleted the first attempt, it keeps both: the new path written fresh, the old helper still sitting there and called by nobody, an unused import at the top of the file. Iterate a few times and the file fills up with the remains of approaches you abandoned — the old version parked in a comment block “for reference” right above the one that replaced it, console.log lines from a debugging session that ended hours ago, and comments addressed to the wrong reader (// As requested, now handling the empty case — talking to you, who typed the prompt, not to the developer who reads this cold and has no idea what was requested, or by whom).

The machine cannot feel the mess any more than it can feel the ugliness. An untidy room does not bother something that was never going to live in it.

Documented, but never the why

Quality seven was documented, with one sharp rule: capture the why, not the obvious what. Good names already say what the code does, and a comment that only repeats them is noise. Spend your words on what the code cannot say for itself: the intent, the trade-off, the “we do it this odd way because of bug X.”

The AI gets this backwards. Left alone it does write comments — but it comments the what. Line by line it narrates the obvious, saying again in English what the code already says out loud. What it rarely offers is the why: the reason this path was chosen over the cleaner-looking one, the bug that explains the workaround. That is exactly the documentation worth having, and exactly the part the machine cannot supply, because the why lived in your head and in your prompt, never in the code it was looking at. If you want it written down, you have to say it.

It also never gets the amount right on its own — it is either too sparse or far too much. A colleague once reviewed some code of mine that was mostly AI-generated and only fine-tuned by me, and his single request was: fewer comments, please. Too many words spent describing what the code plainly did. He was right. The machine had been generous in exactly the place where generosity hurts.

Then there is the reference documentation — the doc-comments on a function’s signature that, in the first post, I said tooling can turn into a browsable website automatically. That word sounds effortless, but it stands for two jobs, and the machine does neither of them unless you ask.

The first job is the comments themselves. If you want a function’s public API documented — its parameters, its return, the contract a caller relies on — you have to ask for it. The second is the tool that reads them and builds the site, which does not set itself up. You choose it, you decide how the output should look, and above all you decide who it is for: reference docs for the teammates who maintain this code are a different document from a public site for outside users, and the machine cannot guess which one you meant.

So the human supplies the part that counts: the why, the right amount, the documentation of the public surface, and the pipeline that publishes it. Filling a file with comments is easy. Filling it with the useful ones is still the job.

The day someone has to change it

A version can be finished; the software goes on. That is what versions are for, and it means every real application gets maintained. A bug appears, a requirement changes, a feature has to grow. And maintenance is the moment when understanding stops being optional and becomes the whole job.

Here the copy-paste developer is in worse trouble than before, not better. The old version of him at least knew he was stuck: the code would not run, so he came back and asked. The new version shipped something that works, took the win, and walked away owning a system he cannot read. He cannot fix it, because he never understood it. He cannot safely extend it, because he cannot see what he would break. He is holding working code that he has no way to maintain — and that is a debt nobody is able to pay.

Working code you cannot maintain is not an asset. It is a liability that looks like one.

It was never about who writes it

So here is the point I most want you to take from this post.

Quality is a property of the code, not of its author. The nine qualities describe the artifact — how it reads, how it is shaped, whether it is understood — and the artifact does not care whose fingers, or whose model, produced it. A beautiful, modular, well-understood module is exactly that whether you typed it at 2 a.m. or an AI generated it in a second. An unreadable tangle is exactly that on the same terms. The author drops out of the question. What remains is only this: does this code have the qualities, or does it not?

That is why “an AI wrote it” answers nothing. It tells you who held the pen. It tells you nothing about whether the result is any good — and “it runs” tells you only that one box of nine is ticked.

And notice the two ends that never change, no matter how much of the middle a machine fills in. A human asked for this software: wanted it, specified it, will be judged on it. A human will use the result: depend on it, live with it. The code is the bridge between those two people. The machine can build that bridge faster than you ever could. But if no human understands it, no human can keep it standing — and you do not discover that while you are crossing. You discover it on the day it has to carry a load it was not built for.

So the bar has not moved. What has moved is where you stand: your work goes from producing the code to specifying it and judging it against those same nine qualities. The spec you give is where they are ordered. The review you do is where they are enforced. Accept “it works” and walk away, and you are the copy-paste developer with a faster tool and a bigger fall still ahead of you.

None of this makes hands-on skill obsolete — the opposite. Knowing how to write each quality in by hand is exactly what lets you see when it is missing from what the machine hands back, and tell a solid answer from a confident wrong one. You cannot judge a quality you were never able to produce.

That is the whole argument, and it is why the ideas had to come first. From here the series gets more concrete — the same nine qualities, but down in the machinery of actual frameworks, where you can see how each one is built in by hand, and what to look for when something else builds it for you.

Next time: the same application plan, handed to the same AI twice — once with no guidance at all, once with a guide to follow — and both results measured against these nine qualities.

See you there.

References

saki
Follow me:
Latest posts by saki (see all)

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Enter your username and password to log into your account. Don't have an account? Sign up.

Want to collaborate on an upcoming project?