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, and null doesn'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:

VariantWhen
ConstraintError.ViolationThe value failed the predicate, or isn't of the base type
ConstraintError.EvaluationFailedThe predicate errored, returned a non-Bool, or called a function a predicate can't use
ConstraintError.BudgetExceededThe predicate ran out of its budget

Each holds these fields:

FieldValue
messageA summary of the failure
constraintThe named refinement, such as ::shop/Port, or <inline>
predicateThe predicate's source text
boundaryWhere the check ran: binding, constructor, parameter, arguments, return, or update
pathSteps from the checked value to the part that failed. Empty when the value itself failed
declared-typeThe declared type at the boundary, when there is one
value-typeThe type of the rejected value
functionThe function, for parameter, argument, and return checks
parameterThe parameter name, for parameter checks
locationThe 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:

CodeSeverityWhen
constraint-violationErrorA literal fails its constraint, such as Port(70000)
constraint-not-boolErrorA predicate definitely returns something other than a Bool
impure-constraintErrorA predicate calls a function a predicate can't use
unsupported-constraintErrorA lazy parameter has a constraint, or a function with lazy parameters has a precondition
invalid-constraintErrorA predicate isn't a function of one value, or a parameter's predicate uses another parameter
duplicate-whereErrorA binding or type has more than one where clause
where-requires-typeErrorA where follows a binding without a declared type
nested-placeholderErrorAn implicit % lambda inside a predicate
unknown-literalWarningA literal outside an open union's known values
where-changes-representationWarningA 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, become minimum and maximum.
  • Length bounds on a Vec become minItems and maxItems.
  • ::hot::regex/is-match with a literal pattern becomes pattern.
  • in with a literal list becomes enum.
  • 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 predicate refines a type; % is the value being checked
  • Refinements check bindings, parameters, return values, constructors, and updates
  • A failed check is a Result.Err holding a ConstraintError
  • Predicates are pure, return a Bool, and run within a budget
  • Literal unions are constraints; open unions accept unknown values
  • Predicates run at namespace scope and can't see parameters or locals