Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

SituationCodeWhen
A schema field is missingE0511always
Wrong type (Int vs String, Int vs Float)E0512always
An extra field the schema does not declareE0513--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:

DeclarationOmitting itWriting null
quota: IntE0511E0512
quota: Int?E0511allowed
quota: Int = 100gives 100E0512
quota: Int? = nullgives nullallowed

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 Port accept this?”, not “was this made by new 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 as new would make it. One schema gives one shape of output, whatever the source - which is what aura types and a host reading the result rely on.
  • An instance made by new is 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, and E0513 under --strict.
  • A schema may hold itself - children: [Node] - to any depth up to 32 levels; deeper is E0208 rather 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.