Lindsay Edwards

Keep a living gotchas file

I lost an afternoon to a bug that compiled perfectly. The type system was happy, the build was green, and the thing still did the wrong thing at runtime. That is the worst kind, because none of your tools warn you.

After the second one like it, I started a plain text file in the repo called gotchas. Not documentation, not a wiki. A short list of the traps that compile fine and only reveal themselves later, in a test or in someone else’s pull request.

It is one of the cheapest things I have ever done, and it keeps paying.

Traps that compile and then bite#

Here are a few real entries, generalised. None of these produce a compiler error. All of them cost someone time.

A base record that must not be sealed. Sealing it compiles happily, right up until another type tries to derive from it and the whole thing falls over somewhere else. The seal is local, the damage is remote.

A generic repository whose Update and Remove methods are synchronous and return void. They do not persist anything on their own. Persistence is deferred to an explicit unit of work calling SaveChanges. Reach for an async variant that looks like it should be there and you get nothing, because it does not exist. In a running app that is a user hitting save, seeing it succeed, and coming back later to find the change was never written.

// this does NOT hit the database
repo.Update(entity);
repo.Remove(other);
// nothing is saved until this runs
await unitOfWork.SaveChangesAsync();

A package-audit setting that fails the build on false positives. It flagged advisories that did not apply and turned every build red until we disabled it. The fix was one line of config, but only if you know the setting exists.

A schema coercion helper that fights a form library’s resolver types. The coercion tried to turn a string into a number inside the schema, and the form resolver could not reconcile the types. The build error pointed nowhere useful.

The meta-lessons hiding in the list#

The file is useful on its own, but after a while patterns emerge from it. The individual gotchas start pointing at two habits worth adopting on purpose.

The first is to prefer synchronous repositories that defer to an explicit unit of work. When Update and Remove do not touch the database, and a single SaveChanges commits the batch, you always know where persistence happens. There is exactly one line that writes. The async-looking method that silently does nothing simply cannot exist to trip you up.

The second is to keep validation and coercion out of any schema type a form resolver also consumes. For that string-to-number fight, the fix was to validate the field as a string and do the parse in the submit handler instead.

// keep the schema as a plain string the resolver is happy with
const schema = z.object({ amount: z.string() });
// parse where you actually use the value
function onSubmit(values: FormValues) {
const amount = Number(values.amount);
}

Validate as a string, parse where you use it, and the resolver never sees a type it cannot handle.

A living gotchas file is where your worst afternoons go to become a five-second read for the next person, including future you.

The bugs that hurt most are the ones your compiler blesses. A short, living gotchas file in the repo catches them the second time, which is the time that matters, because there is always a second time.

Keep it plain, keep it in the repo, and add to it the moment something costs you an hour. The two habits I pulled out of mine were to prefer sync repositories that defer to a unit of work, and to keep coercion out of schema types a form resolver reads. Yours will grow different lessons. Write them down anyway.

Keep reading