ADR-0013 — Typed macro parameters
Status: Proposed — recommendation: defer (the shape below is recorded for when a real need appears) · Date: 2026-08-02
Context
A macro declares params and binds {capture} values from prose, data-table rows,
defaults, and use:…with:. Every arg is an untyped string today: the matcher
only checks that a capture names a declared param (matcher.rs), never that the value
looks like what the step expects. A mistyped value (the record abc is fetched where a
UUID was meant) is caught only at hurl run time — if at all — not by --dry-run.
Cucumber Expressions solve this with typed parameters ({int}, {uuid}, custom types)
validated before execution. proef wants the same shift-left check, expressed within its
architecture. Round-2 code validation (IMPROVEMENT-PLAN §12, item N1) established three
hard constraints:
- Placement must be the declaration site, not the pattern. Three of the four arg
sources are not captures (data-table rows,
defaults,with:), anduse:-only macros have no pattern at all — so an inline{name:type}form could not type them, and a type buried in thematch:string is invisible toproef schema(schemars). - The two-tier variable rule caps the value. An arg may legitimately be
${…}/{{…}}that only resolves at lower/run time (ADR-0005). Type-checking such an arg at bind time would false-positive, so the check must skip any raw arg containing${or{{— it is a best-effort lint over literal args, not a runtime type system. - One-canonical-way forces a single
paramsspelling.paramsis a YAML sequence today ([q, index]); adding a typed spelling alongside it would be two ways to declare params — the golden-rule violation that killed thetemplates:alias and the# key:directive.
Decision
paramsbecomes a name → type map:params: {q: uuid, index: int, note: any}. A missing/anytype means “declared, unchecked” (today’s behaviour). Parsed with a customDeserialize(not an untagged enum — CLAUDE.md bans those near thearbitrary_precisionfootgun). This is a breaking pack-shape change — every existing pack migratesparams: [q, index]→params: {q: any, index: any}, with no alias (consistent with thetemplates:→macros:and# key:removals).- The type set is a closed registry —
any,int,number,uuid,email,iso8601,word— modelled on thefake::GENERATORSregistry (fake.rs), validated at load so an unknown type is a pack error. No user-defined types in v1 (revisit if a real need appears). - Validation lands at three sites: bound captures + data-table rows at bind time
(
proef::bind::param_type_mismatch), and staticdefaults/with:values at load time (next to the existingdefault_not_param/unknown_with_keychecks). All checks are skipped when the raw arg contains${or{{(constraint 2). Datetime types parse viajiff(sans-IO — literal parse reads no clock). proef schemareflects the typedparamsshape (constraint 1 satisfied).
Consequences
- Breaking: every pack’s
paramsmigrates to the map form. A one-time mechanical change, called out in CHANGELOG and GETTING-STARTED; the error-corpus gains abind__param_type_mismatchcase. - Best-effort, not a guarantee: literal args are checked;
${…}/{{…}}args are not. This must be documented so authors do not read it as a type system — its value is catching typos in literal prose at--dry-run, not enforcing runtime types. --dry-rungains a real new class of caught mistake;proef schemaautocomplete gets richer.- The honest cost/benefit: a breaking migration for a best-effort literal-args lint. Recorded so the trade is deliberate, not incidental.
Best-practice basis & recommendation
Research into the field (2025–2026) is decisive on form and honest about worth:
- Form (if built): the industry norm is declaration-site typing referenced by name,
never a full type spec inline. Cucumber Expressions put only a bareword type-name in the
pattern (indexing a registry); SpecFlow/Reqnroll type by the binding method’s return
type; Bruno (v4, 2024) added declaration-site typed variables with a string default. A
name → typemap is the canonical shape (JSON Schemaproperties, OpenAPI). The inline{name:type}form (behave/pytest-bdd) only works because their “declaration” is code, not a schema — with a schema it loses tooling. So the decision above (single-shape map, closed vocabulary) is the correct form, and the list-or-map union is the worst option on every axis (permanent double surface, weaker autocomplete, bifurcated examples — the exact ambiguity “one canonical way” forbids). ESLint’s flat-config hard break (v9→v10, bounded deprecation then cut) is the precedent for a single-owner format choosing a clean break over an indefinite dual-shape. - Worth (candid): a best-effort lint over literal values is the mypy/TypeScript
gradual-typing bargain — genuinely useful for shallow typos, provided it degrades to
“unchecked,” never “pass,” on the parts it can’t see. But an API test runner’s args are
deferred-heavy (base URLs, captured ids, run-time tokens — all
${…}/{{…}}), the exact population the lint cannot check, which shrinks the realized benefit. proef’s own corpus bears this out: today’s params are overwhelmingly string-ish, so the high-value types (uuid/int/iso8601) would rarely fire. Karate — the nearest-neighbour API tool — never types inputs at all; it types responses.
Recommendation: defer. The clean, best-practice-aligned move for proef right now is not to add a low-current-value lint behind a breaking format change; it is to record the correct shape (above) and adopt it if a pack corpus emerges where structured literal args are common. Building it earlier is defensible only in the disciplined form above — never as a list-or-map union. (If the operator wants it now regardless, implement the single-shape map with the honest-degradation discipline; the migration is mechanical.) (Cucumber Expressions, Reqnroll conversions, Bruno typed variables, OpenAPI 3.1, ESLint 10 removal, Karate schema validation)
Alternatives considered
- Inline
{name:type}(Cucumber-Expression style) — rejected: invisible toproef schema, mis-binds in the tokenizer ({q:uuid}becomes a capture literally namedq:uuid), and covers only 1 of the 4 arg sources. paramsaccepts either a sequence (untyped, non-breaking) or a map (typed), via customDeserialize— the non-breaking alternative. Rejected under one-canonical-way (two shapes for one field), but it is the fallback if a breaking migration is judged too costly for a best-effort lint. (This is the open fork for the operator.)- Keep params untyped (do not do N1) — the status quo; the shift-left gap stays. A legitimate choice given the cost/benefit above.
- User-defined type registry — deferred; a closed set is simpler and covers the common shapes.