Nim {.raises: [].}: stop your agent validating trusted data twice
Nim can prove that a proc never raises, yet coding agents fill trusted code with error paths that cannot run: an “unknown unit” raise on a value that already passed validation, a try around a call that cannot fail. Validate once at the boundary, carry the proof in a distinct type, and mark the code behind it {.raises: [].}. Without guidance, two models broke this rule in all 11 runs.
The check that can never fire
A command-line tool converts lengths. The user names a unit; the conversion factors are compiled into the binary. This is the shape agents write without guidance:
import std/[os, tables]
const Factors = {"m": 1.0, "km": 1000.0, "mi": 1609.344}.toTable
proc parseUnit(s: string): string =
if s notin Factors:
raise newException(ValueError, "unknown unit: " & s)
s
proc toMeters(unit: string; value: float): float =
if unit notin Factors: # unit came from parseUnit
raise newException(ValueError, "unknown unit: " & unit)
value * Factors[unit]
let unit = try: parseUnit(paramStr(1))
except ValueError as e: quit e.msg
try:
echo toMeters(unit, 3.0)
except ValueError, KeyError: # cannot happen
quit "conversion failed"
It compiles, ./before km prints 3000.0 and ./before ft exits with unknown unit: ft. Every test passes. The second “unknown unit” check and the try around toMeters can never run: the only unit that reaches them has already passed parseUnit, and the table cannot change after the build.
What a dead error path costs
- It cannot be tested. No input reaches the second raise, so no test covers it and nobody notices when its message or type drifts.
- It spreads to every caller.
toMetersnow says it can raise, so each caller writes a handler for a failure that cannot happen, and each handler is dead code too. - It hides the real contract. The one place a user can cause an error is the boundary. When trusted code raises the same error, a reader can no longer tell where bad input is rejected.
Validate once, carry the proof
Raise once, where untrusted input enters. Return a distinct type from that check, so the type itself says “this value was validated”, and let everything behind it take only that type:
./after km prints 3000.0; ./after ft exits 1 with unknown unit: ftimport std/os
type Unit = distinct int # index into Units; only parseUnit makes one
const Units = [(name: "m", meters: 1.0), (name: "km", meters: 1000.0),
(name: "mi", meters: 1609.344)]
static: # a bad table fails the build, not a customer's run
for u in Units: doAssert u.meters > 0, "bad factor for " & u.name
proc parseUnit(s: string): Unit {.raises: [ValueError].} =
for i, u in Units:
if u.name == s: return Unit(i)
raise newException(ValueError, "unknown unit: " & s)
proc toMeters(u: Unit; value: float): float {.raises: [].} =
value * Units[u.int].meters
let unit = try: parseUnit(paramStr(1))
except ValueError as e: quit e.msg
echo toMeters(unit, 3.0)
Same output, one raise, one handler. A raw string no longer fits: toMeters("km", 3.0) is a type mismatch at compile time, so unvalidated input cannot reach the trusted path by accident. If the compiled-in table needs checking, the static block checks it during the build; a zero factor stops compilation instead of failing on a customer’s machine.
Make the compiler enforce it
A comment saying “this cannot fail” lasts until the next edit. {.raises: [].} is checked on every build. Add a raise to toMeters, as an agent tidying up “missing validation” would, and the build stops:
proc toMeters(u: Unit; value: float): float {.raises: [].} =
if value < 0: raise newException(ValueError, "negative length")
^
Nim 2.3.1, the file above with one line added. Calling a proc that can raise is rejected the same way.
In Python or TypeScript, “this function cannot fail” is a convention a reviewer has to remember. In Nim it is part of the signature, and the compiler rejects the redundant check before review starts. A wrong decision about where errors belong becomes a build error.
0 of 11 without the skill, 8 of 11 with it
We tested this on a multi-module Nim command-line tool where customer-uploaded input must be validated and the product data is compiled into the binary. Each run was blind and sandboxed. Without the skill, every run validated trusted data again:
| Runs passed | Verified skill | No skill |
|---|---|---|
| DeepSeek V4.1 Flash, pre-registered study | 3/5 | 0/5 |
| DeepSeek V4.1 Flash, after DeepSeek’s model update | 2/3 | 0/3 |
| GPT-6.1 Sol, maximum reasoning | 3/3 | 0/3 |
| Total | 8/11 | 0/11 |
The unguided programs mostly produced correct output. The output was right; the design wasn’t. They raised errors that no input could trigger and handed callers a wider error contract than the program has. More reasoning did not help: the frontier model at maximum effort made the same choice in all three runs.
Three to five runs per arm on each model, on a task chosen because it separates agents with and without the skill. Sample sizes and the remaining notes are on the benchmarks page. All code examples were compiled and run with Nim 2.3.1 on Linux amd64.
Checklist for your own code
- Find the boundary. Command-line arguments, uploaded files and network input get validated there, once.
- Return a
distincttype from the validator and make trusted procs take that type, not the raw value. - Mark trusted procs
{.raises: [].}. A raise added later becomes a compile error. - Check compiled-in data at compile time with a
staticblock, not at run time on every start.