There is a habit I have for other people's libraries that I do not have for my own code: before I call something, I read what it returns.
With my own functions I skip that, because I wrote them, so I know. Three times in three days that turned out to be false, and the third time I caught it before it cost anything only because I had started treating my own modules like somebody else's.
The version I had already been burned by twice
I maintain qbofile, a set of browser-based converters between the file formats accounting software uses. It is a small codebase: a parser per input format, a generator per output format, and pages that wire one to the other.
Wiring a new pair felt like plumbing, so I estimated it like plumbing. Two new pages, both reusing an existing parser and an existing generator: no new code. I said that out loud before opening either end.
The generator had no column for the thing the parser produced. The parser could read the category a user had assigned to each transaction; the CSV generator emitted six fixed columns and category was not one of them. Not a bug — it had simply never needed one, because the format it was originally written for does not carry categories.
That is a strange kind of wrong. Nothing was broken. The code did exactly what it always had. My model of it was built from the function name.
The same evening, in the same pair of modules, the second one:
L.push(`P${sanitizeText(tx.description)}`);
P is the payee field in that output format. M is the memo. Two fields, and upstream, description was defined as memo || payee. So for any transaction that had a memo, the memo took the payee slot and the actual payee was dropped. Silently — the file is valid, it imports fine, and the missing name never announces itself.
The two minutes that caught the third one
After the second one I wrote down a rule and did not really believe I needed it: before wiring two components together, open both ends and read what actually crosses.
Two days later I was writing a parser for another old format, and it needed to work out two things that the format does not state: whether dates are day-first or month-first, and whether a dot or a comma is the decimal mark. Both of those I had already solved elsewhere in the codebase. Reusing them was obviously right.
So I opened them. Not the call sites — the functions.
// what I was about to write
const fmt = detectDateFormat(dates);
if (fmt.ambiguous) warn(...);
const sep = detectDecimalSeparator(amounts);
parseAmountCents(raw, sep);
// what the functions actually do
export function detectDateFormat(values) {
...
if (winners.length === 0) return null; // ← returns null, does not throw
return { format, ambiguous, rival, coverage };
}
export function detectDecimalSeparator(values) {
...
return { sep: '.', ambiguous: true, evidence: null, samples }; // ← an object, not a character
}
Two crashes, neither of them written yet. fmt.ambiguous on a null, and an object handed to a function expecting '.' or ','.
Both were mine. Both were about two weeks old. I could have told you what they were for without hesitating, and I would have been right — and I still had the return shape wrong on both.
Why "I wrote it" is the reason, not the excuse
For a library I have never seen, the first thing I do is find out what comes back. For my own module I skip that step, and the thing I skip it in favour of is the function's name.
A name is a summary written before the function was finished. detectDecimalSeparator sounds like it returns a separator. It returns a separator plus the evidence for it plus whether the evidence was conclusive — because when I wrote it, the interesting part was that sometimes the file cannot tell you, and callers need to say so. That is a better function than the name suggests. The name just never got updated to admit it.
So the rule is not really "read your own code more carefully". It is narrower:
A function you have not opened in a few weeks is a dependency. Give it the courtesy you give a stranger's package: look at what it returns before you use it.
What types would and would not have caught
I write this project in plain JavaScript with JSDoc, so the obvious response is that a type checker catches all of this. It catches some of it.
-
detectDecimalSeparatorreturning an object where I expected a string — caught, instantly, that is exactly what type checking is for. -
detectDateFormatreturningnull— caught, but only if I had annotated the return as nullable. The annotation is a claim I make by hand, and it is the same claim I got wrong in my head. - The generator with no category column — not caught by anything. Both sides were
string[]. The type was right and the content was missing. No checker knows that a column namedCategoryshould exist because some other module can produce categories.
That last one is the one that cost the most time, and it is the one no tooling was going to hand me. Which is why the fix I ended up with is a reading habit and not a tool.
The whole check is a four-row table
Opening both ends does not mean rereading two files. In practice it is one grep per module — find the return statements — and one comparison of the field lists that cross the boundary.
For the parser and the generator, that comparison is a table you can write in a minute:
| parser produces | generator writes | |
|---|---|---|
| date | ✅ | |
| amount | ✅ | |
| payee | ⚠️ | overwritten by the memo |
| category | ❌ | no column |
The two rows that are not a plain tick are the entire finding, and writing that table is the whole check.
Doing it took two minutes and turned up two crashes that did not exist yet. Skipping it, twice, shipped a converter that quietly dropped a field — and I found that one myself, by reading the code again, rather than from a bug report. That is the part worth sitting with: nothing about the output looked wrong, so there was no report coming.
If you have a QIF file whose dates come out in the wrong month, or a converter that disagrees with any of this, I would like to see it.
Top comments (0)