Mike Cann's avatar
Mike Cann
3 months ago

3 Rules for De-Slopping AI-Generated TypeScript Code

Hi, I'm Mike, and I'm an AI-aholic. My last big feature, the AI files one I made a whole video about, turned into total slop.

I got so carried away with how fast AI let me build that I never stopped to ask what I was building. I understood the feature at a high level, sure, but ask me about a specific part of the code and I had nothing. Not a comfortable spot, especially for a feature sitting on the critical path for real users.

So I went back in for a serious code cleanup. Here's what I found, what I did about it, and three things you can do today if you're staring at your own pile of AI-generated slop.

What AI files actually does

If you haven't seen the earlier videos: AI files, which shipped in Convex 1.34.0, is part of Convex's broader push on AI coding. When you set up a project, it offers to drop in a set of guidelines and agent skills that help LLMs write better Convex code. There's also a CLI to update those files, remove them, or run a handful of other maintenance tasks. Simple enough as a pitch.

The implementation wasn't. The CLI has to handle a real tangle of paths: first-time convex dev versus later runs, and invocation from the AI files CLI directly versus from the convex dev flow. Then it branches again on who's calling, whether that's a human, an agent, a CI run, or some other non-interactive prompt. That's a lot of conditional logic converging on one entry point, and most of it lived in a single 1,000-line index file. I find code hard to reason about once it looks like that, not because any one line is complicated, but because there's no obvious place to start reading.

Step one: split before you refactor

The first move had nothing to do with rewriting. I broke the monolith into files organized by responsibility: one for agents.md, one for claude.md, one for guideline skills, and so on. I didn't do this by hand. I switched to a smaller, faster model and queued up a series of explicit instructions: split this section off here, that one there. I could probably have handed the model a single high-level goal like "organize this into sensible modules" and gotten a workable result. I did it piece by piece instead, because the overhead was the whole point. Going part by part forced me to look at every corner of the codebase and build a mental map of what existed and where. That map, not the tidier files, was the real payoff, and it was worth the manual effort even though a single prompt could have done the split.

Split Before You RefactorSplit Before You Refactor

Untangling a single function

With the map in hand, I turned to the actual logic, starting with one particularly gnarly function. If it made you cross-eyed, you're in good company. That was my reaction the first time I saw it too. Length was the least of its problems. It was doing several unrelated things at once, with nesting and error handling folded in so deep I couldn't tell where one responsibility ended and the next began.

When I hit a function like this, the first question I ask is: what is this actually for? There was a comment at the top: write or update all the Convex AI files, guidelines, agents.md, claude.md, skills. A quick read of the body said the comment wasn't lying, which isn't a given with old comments. From there, the obvious move was to extract each piece into its own sub-function. That alone turned an unreadable block into a short, ordered list of steps, each one named for what it does. It's a small change with a disproportionate payoff. You can understand what the function does at a glance, and change one part without holding the whole thing in your head.

One function worth calling out is readAIConfigOrDefault. The name tells you what it does before you read a line of the body. It calls attemptReadAIState, and the attempt prefix is doing real work. It signals that the call can fail, and that failures are caught inside attemptReadAIState rather than left to blow up the caller. readAIConfigOrDefault handles whatever comes back, success or failure, and decides what "or default" means in that case. Roughly, the pattern looks like this:

1// attemptReadAIState never throws, it captures failure in the return value
2async function attemptReadAIState(path: string): Promise<AIState | AIStateError> {
3  try {
4    return await readAIState(path);
5  } catch (err) {
6    return { kind: "error", reason: toErrorReason(err) };
7  }
8}
9
10// readAIConfigOrDefault decides what "or default" means for each outcome
11async function readAIConfigOrDefault(path: string): Promise<AIConfig> {
12  const result = await attemptReadAIState(path);
13  if (result.kind === "error") {
14    return defaultAIConfig();
15  }
16  return toAIConfig(result);
17}
18

That split, between what a function does and what its caller does with the result, is one of the biggest things separating readable code from slop.

Three code cleanup habits you can start today

1. Break large functions down

This is the cheapest fix available, and it's almost always worth doing first. Select a chunk of a large function and tell the model to extract it into its own self-contained function. Be explicit, in the prompt or in agents.md, that it needs a descriptive name. Left to its own devices, a model will happily pull out a block of code and call it helper or doStuff. The extraction only pays off if the name documents what the function is for.

2. Segregate query from command

AI-written code (and plenty of human-written code, if we're honest) has a habit of quietly combining a check with an action. Before the refactor, one function was called checkAIFilesStaleness. The name promises a check. The body also logged a message if the files turned out to be stale:

1// Before: the check and the side effect are tangled together
2function checkAIFilesStaleness(files: AIFiles): void {
3  if (isOutOfDate(files)) {
4    console.warn(`AI files are stale: ${files.path}`);
5  }
6}
7

That's two responsibilities wearing one name, and the honest fix was either renaming it to checkAIFilesStalenessAndLogIfStale, or, better, splitting it outright. I went with the split. determineAIFilesStaleness now does only the check, returning a union of literal staleness states, and the caller handles each state explicitly:

1// After: the query returns a fact, the caller decides what to do with it
2type AIFilesStaleness = "upToDate" | "stale" | "missing";
3
4function determineAIFilesStaleness(files: AIFiles): AIFilesStaleness {
5  if (!files.exists) return "missing";
6  return isOutOfDate(files) ? "stale" : "upToDate";
7}
8
9function handleAIFilesStaleness(staleness: AIFilesStaleness): void {
10  switch (staleness) {
11    case "upToDate":
12      return;
13    case "stale":
14      console.warn("AI files are stale, run npx convex ai-files update");
15      return;
16    case "missing":
17      console.warn("No AI files found, run npx convex ai-files add");
18      return;
19    default:
20      staleness satisfies never;
21  }
22}
23

I added the exhaustive check at the end of handleAIFilesStaleness. If a new staleness state is ever introduced, TypeScript's compiler flags every call site that doesn't handle it, so the gap gets caught during a build instead of by a bug report. (Convex's own guide to switch statements covers this exact pattern if you haven't seen it.) That combination of a pure query function plus an exhaustive switch at the boundary makes the control flow legible in a way the original function never was.

3. Name things for what they actually do

This is the throughline of the first two. A function's name is the fastest documentation you'll ever write, and it's read by humans and by the AI agents you'll eventually ask to modify the code. If the name is wrong, if it says "check" when the function also logs, or "read" when the function also mutates state, you're setting up the next person, human or model, to misunderstand the code before they've read a single line of it. If you want more patterns in the same vein, Readable TypeScript code: 14 patterns for humans and AI has a longer list.

Can AI fix the AI slop it made?

Mostly, yes. I turned what I learned from this pass into a Convex de-slop skill you can run against your own codebase for the same kind of code cleanup. I've run it a good number of times now, and it holds up. If you try it, I'd love to know how it goes, and if you spot something it should handle better, open an issue or a PR. I'll probably add more skills like it over time.

For now, that's where I'm leaving this one. If you want another example of Convex being upfront about a design trade-off, Why doesn't Convex have SELECT or COUNT? is worth a read. Thanks for reading. Until next time.

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