← Tidelines/Best practices

Bake-off: a catch-all arm vs. one arm per member

The fallback branch is the most reasonable-looking row in a router, and it is insurance you buy once and then never think about, which is a problem, because the thing it insures against is you finding out.

by TypeGlish team8 min read#best-practices
One row of insurance, eight months of silence.

TL;DR Whether your agent prompt should have a fallback branch is decided by the input, not by taste: on a typed input with every member covered a - otherwise:: row is a blocking structure/unreachable-otherwise error, on a partly covered typed input it is legal, and on an untyped input it is mandatory or the switch is a blocking structure/opaque-switch.

Two routers, same job, same four reason codes. One writes a row for three of them and a catch-all for whatever is left. The other writes a row per code. Both check clean, both score A (93/100), and if you diff them you will conclude that the catch-all version is the tidier file. Then a fifth reason code arrives, which is the only thing that ever really happens to a router, and the two files stop being interchangeable.

§1The two contenders

A contact router for a broadband provider. The host runtime already classifies the contact before the prompt runs, off an IVR selection or an intent model, and hands the result in as a typed input. That is the setup where the question is even askable: the input has a written-down domain, so the compiler knows what values exist.

Contender A covers three members and catches the rest. This is what most people write, and the reasoning behind it is sound: upgrades are rare, the general queue is staffed, and a row that handles everything you did not think of feels like the responsible choice.

fallback.tg - contender A✓ compiles
<$CONFIG>
  $REQUIRE variable reason: one of bills, faults, moves, upgrades
</$CONFIG>

# Role
@@ role: name the domain so "helpful" has a scope
You are a contact router for Northwind Broadband.

# Constraints
@@ one_line: a router returns a destination, not a paragraph
- You MUST reply with at most 1 sentence.
@@ no_advice: a router that starts answering stops routing
- You NEVER answer a customer question yourself.

@@ route: one destination per reason code, decided by the compiler
$SWITCH ON @{reason}
  - bills:: You MUST route to billing.
  - faults:: You MUST route to network.
  - moves:: You MUST route to moves.
  - otherwise:: You MUST route to general.
Four rows for four members, one of which is a wildcard. Nineteen lines, fully annotated, and nothing a reviewer would question.

Contender B is the same file with the last row named. One character of difference in intent and four in text: otherwise becomes upgrades, and the destination becomes the queue that actually handles upgrades.

table.tg - contender B✓ compiles
<$CONFIG>
  $REQUIRE variable reason: one of bills, faults, moves, upgrades
</$CONFIG>

# Role
@@ role: name the domain so "helpful" has a scope
You are a contact router for Northwind Broadband.

# Constraints
@@ one_line: a router returns a destination, not a paragraph
- You MUST reply with at most 1 sentence.
@@ no_advice: a router that starts answering stops routing
- You NEVER answer a customer question yourself.

@@ route: one destination per reason code, decided by the compiler
$SWITCH ON @{reason}
  - bills:: You MUST route to billing.
  - faults:: You MUST route to network.
  - moves:: You MUST route to moves.
  - upgrades:: You MUST route to sales.
Every line above the switch is byte-identical to contender A, and the switch differs in one row. This is as controlled as a pair gets.

§2Round 1: nothing separates them

The referee has nothing to say, and this bake-off has to lead with that because it is a stronger tie than we usually get. check is clean on both. So is the score, to the grade and to seven of eight facets.

tg check + tg score - both contenders
$ typeglish check fallback.tg table.tg
 2 files - 0 error, 0 warning, 0 info

$ typeglish score fallback.tg table.tg
fallback.tg - A (93/100)  proven errors: none  tiers: base+z3
  planes  runtime 91 (what the model reads) · hygiene 100 (source only)
  facets  enforceability 67 x.21 · hardness 100 x.12 · directness 99 x.08 · consistency 100 x.17
          structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08

table.tg - A (93/100)  proven errors: none  tiers: base+z3
  planes  runtime 91 (what the model reads) · hygiene 100 (source only)
  facets  enforceability 67 x.21 · hardness 100 x.12 · directness 100 x.08 · consistency 100 x.17
          structure 100 x.12 (hygiene) · annotation 100 x.12 (hygiene) · style 100 x.08 · security 100 x.08
The only number that moves is directness, 99 against 100, which is a rounding difference on the word otherwise and not an argument. Same grade, same overall, and nothing in CI will ever prefer one of these files.

So the choice cannot be settled by pointing a tool at the file you have. It has to be settled by asking what happens to the file you will have, which is the honest way to compare any two prompt structures. The prompt is not the artifact; the prompt is the thing you edit for two years.

§3Round 2: the fifth reason code

Retention gets its own queue. Somebody adds cancelling to the reason codes, in the one place a reason code is declared, and pushes. The domain line is now five members long in both files. Nothing else is touched, which is exactly how this arrives in real life: the person who edited the domain was working on the IVR, not on the router.

tg check - one member added to the domain
$ typeglish check fallback5.tg table5.tg
table5.tg:16:1  error  structure/non-exhaustive-switch  @{reason} can be cancelling, but no arm
  handles it - add a "- <member>::" row for each (or a deliberate "- otherwise::" fallback).

 2 files - 1 error, 0 warning, 0 info
One error, and it is on contender B. Contender A is not in the report at all: it is clean, it builds, it ships. The tidier file is the one that said nothing.

Read the failure as the product it is. Contender B stopped the build and named the member: @{reason} can be cancelling, but no arm handles it. That is a work item with an owner. Contender A absorbed the new member into a row written eight months earlier by somebody who had never heard of a retention queue, and reported success.

The consequence is not abstract, and you do not have to reason about it, because you can read it. Bind the input and build both files.

tg build --vars - a customer trying to cancel, two artifacts
$ typeglish build fallback5.tg --vars '{"reason":"cancelling"}'
 built .typeglish/dist/fallback5.txt ← fallback5.tg (e51fe841c042, full)

# Role
You are a contact router for Northwind Broadband.

# Constraints
- You MUST reply with at most 1 sentence.
- You NEVER answer a customer question yourself.

You MUST route to general.

$ typeglish build table5fixed.tg --vars '{"reason":"cancelling"}'
 built .typeglish/dist/table5fixed.txt ← table5fixed.tg (dc34ddfb3a2e, full)

# Role
You are a contact router for Northwind Broadband.

# Constraints
- You MUST reply with at most 1 sentence.
- You NEVER answer a customer question yourself.

You MUST route to retention.
Same input, same clean check, two different queues. table5fixed.tg is contender B after somebody answered the error by adding the arm, which took one line and thirty seconds. Contender A never asked.
A fallback does not handle the case you forgot. It stops you finding out you forgot it.

§4Round 3: you cannot have both belts

The obvious reaction, and the one we had, is to want both. Enumerate every member and keep the catch-all, so the file is explicit today and defended tomorrow. The language declines, and the wording of the refusal is the whole post.

tg check both5.tg - full coverage plus a fallback
both5.tg:22:3  error  structure/unreachable-otherwise  Every member of @{reason} has its own arm -
  "- otherwise::" can never run, and it would silently absorb members added to the domain
  later. Remove the row.

 1 file - 1 error, 0 warning, 0 info
The compiler gives two reasons and the second one is the interesting one. The row is dead and the row is a hazard, which is why this is an error rather than the dead-code warning you would expect.

So the design space has exactly three states, and all three are enforced. Write them down as a table, because once you have it the question stops being a judgement call.

the fallback is decided by the input
typed input, every member has an arm   otherwise is FORBIDDEN   structure/unreachable-otherwise
typed input, members still uncovered   otherwise is LEGAL       (arms + fallback = total)
untyped input, open domain             otherwise is REQUIRED    structure/opaque-switch
The third row, verbatim: @{reason} has an open domain (untyped) - a $SWITCH on it needs a "- otherwise::" row (members can't cover an open domain). Drop the : one of ... from the declaration and the same file that was blocked for having a fallback is now blocked for not having one.

That is the finding worth taking away, and it inverts the usual reading. The catch-all is not a safety habit that is sometimes redundant. It is a statement about the input: I do not know what values can arrive here. When that is true, the compiler insists on it. When it is false, the compiler treats saying it anyway as an error, because a wildcard over a set you have fully enumerated is a promise to never notice the set changing.

§5The honest column: when the catch-all wins

Three cases where contender A is right, none of which is it feels safer.

  • The domain genuinely is open. Free text, a value from a system you do not control, an intent label from a model that can emit anything. Do not invent a closed domain you cannot enforce: leave the input untyped, take the mandatory fallback, and let the row carry the honest meaning.
  • The set is large and the tail is uniform. Forty reason codes where thirty-five really do go to one queue is not a case for thirty-five identical arms. But then say so in the declaration: cover the five that matter and let the fallback be the documented default, which is the legal middle row of the table. You keep the property that adding a member is silent, so pair it with a review rule rather than pretending you have not.
  • Branching on a predicate, not a member. A $IF chain is the other spelling of the same engine, and it takes open conditions: thresholds, compounds, several variables at once. There the fallback is not optional, it is required, and leaving it off gets you a counterexample rather than a lecture.
tg check - a $IF chain with no $ELSE
$ typeglish check ifnoelse.tg
ifnoelse.tg:9:1  error  structure/non-exhaustive-switch  This chain isn't exhaustive - no arm
  handles @{wait_minutes}=0.0. Add an arm or an ELSE.

 1 file - 1 error, 0 warning, 0 info
Two arms over is at least 20 and is at least 5, and the prover hands back the value that falls through. Add $ELSE and it is clean. Same word, opposite verdict from the $SWITCH case, and the difference is entirely what the compiler was told about the input.

One last wrinkle from building these files, reported because it will bite you and it is not in the decision table. The arm keys are read as English words, so the member name itself can draw a finding: billing, outage, payments, complaints and renewals each produce a prompt/unregistered-doer info on their arm row, while bills, faults, moves, upgrades, refunds and returns are clean. That is the same behaviour that caught the voicemail arm in building a ticket-triage agent, prompt-first, and it is why the router in this post routes bills rather than billing.

§6The verdict

Contender B wins, and the margin is not in any number the tools printed today. Round 1 was a dead tie on both instruments, which means this decision is invisible to every gate you have. Round 2 is where it lands: one file turned a policy change into a build failure with a named member, and the other turned it into a customer trying to cancel a contract while talking to a general queue.

The rule is short enough to remember. Type the input, cover every member, and delete the catch-all, in that order. If deleting it makes you nervous, the nervousness is information: you do not believe the domain is closed, and the fix is to stop declaring that it is, not to keep a row that would hide the difference. And if you do keep a fallback because the tail is genuinely uniform, know what you bought, which is a prompt that will never tell you a new reason code arrived.

§7Common questions

Should my agent prompt have a default or fallback branch?
It depends on one thing, and it is not a style preference: whether the input you are branching on has a domain the compiler knows. If the input is typed and every member has an arm, a fallback is a blocking structure/unreachable-otherwise error, because the row can never run. If members are still uncovered, a fallback is legal and the arms plus the fallback are what makes the switch total. If the input is untyped, a fallback is mandatory: a $SWITCH over an open domain is a blocking structure/opaque-switch until you add one. So write the domain down first, and the fallback question answers itself.
What does structure/unreachable-otherwise mean?
It means every member of the typed input already has its own arm, so the otherwise row is dead code that can never execute. The diagnostic also names the reason it is an error rather than a warning: the row would silently absorb members added to the domain later. It is the compiler removing your safety net on purpose, because on a closed domain the net is what stops a new member from ever being noticed.
Why did adding a value to my typed input break the build?
Because a $SWITCH ON block over a typed input is proven total by construction, so a member with no arm is a blocking structure/non-exhaustive-switch that names the uncovered member. That failure is the feature. Every switch over that input, in every file, fails at once and hands you the list of places that need a decision, which is the opposite of a new reason code quietly inheriting whatever the fallback said.
Is a catch-all in a system prompt actually dangerous?
It is dangerous in exactly one way, and it is easy to see in the artifact. Build the fallback router with the new reason code bound and the compiled prompt contains the single line You MUST route to general. Build the enumerated router with the same input and it contains You MUST route to retention. Same request, same green check, two different queues, and the difference was decided by a row somebody wrote as insurance eight months earlier.
When should I use $IF and $ELSE instead of $SWITCH arms?
Use a $IF chain when the conditions are open predicates rather than members of a closed set: thresholds, compounds, several variables at once. In a chain the fallback flips from forbidden to required, because a chain over open conditions cannot be proven exhaustive, and leaving $ELSE off is a blocking structure/non-exhaustive-switch that hands you a counterexample, for instance no arm handles a wait of 0.0 minutes. Same word, opposite verdict, and the reason is what the compiler knows about the input.
Field note

Both of these diagnostics exist because a $SWITCH ON block is resolved by the compiler rather than weighed by the model, so coverage is a property that can be proven instead of hoped for, and the losing arms are absent from the artifact rather than outranked inside it. Prose conditionals vs $SWITCH for multichannel agents is the earlier round of that argument, and if unsure vs. if a fact is missing shows what it takes to get a trigger onto that plane in the first place. The one thing neither post says, and this one does: once you are on it, the fallback you brought with you is a liability, and the compiler will say so.

∿ washed up Aug 7, 2026 ∿