I removed the LLM call and replaced it with 200 lines of template code

javascript dev.to

The feature was a letter generator. Somebody fills in a few fields and gets a finished
letter of recommendation, resignation letter or notice letter, in plain text, ready to
paste into an email.

The obvious build is a prompt and a model call. I wrote the deterministic version instead:
a pure function, about two hundred lines, no network, no key, no tokens. I want to lay out
the reasoning, because "just call a model" is the default now and the default is not always
right.

The three reasons, in order of weight

1. The output is short and the shape is fixed.

A recommendation letter is a date block, a greeting, three or four paragraphs, a sign off
and a name. There is no structural variation to discover. Generation is valuable when the
space of good outputs is large and you cannot enumerate it. Here the space is small enough
to write down, and once you have written it down the model is doing an expensive
approximation of a switch statement.

2. It is a legal-adjacent document.

Not legal advice, but it goes into an employment record. A resignation letter that invents
a notice period, or a reference that invents a fact about a person, is a real problem for
the person who sent it. Templates cannot hallucinate. Everything specific in the output
either came from a form field or is a sentence I wrote and can be held to.

3. Zero marginal cost changes what the product can be.

This is the one that actually decided it. A model call costs money per use, and anything
that costs money per use needs an account, a rate limit and eventually a card. A pure
function costs nothing, so the tool can stay open with no signup, forever, without a
business case. That is a product decision expressed as an architecture decision, and it
only works if the code path is free.

What the code looks like

The whole engine is one exported function over one input type.

export type LetterKind = 'resignation' | 'notice' | 'recommendation';
export type LetterTone = 'formal' | 'warm' | 'brief';

export function generateLetter(input: LetterInput): string
Enter fullscreen mode Exit fullscreen mode

Tone is not a prompt instruction, it is a dimension of the data. Two tiny functions carry
most of it:

function greeting(input: LetterInput, tone: LetterTone): string {
  const name = input.recipientName.trim();
  if (!name) return tone === 'warm' ? 'Hello,' : 'Dear Sir or Madam,';
  if (tone === 'warm') return `Hi ${name},`;
  return `Dear ${name},`;
}

function signOff(tone: LetterTone): string {
  if (tone === 'warm') return 'With thanks,';
  if (tone === 'brief') return 'Regards,';
  return 'Sincerely,';
}
Enter fullscreen mode Exit fullscreen mode

The bodies are arrays of paragraphs, assembled conditionally. A recommendation body opens
differently depending on whether the writer told us how they know the subject:

paras.push(
  rel
    ? `I am pleased to recommend ${who} for the role of ${input.role}. ${rel}, which gave me a direct view of how they work.`
    : `I am pleased to recommend ${who} for the role of ${input.role}, based on my direct experience of working with them at ${input.company}.`,
);
Enter fullscreen mode Exit fullscreen mode

That ternary is the whole trick, repeated maybe fifteen times. It is not clever. Clever was
never the requirement.

What you get for free that a model call does not give you

Determinism, which means testability. Same input, same bytes out. A snapshot test over
the full cross product of three kinds and three tones is nine assertions and runs in
milliseconds. Testing a model call means either mocking it, in which case you are testing
your mock, or asserting fuzzy properties of real output and paying for the privilege on
every CI run.

Offline and instant. No spinner, no failure state, no retry logic, no timeout, no
"the service is busy" copy to write and translate. The letter updates as the user types
because rendering it is a function call.

A real validator instead of an implicit one. With a model you tend to send whatever you
have and hope. With a template you have to decide what is required, which forces the
product question into the open:

export function missingFields(input: LetterInput): string[] {
  const missing: string[] = [];
  if (!input.senderName.trim()) missing.push('Your name');
  if (!input.company.trim()) missing.push('Company');
  if (input.kind === 'recommendation') {
    if (!input.subjectName.trim()) missing.push('Who you are recommending');
    if (!input.role.trim()) missing.push('Their role');
  } else {
    if (!input.role.trim()) missing.push('Your role');
    if (!input.lastDay.trim()) missing.push('Last working day');
  }
  return missing;
}
Enter fullscreen mode Exit fullscreen mode

Note that the required set differs by kind. A recommendation has no last working day. A
resignation has no subject. A single prompt would have blurred those together and produced
something plausible for a missing field, which is worse than refusing.

Localisation is mechanical. Nine strings per tone, translated once, correct forever. The
same feature backed by a model needs the prompt tuned per language and the output checked
per language by someone who reads it.

Where the approach genuinely loses

I am not going to pretend this scales to everything.

It cannot say the specific thing. The generated paragraphs are competent and generic,
and generic is exactly the part of a reference letter that carries no weight. A hiring
manager skims "demonstrated consistent judgement" and stops at "she rewrote our billing
reconciliation and the month-end close went from four days to one".

So the engine has a highlight field, free text, dropped verbatim into the middle of the
letter. That is not a limitation I worked around, it is the correct division of labour. The
tool writes the scaffolding nobody reads. The human writes the one sentence that does the
work. A model would have written a fluent guess at that sentence, and a fluent guess about
a real person is precisely the thing you do not want in a reference.

It cannot rewrite arbitrary prose. Paste in three rambling paragraphs and ask for them
tightened, and templates have nothing to offer. That is a genuine generation task and I
would use a model for it.

Adding a kind costs a function. A new letter type means new code, not a new prompt
string. For three kinds that is fine. For thirty I would be rethinking it.

The heuristic I took away

Reach for generation when the output space is large, variable, and you cannot enumerate the
good answers. Reach for templates when the output space is small, the shape is fixed, and
being wrong is expensive.

Short formal documents sit squarely in the second category, and the industry keeps building
them with the first tool because the first tool is what everyone is holding.

The engine described here runs the
letter of recommendation template
tool on the resume builder I maintain. No account, no card, no model call, and the plain
text output is free to copy. It renders in whatever time a string concatenation takes,
which is the entire point.

Source: dev.to

arrow_back Back to Tutorials