Prompts for developers: 7 coding templates I use every day

7 prompts for developers I use daily: ask questions before coding, find edge cases, red tests first, explanations you can verify, reviews and safe refactors.

How are prompts for developers different from ordinary prompts? In my view, a good coding prompt always spells out three things: what to do, what not to do, and how you'll check the result. Here are the 7 templates I use every day, tried out on the expense-tracking app.

The code works, but do I understand it?

In the previous post, I gave the same task to Copilot, Cursor and Claude Code: add a /total command to the expense app, a small app where you record spending by sending messages like "coffee 35k" (35,000 Vietnamese đồng, a little over one US dollar). /total adds up the current month's expenses in Vietnam time, even when the server runs on UTC. AI wrote the code, the tests were green, and this is the month function at the heart of it:

src/expense-book.ts (the version from the previous post)
const VN_TIME = "Asia/Ho_Chi_Minh";

// Month/year in Vietnam time, even when the server runs on UTC.
function vnMonth(date: Date): string {
  const parts = new Intl.DateTimeFormat("en-GB", { timeZone: VN_TIME, month: "2-digit", year: "numeric" }).formatToParts(date);
  const month = parts.find((p) => p.type === "month")!.value;
  const year = parts.find((p) => p.type === "year")!.value;
  return `${month}/${year}`;
}

The previous post ended with three questions I couldn't answer. What does formatToParts return? Why "en-GB" for an app whose users are in Vietnam? Is the ! after find(...) dangerous?

This post answers them with the prompt templates I actually use. Each template comes with a check you do yourself, because an AI answer that sounds reasonable isn't necessarily right. Every command and output below is something I ran on the app's code. All seven templates are also in the demo's PROMPTS.md file so you can copy them.

Template 1: Make the AI ask questions before writing code

Template 1
Read <SPEC.md / description of the task>. Don't write any code yet.
List what's unclear as questions, each with 2 possible interpretations and the consequence of each.

This is the template I use most, and it comes from the first post in the series, write a spec before prompting AI. AI rarely stops to ask. If the request is vague, it picks one interpretation and writes code fast in that direction, and you only notice when you reach line 80.

"Don't write any code yet" is the most important part. Without it, the answer is usually a block of code with a few assumptions tucked in at the end. Asking for "two interpretations and their consequences" forces the answer to show where you actually need to make a decision.

The check is to answer each question yourself and write the answer into the spec. For example, "does /total use the server's month or the user's month?" is exactly the question that produced the "month in Vietnam time" rule in the app's spec.

Template 2: Find edge cases before fixing anything

Template 2
For <function / command>, list the 10 inputs most likely to go wrong. Each line: input → correct result according to <SPEC.md> → why it's easy to get wrong.
Don't change the code, don't write tests yet.

I ran this template with Claude Code for the /total command, then picked a few inputs to try straight away on the current code. This is the important check: a list of edge cases is only worth something once you've tried them on the real code.

My test script sends messages one by one to the app's handleMessage function, sharing one expense book, at 6:30 a.m. on 1 December. The first message records an expense so /total has something to add up:

try-commands.ts
// Send each message to handleMessage in turn, sharing one expense book, and print the reply.
import { handleMessage, type Entry } from "./src/expense-book.ts";

const at = new Date("2026-12-01T06:30+07:00");
const book: Entry[] = [];
for (const message of ["coffee 35k", "/total", "/TOTAL", " /total ", "/total month 10", "/tổng", "total"]) {
  console.log(JSON.stringify(message), "→", handleMessage(message, at, book));
}
TZ=UTC node try-commands.ts (on the previous post's code)
"coffee 35k" → Recorded: coffee, 35,000 đồng
"/total" → 12/2026: 1 expense, total 35,000 đồng
"/TOTAL" → 12/2026: 1 expense, total 35,000 đồng
" /total " → 12/2026: 1 expense, total 35,000 đồng
"/total month 10" → Recorded: /total month, 10 đồng
"/tổng" → Sorry, I didn't understand. Send something like "coffee 35k" to record an expense, or /total for this month's total.
"total" → Sorry, I didn't understand. Send something like "coffee 35k" to record an expense, or /total for this month's total.

Look at the fifth line. Someone who wants October's total sends "/total month 10", and the app records a 10 đồng expense with the note "/total month". The code doesn't match the /total command, so the message falls through to the expense parser, which sees the number 10 and treats it as the amount. No test caught this, because nobody thought of it when writing the spec.

The sixth line is less serious but still worth fixing. The app's users are Vietnamese, and *tổng* is the Vietnamese word for "total". Typing the command in your own language, with the accents a Vietnamese phone keyboard adds, is a natural thing to do, and the user gets back a confusing help message.

I didn't fix the code right away. First I updated the spec to v1.2 with two new rules: a message starting with / is a command and is never recorded as an expense, and /tổng with accents is also the total command.

Template 3: Write tests that must fail first

Template 3
Write tests for cases <numbers ...> from the list above, in <test file>. Don't change the code.
Run the tests and paste the output: the new tests must FAIL on the current code. If any test passes straight away, say so.

A test written for an unfixed bug that passes straight away isn't checking the right thing. That's why this template demands red results, and demands that you're told about any test that's green. The two new tests go at the end of src/expense-book.test.ts. This file uses Node.js's built-in test runner, with a helper vn() that turns a Vietnam-time string into a Date:

src/expense-book.test.ts (the two new tests)
test("a command followed by more text is never recorded as an expense", () => {
  const entries: Entry[] = [];
  const reply = handleMessage("/total month 10", vn("2026-11-05T09:00"), entries);
  assert.equal(reply, "I don't have a command called /total. Send /total to see this month's total.");
  assert.equal(entries.length, 0);
});

test("/tổng (Vietnamese for total, typed with accents) is also the total command", () => {
  const entries: Entry[] = [];
  handleMessage("coffee 35k", vn("2026-11-03T08:00"), entries);
  assert.equal(handleMessage("/Tổng", vn("2026-11-05T09:00"), entries), "11/2026: 1 expense, total 35,000 đồng");
});

Running them on the old code:

TZ=UTC node src/expense-book.test.ts (the previous post's code)
ok 5 - a message it doesn't understand saves nothing
not ok 6 - a command followed by more text is never recorded as an expense
  expected: "I don't have a command called /total. Send /total to see this month's total."
  actual: 'Recorded: /total month, 10 đồng'
not ok 7 - /tổng (Vietnamese for total, typed with accents) is also the total command
# pass 5
# fail 2

Both new tests are red, and the actual line shows exactly the 10 đồng expense bug. The five old tests are still green. That's the starting point I want before touching the code.

Template 4: Fix the code without touching the tests

Template 4
Fix <code file> so every test in <test file> passes. Don't change the tests, don't add libraries.
Run all the tests <with conditions, e.g. TZ=UTC> and paste the output.

I used this template in the previous post. "Don't change the tests" matters more than you'd think, because when you're asked to "make the tests pass", the shortest path is to edit the tests. The check is to look at whether the diff touches the test file, then run the tests yourself instead of trusting the output the AI pasted.

The fix for the two bugs above sits at the top of handleMessage. I show the whole file after the fix under template 7, because it also includes the changes from template 6.

Template 5: Explain it so I understand, with a way to verify

Template 5
Explain <function> line by line for a beginner.
For every claim, include a command I can run (e.g. node -e "...") to check it myself.
Where you're not sure, say you're not sure.

This is the template that answers the three questions at the top of the post. The second line is the part I find most valuable. A fluent explanation makes it very easy to nod along, while a command you can run is either right or wrong.

I collected the verification commands in a verify.ts file and ran it with TZ=UTC to match the server, for 6:30 a.m. on 1 December, Vietnam time (which is still 11:30 p.m. on 30 November in UTC):

verify.ts
const d = new Date("2026-12-01T06:30+07:00");
const opts = { timeZone: "Asia/Ho_Chi_Minh", month: "2-digit", year: "numeric" } as const;
console.log("en-GB parts:", JSON.stringify(new Intl.DateTimeFormat("en-GB", opts).formatToParts(d)));
console.log("vi-VN parts:", JSON.stringify(new Intl.DateTimeFormat("vi-VN", opts).formatToParts(d)));
console.log("vi-VN format:", new Intl.DateTimeFormat("vi-VN", opts).format(d), "| en-US format:", new Intl.DateTimeFormat("en-US", opts).format(d));
console.log("no timeZone, TZ=", process.env.TZ, ":", new Intl.DateTimeFormat("en-GB", { month: "2-digit", year: "numeric" }).format(d));
TZ=UTC node verify.ts
en-GB parts: [{"type":"month","value":"12"},{"type":"literal","value":"/"},{"type":"year","value":"2026"}]
vi-VN parts: [{"type":"literal","value":"tháng "},{"type":"month","value":"12"},{"type":"literal","value":", "},{"type":"year","value":"2026"}]
vi-VN format: tháng 12, 2026 | en-US format: 12/2026
no timeZone, TZ= UTC : 11/2026

The first line answers the first question. `formatToParts` doesn't return a string; it returns an array of pieces, each labeled with a type. The code finds the month piece and the year piece by label, so it doesn't depend on their order or on the separators.

The second and third lines answer the question about "en-GB". With "vi-VN", the result is "tháng 12, 2026" (*tháng* means "month"), with an extra word and a comma. Actually, since the code looks pieces up by label, "vi-VN" would still produce "12/2026", but "en-GB" gives the tidiest month/year format if someone accidentally uses format instead of formatToParts. This is where I had to correct my own understanding after running the command: the reason for "en-GB" is tidiness, not necessity.

The last line shows why timeZone is the most important part of the function. Remove it, and on a server running UTC, an expense at 6:30 a.m. on 1 December counts toward November.

Template 6: Review it like a strict senior

Template 6
Review <file> like a strict senior developer. List issues by severity.
For each issue: which line, the concrete consequence (which input → what goes wrong), how to verify it. Don't change the code.

"The concrete consequence" is what keeps a review from turning into a list of generic advice like "consider adding error handling". For the vnMonth function, the review pointed out two issues I could trust, because both could be verified.

The first is that the function creates a new Intl.DateTimeFormat on every call, while /total calls vnMonth for every expense. I didn't take that on faith; I measured it with a small script that works out the month for 10,000 expenses both ways:

speed.ts
const OPTS = { timeZone: "Asia/Ho_Chi_Minh", month: "2-digit", year: "numeric" } as const;
const newEveryTime = (d: Date) => new Intl.DateTimeFormat("en-GB", OPTS).format(d);
const FORMATTER = new Intl.DateTimeFormat("en-GB", OPTS);
const reuseOne = (d: Date) => FORMATTER.format(d);

const dates = Array.from({ length: 10_000 }, (_, i) => new Date(Date.UTC(2026, 10, 1) + i * 3_600_000));
for (const [name, fn] of [["new formatter every call", newEveryTime], ["reuse one formatter", reuseOne]] as const) {
  fn(dates[0]);
  const start = performance.now();
  for (const d of dates) fn(d);
  console.log(`${name}: ${(performance.now() - start).toFixed(1)} ms for ${dates.length.toLocaleString("en-US")} expenses`);
}
node speed.ts (my machine; 3 runs gave similar results)
new formatter every call: 640.5 ms for 10,000 expenses
reuse one formatter: 9.8 ms for 10,000 expenses

That's more than 60 times slower. With a few dozen expenses a month you won't notice, but once the app has many users and /total reads a whole year of data, it's the difference between an instant reply and making the user wait most of a second. Your numbers will differ, but the ratio should be similar.

The second issue is the !. It tells TypeScript "trust me, this is never undefined". If one day find doesn't find the month piece, say because someone changes the format options, the program crashes with a puzzling "Cannot read properties of undefined" error instead of a message that says what actually happened.

Template 7: Refactor without changing behavior

Template 7
Fix exactly issues <numbers ...> from the review above, and nothing else.
Don't change behavior: every existing test must pass, don't change the tests. Paste the test output before and after the change.

"Nothing else" stops the habit of renaming variables, reordering imports or rewriting the whole file along the way, all of which bloat the diff and make it hard to review. Here's src/expense-book.ts after combining the command fix (template 4) and the two review issues (template 6):

src/expense-book.ts (spec v1.2)
import { parseExpense } from "./parse-expense.ts";

export type Entry = { note: string; amount: number; at: Date };

const HELP = 'Sorry, I didn\'t understand. Send something like "coffee 35k" to record an expense, or /total for this month\'s total.';

// Create it ONCE and reuse it: a new Intl.DateTimeFormat on every call is dozens of times slower (see speed.ts).
// "en-GB" because it gives exactly "12/2026"; "vi-VN" gives "tháng 12, 2026".
const VN_MONTH = new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Ho_Chi_Minh", month: "2-digit", year: "numeric" });

// Month/year in Vietnam time, even when the server runs on UTC.
function vnMonth(date: Date): string {
  const parts = VN_MONTH.formatToParts(date);
  const month = parts.find((p) => p.type === "month")?.value;
  const year = parts.find((p) => p.type === "year")?.value;
  if (!month || !year) throw new Error(`Could not get month/year from ${date.toISOString()}`);
  return `${month}/${year}`;
}

const vnd = (n: number) => `${n.toLocaleString("en-US")} đồng`;

export function monthTotal(entries: Entry[], now: Date): string {
  const month = vnMonth(now);
  const inMonth = entries.filter((entry) => vnMonth(entry.at) === month);
  if (inMonth.length === 0) return `No expenses yet in ${month}.`;
  const total = inMonth.reduce((sum, entry) => sum + entry.amount, 0);
  const count = `${inMonth.length} ${inMonth.length === 1 ? "expense" : "expenses"}`;
  return `${month}: ${count}, total ${vnd(total)}`;
}

export function handleMessage(text: string, now: Date, entries: Entry[]): string {
  const message = text.trim().normalize("NFC");
  // A message starting with "/" is a command and is never an expense (spec v1.2).
  if (message.startsWith("/")) {
    const command = message.toLowerCase();
    if (command === "/total" || command === "/tổng") return monthTotal(entries, now);
    return `I don't have a command called ${message.split(/\s/)[0]}. Send /total to see this month's total.`;
  }

  const expense = parseExpense(message);
  if (!expense) return HELP;
  entries.push({ ...expense, at: now });
  return `Recorded: ${expense.note}, ${vnd(expense.amount)}`;
}

normalize("NFC") is there because the letter "ổ" can be typed either as a single character or as an "o" followed by two separate accent marks, depending on the keyboard. The two look identical but are different when compared with ===. Converting both to the same form before comparing means /tổng is recognized whichever keyboard typed it.

src/parse-expense.ts, the part that reads amounts, hasn't changed since the previous post. Running both test suites again with TZ=UTC:

TZ=UTC node src/expense-book.test.ts
ok 1 - three expenses, then /total gives the month's total
ok 2 - a month with no expenses
ok 3 - 23:59 on 30 Nov, Vietnam time, still counts for November
ok 4 - 6:30 a.m. on 1 Dec, Vietnam time (still 30 Nov in UTC), counts for December
ok 5 - a message it doesn't understand saves nothing
ok 6 - a command followed by more text is never recorded as an expense
ok 7 - /tổng (Vietnamese for total, typed with accents) is also the total command
# pass 7
# fail 0

Running the template 2 script again, the two lines that used to be wrong are now right:

TZ=UTC node try-commands.ts (after the fix; only the two changed lines)
"/total month 10" → I don't have a command called /total. Send /total to see this month's total.
"/tổng" → 12/2026: 1 expense, total 35,000 đồng

The amount-parsing suite, parse-expense.test.ts, still passes all 16 tests. The five old /total tests, including the tricky 6:30 a.m. on 1 December case, are still green after the formatter change, so I'm confident the behavior hasn't changed.

What do these seven templates have in common?

Looking back at all seven, they aren't magic spells. Each one just makes the three things from the start of the post more explicit: what to do, what not to do, and how the result will be checked. Anthropic's prompt engineering guide also stresses being clear, direct and having success criteria, not any special wording.

The check is the part you can't hand over to AI. The "/total month 10" bug only showed up when I tried the inputs on the real code. My wrong idea about "en-GB" was only corrected when I ran the verification command. The "60 times" figure is only trustworthy because I measured it myself. That's also the spirit of the post on an AI coding workflow that keeps you thinking.

When you need a library you've never used

The expense app now reads commands more reliably and adds up totals faster, but it still only runs inside tests. To receive real messages from a phone, it needs to talk to a chat app through a library I've never used.

That's where the templates above aren't enough. AI can answer very confidently about a library based on an old version, and you have no tests to check against. How to use AI to understand an unfamiliar library or codebase without being led astray is the subject of the next post. You can follow the whole series at Building a side project with AI, and if you're preparing for interviews, the question "how do you use AI when coding?" is in the interview question bank.

Get the next post by email.

One email a week, only when there is something worth reading. No spam.