Using AI to write code: write a spec before you prompt
Using AI to write code works better with a one-page spec and a table of examples that becomes your tests. A real expense app, with code and 11 passing tests.
Ever asked AI to write code and got something that runs but isn't what you meant? Using AI to write code works best when you give it a short spec with a table of examples. That table becomes your tests, the definition of "correct" for both you and the AI. I'll try it on a real side project.
What is this series' side project?
This is the first post in the series Building a side project with AI. In it, I build a small app from idea to real users, with AI helping at almost every step. The goal isn't to let AI do everything. It's to learn to work faster with AI while still understanding what you're doing.
The app idea is simple: you log your spending by sending a one-line message, the way you'd text a friend. Send coffee 35k and the app records an expense of 35,000 đồng with the note "coffee". Send /total and it replies with this month's total.
I live in Vietnam, so the app works in Vietnamese đồng, where prices run into the thousands and millions (35,000 đồng is a little over one US dollar). That's why people here write amounts in shorthand: 35k for thirty-five thousand, 3tr for three million (tr stands for *triệu*, million), 1tr2 for 1.2 million, and 45.000 with a dot between the thousands. I picked this app because it's small enough to finish, real enough that I use it every day, and it has one genuinely tricky part: reading amounts the way people actually write them. Later posts in the series connect the app to Telegram and add an AI feature to the app itself.
What happens when the prompt is too vague?
The most natural thing to do is open an AI tool and type something like this:
Write me a TypeScript function that parses expense messages like "coffee 35k".You'll get a function that runs and looks tidy. What's hard to notice is how many things the AI had to guess, because the prompt never mentioned them.
For example, is 1tr2 1,200,000 or 1,000,002, and does the app even need to support that notation? Is 45.000 forty-five or forty-five thousand, given that Vietnamese uses a dot to separate thousands while most programming languages treat the dot as a decimal point? If a message has no amount, should the function return null, throw an error or return 0? And for coffee 35k 40k, which number does it take?
Every place where the AI has to guess is a place where the app can be wrong without you ever noticing. Ask again, and the AI may guess differently. You have code in hand, but you can't say for sure that it's right, because you haven't said what right means either.
To make this concrete, I wrote a "guessing" version in src/guess.ts. It's the kind of plausible-looking code a vague prompt easily produces; I wrote it to illustrate the point, it isn't code an AI actually generated. parseGuess takes the first number in the message, multiplies it by a thousand if it sees the letter k, and by a million if it sees the letters tr.
The bottom of the file is a table of 10 messages with the result I want for each one. The table comes from the spec later in this post, along with the reason for every row. The loop at the end calls parseGuess on each message, compares it with the expected result, and prints ✓ or ✗:
export function parseGuess(message: string) {
const match = message.match(/[\d.,]+/);
if (!match) return null;
let amount = parseFloat(match[0]);
if (/k/i.test(message)) amount *= 1000;
if (/tr/i.test(message)) amount *= 1_000_000;
return { note: message.replace(/[\d.,]+\w*/, "").trim(), amount };
}
// [message, expected result]; null means the app must answer "didn't understand"
const table: [string, { note: string; amount: number } | null][] = [
["coffee 35k", { note: "coffee", amount: 35000 }],
["street food 45.000", { note: "street food", amount: 45000 }],
["gas 50000", { note: "gas", amount: 50000 }],
["rent 3tr", { note: "rent", amount: 3000000 }],
["phone 1tr2", { note: "phone", amount: 1200000 }],
["train ticket 32K", { note: "train ticket", amount: 32000 }],
["35k coffee", { note: "coffee", amount: 35000 }],
["coffee", null],
["coffee 0k", null],
["coffee 35k 40k", null],
];
let pass = 0;
for (const [message, expected] of table) {
const got = parseGuess(message);
const ok = JSON.stringify(got) === JSON.stringify(expected);
if (ok) pass += 1;
console.log(`${ok ? "✓" : "✗"} ${message.padEnd(19)} → ${JSON.stringify(got)}`);
}
console.log(`\n${pass}/${table.length} rows of the table right.`);Skim it and the function seems fine. But run node src/guess.ts and you get this. Each line is a message and what the function returned; ✗ means it differs from the expected result:
✓ coffee 35k → {"note":"coffee","amount":35000}
✗ street food 45.000 → {"note":"street food","amount":45000000}
✓ gas 50000 → {"note":"gas","amount":50000}
✓ rent 3tr → {"note":"rent","amount":3000000}
✗ phone 1tr2 → {"note":"phone","amount":1000000}
✗ train ticket 32K → {"note":"train ticket","amount":32000000000}
✓ 35k coffee → {"note":"coffee","amount":35000}
✓ coffee → null
✗ coffee 0k → {"note":"coffee","amount":0}
✗ coffee 35k 40k → {"note":"coffee 40k","amount":35000}
5/10 rows of the table right.A train ticket costs 32 billion đồng.
The cause is both funny and very real. The function checks whether the message contains the letters tr to spot the "million" unit, and "train" contains tr. It also checks for k, and "ticket" contains k, so 32 gets multiplied by a thousand and then by a million. "Street food" makes the same mistake plus one more: parseFloat("45.000") treats the dot as a decimal point and returns 45, which is then multiplied by a million because "street" contains tr. On top of that, 1tr2 loses its fraction, 0k is saved as if it were normal, and with two amounts the function takes the first one and leaves "40k" stuck in the note.
I don't think any of these bugs comes from the AI being bad. Each one is a question the prompt left open, and the code picked an answer that sounds reasonable but isn't what the user meant. If you want the AI to pick the right answer, you have to answer those questions first, and that's exactly what a spec is for.
Does a spec need to be long?
When people hear "spec", many picture a document dozens of pages long. For a side project, one page is enough, as long as it answers four questions: what problem the app solves, what the first version does, what the first version doesn't do, and what counts as done.
The "doesn't do" part is the one most often skipped, yet it's the part that stops both you and the AI from adding work nobody needs. Here are the two sections at the top of the expense app's spec; the full file is in a later section:
## v1 features
1. Send `coffee 35k` → save one expense: note "coffee", amount 35,000 đồng, time of the message.
2. Send `/total` → total spending for the current month.
3. A message it can't understand → reply with instructions on how to write one, and save **nothing**.
## Out of scope for v1
Multiple users, categories, budgets, charts, editing/deleting expenses, currencies other than VND.With a single "out of scope" line, the spec already answers several questions the AI likes to decide on its own, such as whether the app needs a login, categories or USD support. The answer to all three is no.
Why is the table of examples the most important part?
In my opinion, the most valuable part of the spec is a table that says exactly what result each message should produce. Every row answers one question the vague prompt left open. Here are the ten rows for the expense app, each with its reason:
coffee 35krecords "coffee", 35,000 đồng. This is the most common way to write it;kmeans thousand.street food 45.000records 45,000 đồng, because in Vietnam a dot separates thousands, it isn't a decimal point.gas 50000records 50,000 đồng. A number with no unit is read as đồng.rent 3trrecords 3,000,000 đồng, becausetrmeans million.phone 1tr2records 1,200,000 đồng, just as people say "one million two".train ticket 32Krecords 32,000 đồng. An uppercaseKstill means thousand, and the "tr" in "train" must not be read as million.35k coffeerecords "coffee", 35,000 đồng, because some people type the amount first.coffeerecords nothing, because the amount is missing.coffee 0krecords nothing, because a 0 đồng expense is just noise in the book.coffee 35k 40krecords nothing, because there are two amounts and the app can't know which one is meant.
In SPEC.md, these ten rows sit in a three-column table (message, note and amount), with a few general rules below it. Here is the full spec:
# Expense book — spec v1
## Problem
I want to record my daily spending without opening an app and filling in a form. The fastest way: send one line, like texting a friend.
## Users
One person (me). No login yet, no multiple users yet.
## v1 features
1. Send `coffee 35k` → save one expense: note "coffee", amount 35,000 đồng, time of the message.
2. Send `/total` → total spending for the current month.
3. A message it can't understand → reply with instructions on how to write one, and save **nothing**.
## Out of scope for v1
Multiple users, categories, budgets, charts, editing/deleting expenses, currencies other than VND.
## Rules for reading amounts
| Message | Note | Amount (đồng) |
|---|---|---|
| `coffee 35k` | coffee | 35000 |
| `street food 45.000` | street food | 45000 |
| `gas 50000` | gas | 50000 |
| `rent 3tr` | rent | 3000000 |
| `phone 1tr2` | phone | 1200000 |
| `train ticket 32K` | train ticket | 32000 |
| `35k coffee` | coffee | 35000 |
| `coffee` | — | not understood (no amount) |
| `coffee 0k` | — | not understood (amount must be > 0) |
| `coffee 35k 40k` | — | not understood (two amounts) |
- The amount is a whole number of đồng, greater than 0, at most 1 billion.
- `k` = thousand, `tr` = million, `tr2` = 1.2 million; case-insensitive.
- A `.` or `,` between groups of 3 digits is a thousands separator.
- The note is what's left after removing the amount, with extra spaces removed; it must not be empty.
## Definition of done
- Every row in the table above has a test, and the tests pass.
- No external library for reading amounts.The "—" in the note column means no note is saved, because the whole message is rejected. Writing this table takes about fifteen minutes, and they're fifteen minutes spent thinking about the product, not about code. When you get to the coffee 35k 40k row, you're forced to choose between taking the first number, adding them up, or saying you didn't understand. I chose "didn't understand", because recording the wrong amount does more harm than asking the user to send the message again. That's a product decision, and I don't want the AI making it for me.
On top of that, every row in the table turns straight into a test, so you don't need to come up with any other tests. Pay special attention to the "not understood" rows, because that's where bugs like to hide. Beginners tend to think only about the case where everything goes right, and a table of examples forces you to think about the cases where it doesn't.
How does the prompt change once you have a spec?
With a spec in place, the prompt gets shorter and much clearer:
Read SPEC.md, the section "Rules for reading amounts".
1. Write tests with node:test for every row of the table (file src/parse-expense.test.ts).
2. Write parseExpense(message: string): { note: string; amount: number } | null
in src/parse-expense.ts so that every test passes. No external libraries.
3. Run `node --test src/parse-expense.test.ts` and paste the result.
If anything in the spec is unclear, ask before writing code.Compared with the first prompt, this one points the AI at the right source of information, SPEC.md, instead of leaving it to rely on memory. It asks for the tests before the code, so the tests aren't written to match whatever the AI implemented. It sets the constraints up front: the function name, the file location, no external libraries. And finally it asks for evidence, the test output, rather than accepting a "done!".
The last line, "if anything is unclear, ask", is a habit I recommend keeping. Anthropic's Prompting best practices guide has an easy image to remember: think of the AI as a brilliant new employee who knows nothing about your project yet. It also suggests a simple test: show your prompt to a colleague with no context on the task, and if they'd be confused, the AI will be too.
The result: one function and 11 tests
The code below is what Claude Code wrote from exactly this spec and prompt, and the tests were run for real. For this English version I only translated the notes in the test data and the test names, then ran the tests again. The project has just three files in src: guess.ts from above, the test file and the function file below, plus SPEC.md at the root.
Here's the test file. The ok array holds the messages that should be read, each with its expected note and amount. The array in the second loop holds the messages for which the function must return null. Each element becomes its own test:
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseExpense } from "./parse-expense.ts";
const ok: [string, string, number][] = [
["coffee 35k", "coffee", 35000],
["street food 45.000", "street food", 45000],
["gas 50000", "gas", 50000],
["rent 3tr", "rent", 3000000],
["phone 1tr2", "phone", 1200000],
["train ticket 32K", "train ticket", 32000],
["35k coffee", "coffee", 35000],
];
for (const [message, note, amount] of ok) {
test(`reads "${message}"`, () => {
assert.deepEqual(parseExpense(message), { note, amount });
});
}
for (const message of ["coffee", "coffee 0k", "coffee 35k 40k", "35k"]) {
test(`does not understand "${message}"`, () => {
assert.equal(parseExpense(message), null);
});
}You may notice the last line has an extra "35k", a case that isn't in the table: a message with an amount but no note. The spec's rules say "the note must not be empty", so the test is still valid, but in the next revision of the spec I'll add this case to the table itself so it doesn't get missed.
And here's the function that reads a message. It finds every amount in the message with a regular expression. If it finds exactly one, it turns it into a number of đồng and uses the remaining words as the note. Each part is explained in the next section:
export type Expense = { note: string; amount: number };
const MAX_AMOUNT = 1_000_000_000;
// 35k · 32K · 3tr · 1tr2 · 45.000 · 50000 (one amount "token", standing on its own)
const AMOUNT = /(?<![\p{L}\d])(\d{1,3}(?:[.,]\d{3})+|\d+)(k|tr(\d)?)?(?![\p{L}\d])/giu;
function toAmount(digits: string, unit: string | undefined, decimal: string | undefined): number {
const base = Number(digits.replace(/[.,]/g, ""));
const lower = unit?.toLowerCase();
if (lower === "k") return base * 1_000;
if (lower?.startsWith("tr")) return base * 1_000_000 + Number(decimal ?? 0) * 100_000;
return base;
}
export function parseExpense(message: string): Expense | null {
const matches = [...message.matchAll(AMOUNT)];
if (matches.length !== 1) return null; // no amount, or more than one
const [token, digits, unit, decimal] = matches[0];
const amount = toAmount(digits, unit, decimal);
if (amount <= 0 || amount > MAX_AMOUNT) return null;
const note = message.replace(token, " ").replace(/\s+/g, " ").trim();
if (!note) return null;
return { note, amount };
}To run the tests you need Node.js 22.18 or later and nothing else. Each ok line is a passing test, and the last two lines are the summary. In a terminal, Node may show ✔ instead of the word ok, but the content is the same:
ok 1 - reads "coffee 35k"
ok 2 - reads "street food 45.000"
ok 3 - reads "gas 50000"
ok 4 - reads "rent 3tr"
ok 5 - reads "phone 1tr2"
ok 6 - reads "train ticket 32K"
ok 7 - reads "35k coffee"
ok 8 - does not understand "coffee"
ok 9 - does not understand "coffee 0k"
ok 10 - does not understand "coffee 35k 40k"
ok 11 - does not understand "35k"
# pass 11
# fail 0Do passing tests mean you understand the code?
11 passing tests tell you the function does what the spec asks. They don't mean you understand the function. Before accepting code an AI wrote, I always make myself explain every part of it, and if I can't, I ask the AI until I can.
For example, what are (?<![\p{L}\d]) at the start and (?![\p{L}\d]) at the end of the regular expression for? They're the condition "the character before and after must not be a letter or a digit". Thanks to them, numbers stuck to letters, like abc123 or rice 35kg, aren't read as amounts. \p{L} matches any letter, including letters with diacritics, and it only works with the u flag.
Why does 1tr2 come out as 1,200,000? The tr(\d)? part takes the digit after "tr" as hundreds of thousands, so the result is 1 × 1,000,000 + 2 × 100,000. And the function checks matches.length !== 1 instead of taking the first number because the spec says two amounts means "not understood": the decision made in the table of examples now lives in the code.
The last question is the most important one: which ways of writing an amount aren't in the table yet? I wrote one more small file that calls parseExpense with four messages that aren't in the table and prints what it returns:
import { parseExpense } from "./parse-expense.ts";
for (const message of ["apples 2kg 35k", "rice 35kg", "1.5tr laptop", "coffee 35 thousand"]) {
console.log(message.padEnd(19), "→", parseExpense(message));
}apples 2kg 35k → { note: 'apples 2kg', amount: 35000 }
rice 35kg → null
1.5tr laptop → null
coffee 35 thousand → { note: 'coffee thousand', amount: 35 }The first three results are reasonable.
apples 2kg 35k correctly records 35,000 đồng, because "2kg" is stuck to letters and isn't treated as an amount. rice 35kg is rejected because there's no amount at all. 1.5tr laptop is rejected because "1.5tr" isn't in the spec, and rejecting is still safer than guessing. The last line is a real bug: the user wrote "35 thousand", but the app recorded 35 đồng with the note "coffee thousand", without any error. All 11 tests still pass, because no row in the table contains the word "thousand".
Tests only protect the cases you thought of; everything else you have to try yourself. Reading the code and trying a few more inputs like this takes about ten minutes, and to me that's the difference between "letting AI write it for you" and "writing it with AI". You'll find more on keeping your own judgement while using AI in the AI for developers topic.
What do you do when the spec has to change?
The "35 thousand" bug above is exactly the moment the spec needs to change, because you have to decide whether the app understands words like "thousand" and "million" yet. The order I usually follow is to fix the spec first, by adding a row coffee 35 thousand → 35000 to the table, or → not understood if you decide not to support that wording yet. Then I add a test for the new row and run it to watch it fail. Only then do I ask the AI to fix the code until every test passes, including the 11 old ones.
Working in that order, the spec stays the most accurate description of the app, and the 11 old tests make sure the new fix doesn't break anything that already works.
The habit of writing a spec first also helps in interviews, especially with the question "how do you use AI when you code?", which you'll find in the interview question bank.
Where should you work with AI?
Once you have a spec, the next question is where to work with AI: in the browser, in a code editor such as Cursor or GitHub Copilot, or right in the terminal with Claude Code. Each fits a different way of working.
The next post in the series uses this same spec to compare the tools on one concrete task: adding the /total command. You can follow the whole series at Building a side project with AI.