Custom Parameters
A custom parameter lets your prose speak your domain’s notation — £2.55,
June 6, 2026, LHR — while your steps receive real domain values. You declare it
once, next to the state it belongs to, and every step expression in that file
can use it as a {name} placeholder.
const { stimulus, sensor } = steps(() => ({ fee: GBP(0) })).param( 'money', /£\d+\.\d{2}/, (raw) => GBP(Number.parseFloat(raw.slice(1))), (m) => `£${m.value.toFixed(2)}`,)
sensor('owes a {money} late fee', (state) => state.fee)// inside register(Steps<Ctx> s)s.state(Ctx::new);s.param( "money", Pattern.compile("£\\d+\\.\\d{2}"), groups -> GBP.of(new BigDecimal(groups[0].substring(1))), money -> "£" + money.value().setScale(2));
s.sensor("owes a {money} late fee", (Ctx ctx, Money expected) -> ctx.fee());val steps = steps(::Ctx) { param( "money", Regex("""£\d+\.\d{2}"""), format = { "£%.2f".format(it.value) }, ) { groups -> Money.gbp(groups[0].substring(1).toBigDecimal()) }
sensor("owes a {money} late fee") { _: Money -> fee }}param, stimulus, sensor = steps(lambda: {"fee": gbp(0)})param( "money", r"£\d+\.\d{2}", parse=lambda raw: gbp(Decimal(raw[1:])), format=lambda m: f"£{m.value:.2f}",)
@sensor("owes a {money} late fee")def _(state, expected): return state["fee"]steps(-> { { fee: gbp(0) } }) do param( 'money', '£\d+\.\d{2}', parse: ->(raw) { gbp(raw[1..].to_f) }, format: ->(m) { format('£%.2f', m.value) }, )
sensor('owes a {money} late fee') { |state, _expected| state[:fee] }endlet parse: ParseFn = Rc::new(|g: &[&str]| { Value::Float(g[0].strip_prefix('£').unwrap_or(g[0]).parse().unwrap_or(0.0))});let format: FormatFn = Rc::new(|v: &Value| match v { Value::Float(pounds) => Some(format!("£{pounds:.2}")), _ => None,});s.param("money", r"£\d+\.\d{2}", parse, Some(format));
s.sensor( "owes a {money} late fee", file!(), line!() as usize, Handler::sync1(|state, _expected| Ok(smap(&state).get("fee").cloned())),);s.State(() => VMap(("fee", Value.Of(0.0))));
s.Param( "money", @"£\d+\.\d{2}", groups => Value.Of(double.Parse(groups[0]!.TrimStart('£'))), value => $"£{((VFloat)value).Float:F2}");
s.Sensor("owes a {money} late fee", (state, expected) => state["fee"]);s.Param("money", `£\d+\.\d{2}`, func(g []string) varar.Value { pounds, _ := strconv.ParseFloat(strings.TrimPrefix(g[0], "£"), 64) return varar.FloatValue(pounds) }, func(v varar.Value) (string, bool) { if pounds, ok := v.AsFloat(); ok { return fmt.Sprintf("£%.2f", pounds), true } return "", false })
s.Sensor("owes a {money} late fee", func(state varar.Value, args []varar.Value) (*varar.Value, error) { return varar.Ptr(state.CloneMap()["fee"]), nil})This page is the reference for the three fields of a custom parameter type and for how a parameter mismatch is rendered. The rules are identical in every port (TypeScript, Java, Kotlin, Python, Ruby).
The three fields
Section titled “The three fields”| Field | Direction | Role |
|---|---|---|
regexp |
— | which spans of prose this parameter matches |
parse |
document → value | turn the matched text into the value your handlers receive |
format |
value → document | turn a value back into the document’s notation (mismatch display) |
regexp
Section titled “regexp”The pattern that recognizes the parameter inside a sentence.
-
Capture the value. Wrap the part of the match that is the value in the first capture group — that is what your handler receives and the exact span an editor highlights. With no capture group the whole match is the value (that is when you reach for
parse). Use non-capturing groups —(?:st|nd|rd|th)— for alternation or repetition you don’t want captured. -
Matching runs against the raw sentence text — markup and all.
#urgentreaches your regexp as#urgent, so when a notation run is your parameter, put the markers in the pattern and a capture group around the data inside:param('tag', /#(\w+)/)The
#stays notation; the groupurgentis the value, noparseneeded. Markdown emphasis is common enough that it ships built-in as{emph}(see Built-in parameter types) — you don’t hand-roll it. The recipe above is for any other notation that is really data: a hashtag, a ticket id, a wiki link. Markup is notation, exactly like£2.50. -
The pattern participates in step matching, so keep it anchored to real notation. A pattern that matches everywhere makes every sentence a step candidate.
Turns the matched text into the value your step handlers receive — a number, a
date, a domain object. It is a varargs function over the capture groups. If
omitted, the value is the first capture group — or the whole matched text when
the pattern has none (TypeScript, Python, and Java’s two-argument param; the
Kotlin signature always takes a trailing parse lambda).
In TypeScript, the parse function’s return type flows into the handlers: with the
money type above, a handler for 'owes a {money} late fee' receives a typed
Money argument with no annotation needed.
The transformed value — not the raw text — is what a sensor’s return is deep-compared against.
format
Section titled “format”The inverse of parse: turns a value back into the document’s notation.
It has exactly one job — rendering the actual value when a sensor’s return
doesn’t match the document.
Without format, a failure has to fall back to a generic rendering of your
domain object:
CellMismatchError: cell 1: expected £2.55 but was {"currency":"GBP","value":2.6}With format, both sides of the diff speak the document’s language:
CellMismatchError: cell 1: expected £2.55 but was £2.60format never affects whether a comparison passes — the verdict is always
deep equality on the transformed values. It only affects how a failure reads.
How a mismatch is rendered
Section titled “How a mismatch is rendered”When a sensor’s returned value differs from a transformed parameter, the failure carries one span-anchored diff per parameter. The expected side is always the document’s own text at the parameter’s span. The actual side is rendered by the first rule that applies:
- the parameter type’s
format, when it defines one — the document’s notation, identical in every port; - a string value is used as-is;
- any other primitive (number, boolean, …) is stringified;
- anything else falls back to the port’s native rendering —
JSON.stringifyin TypeScript,repr()in Python,toString()on the JVM. This keeps the message informative, but it is port-specific: if you care how a domain object reads in a failure, give its parameter type aformat.
Because every diff is anchored to a source span, editors highlight the exact
failing characters in the Markdown and show the rendered actual value in
place. Test frameworks that support an expected/actual pair (vitest, for
example) receive the format-rendered strings, so their diff also reads
£2.55 vs £2.60; only when no format exists do they receive the raw
values instead, so an object mismatch still gets the framework’s structural
- Expected / + Received diff rather than two JSON strings.
Built-in parameter types
Section titled “Built-in parameter types”The Cucumber Expressions
built-ins are always available and need no declaration: {int}, {float},
{double}, {byte}, {short}, {long}, {biginteger}, {bigdecimal},
{word}, {string} and the anonymous {}. Each produces the obvious value
in its port ({int} is a number in TypeScript, an Integer in Java, an
Int in Kotlin, an int in Python). Their values are primitives or strings,
so mismatch rendering never needs a format for them.
On top of those, Varar adds one built-in of its own: {emph}, Markdown
emphasis in any of its uniform forms — *x*, _x_, **x**, __x__,
***x***, ___x___. Only the inner text reaches your handler (*Emma* →
Emma; only the outermost delimiter pair is stripped, so **_Emma_** →
_Emma_), and editors highlight just that value, not the markers. It carries
a format that renders a mismatch back as *value*.
Custom parameter types are declared per step file with param, alongside
steps — a chained .param() (TypeScript), the returned param function
(Python), the binder’s param (Java), or a param call inside the steps
block (Kotlin and Ruby). A type declared in one step file is not visible to
another file’s expressions — like state, parameter types are a per-file concern.