Schemas and validation
Schemas
type declares a schema; new instantiates one with checking:
type Container
name: String
image: String
port: Int
end
main: new Container
name: "gateway"
image: "company/gateway:2.4.1"
port: 8080
end
A short instance fits on one line, with commas between the fields (D38). It is checked exactly as the block form:
sidecar: new Container name: "proxy", image: "envoy:1.30", port: 9901 end
What is checked at instantiation:
| Situation | Code | When |
|---|---|---|
| A schema field is missing | E0511 | always |
Wrong type (Int vs String, Int vs Float) | E0512 | always |
| An extra field the schema does not declare | E0513 | --strict only |
A default is checked where the schema is declared, not only where it is
used. q: Int = "not an int" is E0516 at the type, because a field that
callers always supply explicitly would otherwise never reach new with its
default, and the declaration would go on claiming something impossible.
Only literal defaults are judged — what an arbitrary expression evaluates to is
not knowable before it runs, and new still checks the result.
type Quota
# `Int? = null` — the marker admits null, the default makes the field
# optional. The marker alone would still require the field at `new`.
limit: Int? = null
end
Field types are String, Int, Float, Bool, List and Object. Int and
Float are separate types, so memory limits in bytes and 64-bit identifiers keep
their precision — overflow is E0304, not a silent wrap.
Fields that may have no value
A field holds a value of its type and nothing else. When a value is genuinely
absent — an unlimited quota, a deadline nobody set — mark the field with ?:
type Plan
id: String
quota: Int?
end
unlimited: new Plan
id: "enterprise"
quota: null
end
This exists to kill the sentinel. Without it, “unlimited” gets written as -1
or 2147483647, and everyone has to remember which number means what and where.
A sentinel is a valid value of its type, so nothing ever catches a wrong one.
? answers one question only: may the value be absent. Whether the field can be
left out is the separate job of = default, and the two compose:
| Declaration | Omitting it | Writing null |
|---|---|---|
quota: Int | E0511 | E0512 |
quota: Int? | E0511 | allowed |
quota: Int = 100 | gives 100 | E0512 |
quota: Int? = null | gives null | allowed |
A nullable field is still type-checked when it does hold a value, and
aura types emits Option<i64>, number | null and *int64.
The one way ? can cost you
? widens what a field accepts, and that is exactly what makes it worth a
second look when the value comes from outside:
quota: data.get("quota") # no fallback
Without ? a missing key is E0512 and the run stops, so two people with
different input files find out immediately. With ? the same difference passes
as null, and they ship different configurations without a word.
That combination is W0513, and under --strict it is an error:
[W0513] Warning: 'quota' is nullable and is filled from `get(key)`, so a
missing value becomes null instead of an error
Help: name the value that stands for absence: `get(key, fallback)`
— or drop the `?` so a missing one is reported
env(name) without a fallback is the same case. Naming the fallback is all the
warning asks for: data.get("quota", 0) says which value means absence, and
says it in the manifest rather than in someone’s head.
Lists with an element type
List on its own accepts any elements. Write [T] to say what is in it:
type Service
name: String
tags: [String]
endpoints: [Endpoint]
end
The type mirrors the value: [1, 2] is a list, so [Int] is its type. Nesting
follows with no new rule, [[Int]], and an enum works as the element type.
A wrong element is reported with its index, because “one of these is wrong” is no help in a list of forty:
[E0512] Error: endpoints[0] of schema Service expects Endpoint, got String
This also reaches the host. aura types emits Vec<Endpoint>, Endpoint[] or
[]Endpoint instead of an untyped array.
Bare List keeps its old meaning, so nothing written before this needs changing.
A field typed by a schema holds what new would accept
A field typed Port, [Port] or [[Port]] holds values that new Port would
accept - wherever they came from (D41). An element read from JSON, YAML or a
host is checked by the same rules as one written with new: required fields,
types, enums and invariants.
type Port
number: Int
protocol: String = "tcp"
assert number > 0, "port must be positive"
end
type Service
ports: [Port]
end
raw = read_file("ports.json").parse_json()
svc: new Service ports: raw end
A wrong value names the way down to it:
[E0515] Error: Port: port must be positive (in ports[2])
[E0511] Error: missing field 'port' required by schema Endpoint (in routes[1].target)
How it behaves, and why
- Structural, not nominal. A value carries no mark of the schema that made
it, so the question is “would
new Portaccept this?”, not “was this made bynew Port?”. Data from outside passes when it is right, without being rebuilt field by field through.map. - Defaults are filled in.
{"number": 80}in a[Port]field becomes{"number": 80, "protocol": "tcp"}, in the schema’s field order, exactly asnewwould make it. One schema gives one shape of output, whatever the source - which is whataura typesand a host reading the result rely on. - An instance made by
newis checked again. The checks are pure and the defaults are already there, so nothing changes; the alternative, a hidden mark saying “already checked”, would be state a reader cannot see. - Extra fields follow
new: kept, andE0513under--strict. - A schema may hold itself -
children: [Node]- to any depth up to 32 levels; deeper isE0208rather than a crash, since data, unlike code, can be built to any depth.
Before D41 such a field took any object at all: one without number, or one
whose number broke the invariant. A manifest that relied on that now stops
with an error that says which element is wrong.
Rules the schema states about itself
A type says what shape the values have. It can also say what they must be:
type Plan
price_monthly: Int
price_yearly: Int
max_channels: Int
assert price_monthly >= 0, "price must not be negative"
assert max_channels > 0, "a plan needs at least one channel"
assert price_yearly <= price_monthly * 12, "yearly must not exceed twelve months"
end
These run on every new Plan, and a failure is E0515 carrying the message the
schema wrote:
[E0515] Error: Plan: yearly must not exceed twelve months
The third one is the reason this is worth having: it relates two fields, which no per-field constraint can express. It needs no ordering rule either, because every field is filled and type-checked before any invariant runs.
Why not just write the assert next to the new
Because then it is not part of the type. It does not travel with the schema, so an importer gets the fields without the rules; tooling cannot see it; and the next person to instantiate the schema simply will not write it.
Put it in the schema and it holds everywhere, including in a manifest whose author never read your package:
import "./lib.aura" as lib
p: new lib.Port
number: 80 # E0515: Port: ports below 1025 are privileged
end
What an invariant can see
Its own fields, and nothing else. Not the module the schema was declared in, not
the one the new was written in:
[E0504] Error: use of undefined variable 'limit'
Help: an invariant sees only this schema's fields (q), so it means the
same in every manifest that uses the schema; pass anything else
in as a field
That is deliberate. A rule that could read a module variable would mean something different in each manifest that imported the schema — which is the problem invariants exist to solve. It also means nothing effectful is reachable from one, so an instance is valid or invalid identically for everyone.
assert
Arbitrary invariants are the assert statement:
admins = [187650342, 244179081]
assert admins.uniq().len() == admins.len(), "Duplicate admin ids"
assert admins.len() >= 1
A failure is E0530, with your message and an exact position. assert also runs
under --dry-run: a rehearsal has to be able to find a validation failure.
fail inside an expression
For validation in the middle of an expression there is fail:
port: p > 0 && p < 65536 ? p : fail("invalid port #{p}")
Closed sets: enum
A String field accepts any string, so a typo in a value rides all the way to
production. enum declares a closed set of permitted strings:
enum Tier
"frontend"
"backend"
end
type Service
tier: Tier
end
svc: new Service
tier: "backand" # E0514, with "did you mean \"backend\"?"
end
An enum member is an ordinary string: the JSON contains "backend" and no
wrapper appears. It constrains the value; it does not introduce a new data type.
pub enum is exported to importers (D12). Members resolve where the schema is
declared, so an imported schema is validated against its own module’s enum —
even if you have one of the same name in scope.
Static analysis
aura check — and automatically before every eval — finds problems before
anything runs:
E0504— use of an undeclared variable, including inside#{...};W0501/W0502/W0503— unused variables, imports, functions and types;W0512— an effectful call (env,read_file) in an imported module.
Under --strict warnings become errors. That is the mode for CI.