Overpowered by Patterns
Much has been made of the ability of LLMs to write simple applications from scratch. Less has been written about how well LLMs can maintain an existing project. Working with an existing codebase exposes something that is both a strength and a weakness of an LLM: following patterns. The least wrinkle in a codebase can cause an LLM to err through pattern-following, overriding both its training and its reasoning. An existing pattern can throw a powerful model such as Claude Opus 4.8 off track even on something as simple as a function to parse an integer.
The problem is not the inability of the model to “reason” (at least on an artificial level) for problem solving. The issue is the model's inability to step back and consider what it all means. Pull out any recent LLM, however feeble, and prompt it with the following:
JavaScript's
parseInt()function is too lenient because it accepts trailing invalid characters. Write a more robust but simple integer parsing function in TypeScript that does not have such a defect.
In essence we want to write in TypeScript a better version of JavaScript's parseInt() function that doesn't allow characters that aren't part of an integer literal form. GPT 5.4 nano — an old, weak model — puts out something reasonable, although it would be better not to accept whitespace:
export function parseIntStrict(input: string): number { const s = input.trim(); // Optional +/-, then one or more digits, and nothing else. if (!/^[+-]?\d+$/.test(s)) { throw new Error(`Invalid integer: "${input}"`); } const n = Number(s); // (Optional but usually wise) prevent unsafe integer values. if (!Number.isSafeInteger(n)) { throw new Error(`Integer out of safe range: "${input}"`); } return n; }
But watch what happens in an existing codebase when existing patterns make the model deviate. During development of a TypeScript XML parser for importing a Jira Cloud backup for the upcoming Jekt™ VS Code extension, the codebase contained a utility function to make sure that any Jira issue ID in an XML attribute is a positive integer:
export function requirePositiveInteger(value: number, makeError: () => Error): number {
// TODO: extract to a shared TypeScript utility library once one exists.
if (!Number.isSafeInteger(value) || value <= 0) {
throw makeError();
}
return value;
}
The actual parsing logic looks something like this from a high level (although this logic is spread across several classes):
// 1. Read from the open attribute bag.
const raw = attributes["id"];
// 2. Require presence, or fail naming the element and attribute.
if (raw === undefined) {
throw new MissingAttributeError("Issue", "id");
}
// 3. Parse text → number. This is the still-lenient step: "20052abc" → 20052, "1.9" → 1, " 52 " → 52.
const value = Number.parseInt(raw, 10);
// 4. Validate it is a positive safe integer.
return requirePositiveInteger(value, () => new InvalidJiraIssueIdError(raw));
Nothing out of the ordinary here. What we want to do is replace Number.parseInt(raw, 10) with a better parsing function. But when I asked Claude Opus 4.8 how to fix this lenient parsing, the model proposed a requirePositiveIntegerText() function that conflated parsing the lexical form with validating the numeric range!
If you want it tightened, the clean fix is centralized: a strict-decimal variant — reject text not matching
/^\d+$/before parsing (or fold that into a singlerequirePositiveIntegerTexthelper the fourparsefunctions share) — which would also let theparsecontract state "rejects non-decimal text" explicitly./** Requires that decimal text denotes a positive safe integer, returning that integer. */ export function requirePositiveIntegerText(text: string, makeError: () => Error): number { if (!/^\d+$/.test(text)) { // strict text guard bolted on... throw makeError(); } return requirePositiveInteger(Number.parseInt(text, 10), makeError); // ...then the value check }
But it gets worse. We already had an integer.ts utility module where a new parseIntStrict() function could have gone. I tried to coax the model along a better design path:
This
requirePositiveIntegerText()thing is weird. If the integer parsing function has a defect, why don't you create a "better parsing function" that doesn't have the deficiencies the built-in one does? This new function you propose would be doing parsing, and it doesn't even have "parse" in the name. Isn't it mixing up parsing and a check function??
Claude readily agreed with my reasoning. So did it propose a parseIntStrict() function? No, it proposed a parsePositiveInteger() function!
/** Parses canonical decimal text (no sign, leading zeros, point, exponent, or surrounding space) as a positive safe integer. */ export function parsePositiveInteger(text: string, makeError: () => Error): number { if (!/^[1-9]\d*$/.test(text)) { // Number.parseInt is too lenient; accept only canonical positive-integer text throw makeError(); } return requirePositiveInteger(Number(text), makeError); // value-range judgment stays in one place }
The model's first error was that, in seeing that the overall goal was to parse an integer and then validate that it was positive, it followed the requirePositiveInteger() pattern to fold parsing and validation into one function instead of surgically fixing the “parse integer” part. The model's second error again was to follow the requirePositiveInteger() pattern and create a highly specific parsing function only for positive integers! Claude Opus could not step back and reason: if the validation already requires a positive integer, it would be best to make a general integer parsing function; instead the fact that a requirePositiveInteger() function even existed in the codebase made Claude deviate from the simple strict integer parsing function that even GPT 5.4 nano could handle.
The takeaway is that no matter how well a modern model “reasons”, and no matter how well it seems to produce useful things from scratch, the slightest pattern can make the model deviate when it is left unchecked to add to an existing codebase. The result is a ballooning of cruft that itself serves as a pattern for future sprawl, making the codebase increasingly harder to maintain.
What can be done about this? To some extent, this pattern-following is inherent to how LLMs work. If LLMs did not follow patterns, they wouldn't work at all! It's likely the solution lies not in better training or better instructions, but more detailed and directed review. (See the earlier article, LLMs and the Power of Review.) We'll thus revisit this issue when discussing harnesses, factories, and architecture review processes in upcoming articles.