← Back to Blog

Make It a Compile Error: Teaching the Compiler to Police the AI

Paul Allington 3 July 2026 8 min read

There is a particular kind of bug that doesn't announce itself. It doesn't throw, it doesn't log, it doesn't turn anything red. A property that should have a value just quietly has null instead, and everything carries on as if nothing happened. You find it three weeks later when a customer asks why their phone number disappeared. Nobody wrote a test for it because nobody knew there was anything to test. That's the kind of bug I went hunting for on a large multi-tenant SaaS I work on, and the way I caught it was by teaching the compiler to do my worrying for me.

The job sounded boring. Rip out a reflection-based object-mapper - the kind with CreateMap definitions and _mapper.Map() calls scattered everywhere - and replace it with plain, hand-written mapping code. Dozens of files. For performance, and for the sake of anyone who'd ever have to debug it. I'll be honest with you, I expected this to be the most tedious thing I'd asked an AI to do all year. It turned out to be one of the most interesting, and not for the reason I thought.

Why anyone would hand-write mapping in the first place

Reflection-based mappers are seductive. You declare that OrderDto maps to OrderViewModel, the library figures out the property names at runtime, and you never write the boring dest.Name = src.Name lines. It feels clever. It also means a chunk of your application's behaviour is decided by string-matching inside a library at runtime, which is brilliant right up until you want to know why a value is wrong, or you want it to be fast, or you want to read the code and actually understand what maps to what.

So the plan was to go the other way entirely. Explicit, hand-written assignments. Dull, readable, fast, debuggable. The whole point of hand-written mapping is that there's no magic left - every property that gets a value gets it because a human (or an AI) typed a line that says so.

Which is also exactly where it falls apart.

The failure mode nobody writes about

Here's the thing though. The danger with hand-rolled mapping isn't the line you get wrong. A wrong line is easy - it shows up, something looks off, you fix it. The danger is the line you never write at all.

Picture it. Six months from now, someone adds a new property to a view model. Maybe me, maybe another developer, maybe Claude on some future task. They're focused on the feature they're building. They add the property, they wire up the bit they care about, and they completely forget that somewhere in a mapping method this new property needs an assignment too. There's no compiler complaint. There's no test failure. The property just quietly maps to null forever. Nobody writes about this part, because by the time it bites you it looks like a data problem, not a code problem.

With a reflection-based mapper you at least got accidental coverage - it'd name-match the new property for you, for better or worse. Strip that away and you've traded runtime magic for a permanent, silent trap. I could feel myself about to make the codebase more fragile in the name of making it cleaner.

The instruction that changed everything

So I stopped and gave the AI a different brief. Not "convert the mappers." Instead: make it impossible for devs or Claude to make a mistake - if a property isn't mapped or ignored, it should compile-error until resolved.

That's a different request entirely. I wasn't asking for code anymore. I was asking for a rule that the codebase itself would enforce, on every developer, on every AI, forever, without anyone having to remember it. The human is meant to be the product manager here - I set the constraint, the AI works out how to build it. And what it came back with was genuinely the right answer.

Instead of trusting humans to remember the rule, and instead of trusting the AI to remember it either, it built a Roslyn DiagnosticAnalyzer. A separate project, referenced as an analyzer in the main one, that inspects the code as it compiles. It defined custom diagnostics - MAP001, MAP002 - and a small set of attributes to go with them: [ExhaustiveMap] to say "this mapping must account for every destination property", [MapIgnore] to deliberately exclude one, and [MapIgnoreInheritedFrom] for the inherited-property cases.

The deal is simple. Mark a mapping [ExhaustiveMap] and from then on every destination property must be either explicitly mapped or explicitly ignored. Miss one, and it isn't a warning you can scroll past. It's a build error. The project does not compile. You cannot ship it, you cannot merge it, you cannot pretend you'll deal with it later.

Build the cage before you let anything into it

The order of work mattered more than I expected, and it's the part I'd push hardest on if you ever do this yourself.

It shipped in phases. First, build the analyzer - just the analyzer, nothing else. Then prove it on a single mapping, one file, and confirm that removing an assignment genuinely turned the build red and adding it back turned it green. Only then, with the safety net demonstrably working, did the mass conversion of dozens of files begin.

This is the bit I'd have got wrong if I'd been doing it by hand: I'd have converted the easy files first to feel productive, and built the guardrail last when I was tired. Which is precisely backwards. You build the thing that catches mistakes before you start making mistakes at scale. The analyzer wasn't the chore standing between me and the real work. The analyzer was the real work. Everything after it was mechanical.

Two bugs that were already there

Here's the payoff, and it landed almost immediately. The moment the analyzer was switched on and pointed at real mappings, it caught two genuine pre-existing bugs. Not bugs the conversion introduced - bugs that had been quietly living in the codebase under the old reflection-based mapper.

The first: lists that a controller was populating, that the old mapper had been silently null-mapping the whole time. The controller did its work, the data was there, and then the mapping threw it on the floor without a word. The second: properties being mapped from sibling classes that weren't actually on the destination at all - assignments that meant nothing, pointing at fields that didn't exist where everyone assumed they did.

Both had been invisible. Both had presumably been wrong in production for who knows how long. And neither was something I'd asked anyone to look for. The compiler found them as a side effect of being told the rule, because once you force every property to be accounted for, the gaps have nowhere to hide. That's the difference between a guardrail and a code review - a guardrail doesn't get tired and it doesn't assume the previous developer knew what they were doing.

The part where the AI got humble

Now for the honest beat, because this wasn't all clean triumph. Partway through the mass conversion, the AI's own estimate of the work ballooned. It had a look at the actual view models and found they weren't the tidy little objects I'd pictured. Some had 100 settable properties. Some had 146. Once you do the arithmetic across dozens of files, you're looking at somewhere between 1,500 and 2,500 lines of mechanical, explicit assignment.

Ouch. Also fair. That's the real shape of the codebase, not the shape I'd assumed, and I'd rather the AI told me the number was bigger than quietly cut corners to hit a tidier one.

What I liked most was what it did with that knowledge. It flagged the riskiest surfaces - the view models holding people's contact details - and proposed doing those last, with a human pass over them rather than a confident machine sprint. An analyzer guarantees every property is dealt with, but it can't tell you whether you mapped the right thing to the right place - and on the data that actually matters, that distinction is everything. The compiler can prove completeness. It cannot prove correctness. The AI knew the difference and sequenced the work around it, which is more judgement than I expected and exactly the judgement I'd want.

What I'd actually take away from this

The lesson here is not really about object-mappers, or Roslyn, or even about AI specifically. It's about where you put your trust.

The instinct, when you're worried someone might forget a rule, is to write the rule down. Put it in the coding standards. Put it in the pull request template. Put it in the AI's instructions. And all of that is fine, right up until the one Tuesday afternoon someone's rushing and doesn't read it. Documentation is a request. Don't rely on discipline - human or AI - to remember a rule. Encode the rule as a compiler error. A custom analyzer turns "please don't forget" into "the build won't go green until you've dealt with this," and those are not the same sentence at all.

This matters more, not less, now that an AI is writing large chunks of the code. We spend a lot of energy worrying about whether the AI will do the right thing. The better question is whether the environment makes the wrong thing impossible. An AI can write a beautifully confident explanation of why its mapping is complete. The compiler will not read it, will not be charmed by it, and will simply refuse to build if a property is unaccounted for. The best guardrail for AI-written code is one the AI itself cannot talk its way past.

So if you're handing big mechanical jobs to an AI - and you should, they're brilliant at them - spend the first hour building the thing that catches the mistakes rather than the thing that makes the change. Make the rule structural, not aspirational. Then let it loose. The boring, repetitive work is exactly what these tools are best at, but only once you've built a cage that the work can't quietly escape from. The compiler is not impressed by confident explanations, and on the jobs that matter, that's precisely the colleague you want.

Want to talk?

If you're on a similar AI journey or want to discuss what I've learned, get in touch.

Get In Touch

Ready To Get To Work?

I'm ready to get stuck in whenever you are...it all starts with an email

...oh, and tea!

paul@thecodeguy.co.uk