Constraints
A where clause refines a type with a predicate. A value of the type must
pass the predicate wherever it enters the type: a typed binding, a function
parameter, a return value, a constructor, or an update. A value that fails
becomes an ordinary Result.Err holding a ConstraintError.
Named Refinements
Add where and a predicate to a type declaration. % is the value being
checked:
Port type Int where between(%, 1, 65535)
Cents type Int where gte(%, 0)
Username type Str where between(length(%), 3, 20)
Port(8080) → 8080
Port(70000) → Err(ConstraintError.Violation)
Username("al") → Err(ConstraintError.Violation)
A refinement's values are plain values of its base type. There is no type tag:
arithmetic, equality, and JSON work as they do for the base type. is-type
checks the base type and runs the predicate.
port: Port 8080
next-port add(port, 1)
next-port → 8081
to-json(port) → "8080"
is-type(443, Port) → true
A struct keeps its type tag. For a constrained value with its own identity,
use a struct with a where clause.
Fields and Invariants
A where can follow any field type, an element type, or a whole struct. A
where after the closing brace is an invariant of the whole record. It can go
on the same line as the brace or on the next one.
LineItem type {
sku: Str,
quantity: Int where between(%, 1, 99),
price: Cents,
}
Order type {
items: Vec<LineItem> where between(length(%), 1, 100),
discount: Cents,
subtotal: Cents,
} where lte(%.discount, %.subtotal)
Order({..., items: [{..., price: -5}]})
→ Err(ConstraintError.Violation), path ["items", 0, "price"]
Order({..., discount: 900, subtotal: 500})
→ Err(ConstraintError.Violation), path []
Checks run innermost first: each element and field, then the record's
invariant. The error's path leads to the part that failed.
Bindings
A typed binding takes a where after its value. A line that starts with
where continues the binding above it.
max-retries: Int 3 where between(%, 0, 10)
batch-size: Int div(1200, 4)
where between(%, 1, 500)
The where can also follow the declared type, as in
max-retries: Int where between(%, 0, 10) 3. A binding takes one where
clause. Combine conditions with and. A binding needs a declared type to
take a where; use : Any for an untyped value.
Parameters and Return Values
A where after a parameter's type checks that argument. A where after the
return type checks the value the function returns.
retry-delay fn (attempt: Int where between(%, 1, 10)): Int where lte(%, 30000) {
mul(mul(attempt, attempt), 1000)
}
retry-delay(3) → 9000
retry-delay(0) → Err(ConstraintError.Violation), boundary "parameter"
retry-delay(6) → Err(ConstraintError.Violation), boundary "return"
A failing argument returns its Err before the body runs. The return check
runs only when the body succeeds; an Err from the body passes through
unchanged. A function that calls itself in tail position keeps its return
check and its constant stack.
Preconditions
A where after the parameter list checks the arguments together. Its % is a
record of the arguments, keyed by parameter name. A long signature can continue
on later lines:
withdraw fn (amount: Int, balance: Int)
where and(gt(%.amount, 0), lte(%.amount, %.balance))
: Int where gte(%, 0)
{
sub(balance, amount)
}
withdraw(10, 100) → 90
withdraw(150, 100) → Err(ConstraintError.Violation), boundary "arguments"
Parameter checks run first, then the precondition, then the body.
Rest Parameters
A where on a rest parameter checks each argument it collects. A failing
argument's index is the error's path. A precondition sees the rest parameter
as a Vec:
tag-count fn (...tags: Str where not(is-empty(%)))
where lte(length(%.tags), 3)
: Int
{
length(tags)
}
tag-count("a", "b") → 2
tag-count("a", "") → Err(ConstraintError.Violation), boundary "parameter", path [1]
tag-count("a", "b", "c", "d") → Err(ConstraintError.Violation), boundary "arguments"
Nullable Types
Username? accepts null. A non-null value must satisfy Username. A where
on a nullable type sees null too, so its predicate has to handle it:
nickname: Username? null
SmallOrMissing type Int? where if(is-null(%), true, lt(%, 10))
Writing Predicates
A predicate is either a % expression or a lambda with one parameter:
Int where gt(%, 2) and Int where (x) { gt(x, 2) } are the same. As with
any lambda, its { goes on the same line as its ). A predicate can call
your own functions:
valid-extension-days fn (days: Int): Bool {
and(gte(days, 1), lte(days, 3))
}
ExtensionDays type Int where valid-extension-days(%)
A predicate runs at namespace scope. It can use the functions and top-level
values of its namespace, but not a function's parameters or local bindings,
even when its where appears inside that function. To relate one parameter to
another, use a precondition.
A predicate must:
- Return a
Bool. Truthy values don't pass, andnulldoesn't pass. - Be pure. It can't use the clock, randomness, environment, context, files, stores, HTTP, events, tasks, or sockets, directly or through the functions it calls.
- Finish quickly. Each check has an instruction and call-depth budget.
length of a Str counts UTF-8 bytes, not characters, so length("café") is
5. Keep that in mind when you bound text length.
A where predicate is an ordinary function argument under Hot's % rule, so
an implicit % lambda can't appear inside it. Write an inner function
explicitly:
Positives type Vec<Int> where all(%, (n) { gt(n, 0) })
Small type Vec<Int> where all(%, %(lt(%, 10)))
Handling Constraint Errors
A failed check is a Result.Err whose value is one of these:
| Variant | When |
|---|---|
ConstraintError.Violation | The value failed the predicate, or isn't of the base type |
ConstraintError.EvaluationFailed | The predicate errored, returned a non-Bool, or called a function a predicate can't use |
ConstraintError.BudgetExceeded | The predicate ran out of its budget |
Each holds these fields:
| Field | Value |
|---|---|
message | A summary of the failure |
constraint | The named refinement, such as ::shop/Port, or <inline> |
predicate | The predicate's source text |
boundary | Where the check ran: binding, constructor, parameter, arguments, return, or update |
path | Steps from the checked value to the part that failed. Empty when the value itself failed |
declared-type | The declared type at the boundary, when there is one |
value-type | The type of the rejected value |
function | The function, for parameter, argument, and return checks |
parameter | The parameter name, for parameter checks |
location | The source location of the where clause |
ConstraintError.EvaluationFailed adds cause: the underlying error, or a
description of what the predicate returned. ConstraintError.BudgetExceeded
adds budget ("instructions" or "depth") and limit. The rejected value
itself is never included.
describe-port fn (value: Any): Str {
result Port(value)
if-err(result, (e) {
match e {
ConstraintError.Violation => { `invalid: ${e.message}` }
_ => { "could not check" }
}
})
}
describe-port(80) → 80
describe-port(0) → "invalid: constructor of `Port` failed `between(%, 1, 65535)`"
err-message returns the message.
Literal Unions
A literal union is a constraint too. A closed union accepts only its members,
at every boundary: parameters, typed fields, constructors, return values, and
updates. Any other value is Err(ConstraintError.Violation) when it arrives
at run time. An open union also accepts unknown values of the same type,
which suits values an external service may add to over time. Use open for
values that come from outside your code, such as API responses.
Currency type "USD" | "EUR" | "GBP"
TrackLinks type open "None" | "HtmlAndText" | "TextOnly"
Currency("JPY") → Err(ConstraintError.Violation)
TrackLinks("HtmlOnly") → "HtmlOnly"
Updates
assoc, assoc-in, update, update-in, merge, and deep-path assignment
keep a typed value's type, so they check its constraints too:
Server type {
host: Str,
port: Port
}
server Server({host: "localhost", port: 8080})
moved assoc(server, "port", 9090)
assoc(server, "port", 9090) → Server({host: "localhost", port: 9090})
assoc(server, "port", 70000) → Err(ConstraintError.Violation), boundary "update"
The checks reach typed values nested inside the one you change. assoc-in,
update-in, and merge check every typed value along the way, not only the
outermost one. postwalk and prewalk check each typed value they rebuild.
Map spread ({...server}) and transforms like map-vals and delete return
plain maps, which claim no type and are not checked.
Conversions
Where a value meets a constraint, Hot only converts numbers exactly: an Int
becomes a Dec, and a whole Dec such as 5.0 becomes an Int. 7.9 is
rejected rather than truncated, and "5" is rejected rather than parsed.
Convert explicitly first, as in Port(Int(text)). Parameters without a
constraint keep their usual conversions.
Checked Before Running
hot check reports constraint problems it can find without running the
program:
| Code | Severity | When |
|---|---|---|
constraint-violation | Error | A literal fails its constraint, such as Port(70000) |
constraint-not-bool | Error | A predicate definitely returns something other than a Bool |
impure-constraint | Error | A predicate calls a function a predicate can't use |
unsupported-constraint | Error | A lazy parameter has a constraint, or a function with lazy parameters has a precondition |
invalid-constraint | Error | A predicate isn't a function of one value, or a parameter's predicate uses another parameter |
duplicate-where | Error | A binding or type has more than one where clause |
where-requires-type | Error | A where follows a binding without a declared type |
nested-placeholder | Error | An implicit % lambda inside a predicate |
unknown-literal | Warning | A literal outside an open union's known values |
where-changes-representation | Warning | A refined primitive type used with is-type or as a match arm |
Tool Schemas
MCP and agent tool schemas include a constraint's common shapes:
- Comparisons with a literal bound, and
between, becomeminimumandmaximum. - Length bounds on a
VecbecomeminItemsandmaxItems. ::hot::regex/is-matchwith a literal pattern becomespattern.inwith a literal list becomesenum.- A literal union becomes a typed
enum, such as{"type": "string", "enum": ["USD", "EUR", "GBP"]}.
Length bounds on a Str don't become minLength or maxLength, because
length counts bytes and JSON Schema counts characters. Like every other
predicate, they are still enforced, and the schema's description states them.
A precondition goes in the tool's description.
Summary
Type where predicaterefines a type;%is the value being checked- Refinements check bindings, parameters, return values, constructors, and updates
- A failed check is a
Result.Errholding aConstraintError - Predicates are pure, return a
Bool, and run within a budget - Literal unions are constraints;
openunions accept unknown values - Predicates run at namespace scope and can't see parameters or locals