Type system
Brass is statically typed with flexible type inference. Every function body
is checked before it can execute; brass check produces the complete
whole-program verdict. Annotations constrain types, but are never required
for safety. See Execution model for when the default
run performs each check.
The inference is Hindley-Milner-style, unification over type variables, but deliberately not textbook HM; it deviates where a scripting language benefits:
- A polymorphic function is not generalized once into a principal type scheme; it is checked again at each call site with the actual argument types and compiled per concrete instantiation (monomorphization). This is what lets most code omit annotations without losing precision.
- Structural typing feeds inference: an unannotated parameter is constrained by the members the body actually uses, not by a nominal signature.
- Numeric literals default by magnitude and adapt to the context they flow into, and value-preserving numeric conversions are inserted implicitly at flow points (see Literals and conversions). Textbook HM would reject these mixed-type uses instead of converting.
Primitive types
Section titled “Primitive types”| Kind | Types |
|---|---|
| Signed integers | int8 int16 int32 int64 |
| Unsigned integers | uint8 uint16 uint32 uint64 |
| Floats | float32 float64 |
| Other | bool string void |
void is the no-value return type. There is no character type: a character
is a one-character string. Error diagnostics may additionally mention
never (the type of null before it meets a context, spelled never?); it
is not writable in source.
Literals
Section titled “Literals”- The default type of an integer literal is
int32when the value fits, otherwiseint64(so9223372036854775807is anint64). - The default type of a float literal is
float64. - A literal adapts to an annotated type when it fits:
let b: int8 = -128is fine,let b: int8 = 300is a compile error (never a silent wrap). A float literal adapts to either float width; an integer literal in a float context becomes a float. - The required type can also come from a container the value flows into: a
bare integer literal passed to a method of a map whose value type is pinned
to
int64(by a first store or a refinement annotation) is checked againstint64, so it types asint64rather than defaulting toint32(and anint32value widens at the call). INT64_MINcannot be written as one literal (-9223372036854775808overflows before the minus applies); the prelude constant exists instead.
Bracket literals
Section titled “Bracket literals”A bracket literal [...] is typed in this order:
- A type annotation (or another inference result, such as the parameter it is passed to) decides: the literal takes that type.
- Elements that cannot unify make it a tuple, but a
nullelement never does:nullunifies with any element type, so[4, null, 65]is a sequence ofint32?. - Bound immutably (
const), it is a fixed-length array:const a = [1, 2, 3]isint32[3]. - Bound mutably (
let) or in any other position, it is a growable array:let a = [1, 2, 3]isint32[].
A fixed-length array is usable where a growable array of the same element is
required (the length is extra static information), but not the reverse.
[lo..hi] builds the half-open integer range as an array.
Numeric conversions
Section titled “Numeric conversions”In operators
Section titled “In operators”An arithmetic or comparison operator between two numeric values of different
types implicitly converts both operands to their common type: the smallest
type both convert to value-preservingly. So int32 + int64 is int64,
uint8 + int32 is int32, and int32 + float64 is float64. Pairs with no
value-preserving common type (int64 with uint64, int64 with float64)
are a compile error; convert one side explicitly. + on two strings
concatenates.
Flow conversions
Section titled “Flow conversions”Numeric values also convert automatically when they flow into a numeric position of another type (an assignment, an argument, a return value, a compound assignment, or an element/field store), but only when the conversion is value-preserving:
- an integer into a strictly wider integer of the same signedness;
- an unsigned integer into a strictly wider signed integer;
float32intofloat64;- an integer into a float whose mantissa holds every value exactly: up to
int32/uint32forfloat64, up toint16/uint16forfloat32.
So let b: int64 = an_int32 and total += an_int32 (with total: int64)
both widen the value. Anything lossy (a narrower integer, a sign change, a
narrower float, int64 into float64, or float into int) never happens
implicitly; the error suggests the explicit conversion.
A T value also flows freely into a T? position. A nullable value never
flows into a non-nullable one; it must be narrowed first.
Explicit conversions
Section titled “Explicit conversions”| Conversion | Result | Notes |
|---|---|---|
intN.from(x) |
intN! |
range-checked; Err when out of range |
intN.parse(s) |
intN! |
parses a string |
floatN.from(x) |
floatN |
total: always succeeds, precision loss is accepted because it was asked for |
floatN.parse(s) |
floatN! |
parses a string |
string.from(x) |
string |
total; renders any value |
T.from(v) for a record type T |
T? |
structural conversion: see below |
int32.from(3.9) can fail (and truncates toward zero on success), so it
returns a Result; float64.from(big_int64) cannot fail, so it returns a plain
float even though it may round. Converting a uint64 uses the whole unsigned
range, including values above INT64_MAX. The prelude also provides free
function aliases (int32_from, int32_parse, float64_from,
float64_parse, string_from).
Parameter passing
Section titled “Parameter passing”How an argument is passed is part of the signature, and it is inferred when not annotated:
| Annotation | Passing | Callee mutation |
|---|---|---|
| (none, body only reads) | shared reference | rejected by inference (would reclassify) |
| (none, body mutates) | private deep copy at callee entry | stays local, invisible to the caller |
ref(T) |
immutable reference | rejected |
ref(mut(T)) |
mutable reference | writes through to the caller |
mut(T) |
mutable deep copy | stays local |
infer |
read-only deep copy | rejected; mutating an infer parameter is a compile error |
| (numeric type) | by value | n/a; numbers are copied |
Details:
- “Mutates” means a field or element store, a growing method (
push,insert,remove,pop), a loop-variable write-back, or passing the value on into a position known to mutate. Rebinding the parameter name (p = ...) is not a mutation of the caller’s value. - The deep copy happens at callee entry, once, driven by the parameter’s type.
ref(mut(T))also requires the argument to be mutable (let, notconst), even if the body does not currently mutate it.infermay be combined structurally:infer[]requires “an array, element type inferred”;infer?[]an array of nullables. Each occurrence ofinferis an independent inference hole.
The self receiver is a special case: unannotated self is always a
reference. A method that only reads self receives ref(Self); one that
mutates it receives ref(mut(Self)), so the change is visible to the caller.
Annotate self: Self to work on an owned deep copy instead (mutations stay
local).
Optional trailing parameters
Section titled “Optional trailing parameters”A trailing parameter of nullable type may be omitted at a free, static, or
instance call; it defaults to null. Several trailing nullable parameters may
be omitted together, followed by an implicit trailing Location when the
signature declares one. The prelude’s assert(cond, msg: string?) is callable
as assert(cond).
Records and structural typing
Section titled “Records and structural typing”type Name = { fields... } declares a nominal record type. A field without a
type annotation accepts any value; its type is fixed per construction site (a
record type with such open fields behaves as an inferred-generic type: each
use site gets its own instantiation).
Records and arrays have reference semantics: mutating through one binding
is visible through every binding that shares the object. const makes a
binding immutable (and forbids mutating through it).
When constructing a sum variant, each payload value flows through its declared field type, including the same numeric widening and nullable promotion used by record fields and function arguments.
Structural subtyping
Section titled “Structural subtyping”A value of a record type is usable wherever a structurally smaller record is
required: a function parameter constrains a value only by the members it
actually uses (unannotated parameters), or by the named type’s members
(annotated). A record with more fields satisfies a requirement of fewer
fields. Arrays are invariant in their element type. Sum types are nominal:
only the declared type matches, unless the sum declares a parent
(type Child: Parent), which admits it at the parent’s flow sites by
rebuild. See
Declared sum subtyping.
When an anonymous record ({ field: value, ... }) is passed to an
unannotated parameter, the compiler derives the parameter’s required “row” of
fields from the callee body (interprocedurally), checks the argument against
it at the argument’s own span, and compiles a view of the value for that
parameter.
Anonymous-record method dispatch
Section titled “Anonymous-record method dispatch”Calling a method on a structural value resolves it against the in-scope record types: those declared in or imported into the calling module (builtins and the implicit prelude count). If exactly one such type declares that method and the value satisfies that type’s fields, the call dispatches to it with no annotation. An anonymous value never adopts a type the module has not imported, even when its shape matches; the error names the satisfied type and the missing import. Zero candidates produce a near-miss diagnostic; several candidates make the call ambiguous: a compile error at the value asking for an annotation.
This scoping gates only the adoption of a type by an anonymous value. A value whose nominal type is already known (the return of an imported function, say) dispatches its methods by that type; the type’s name need not be imported.
Structural conversion
Section titled “Structural conversion”For a record type T, T.from(v) yields T?: the record value when v
structurally has all of T’s fields (decided for the actual value at that
call site), else null. Pair it with if let:
if let person = Person.from(obj) { ...}v may be of any type: a value that is not a record at all simply has
none of T’s fields, so the conversion answers null rather than failing to
compile. This lets one function take a value whose type differs per call site
and branch on what it turned out to be:
fun as_text(value) -> string! { if let p = Path.from(value) { // a Path: render it return p.to_string() } if value.chars { // a string: it is already text return value } return error("expected a string or a `Path`")}The second guard is a member-presence test: an uncalled member is a
compile-time question about the argument’s type, and the arm behind a member
that type does not have is statically dead: never checked, never emitted (see
Absent fields in conditions). Path.from, by
contrast, decides at run time, which is why the string case needs a guard of its
own: without it, return value would be checked against the string return with
value still a Path. The same guard is written more directly as
value.is_string, since each primitive type implements only its own
is_<type> method, so the presence test doubles as a type test (see
the reflection reference).
Type tests
Section titled “Type tests”if value: Type { ... } else ... tests the static type of value. The
test is decided entirely at compile time, per monomorphic instance of the
enclosing function: the subject’s concrete type either satisfies the tested
type or it does not, the selected arm alone is type-checked and compiled, and
the unselected arm is pruned exactly like the dead arm of a
member-presence test. Nothing is tested at
run time, and else if chains dispatch on the first satisfied test.
A type satisfies the test when it is:
- the tested type itself (representation-exact:
int32does not satisfy anint64test andTdoes not satisfy aT?test – matching never converts), or - a subtype of it: a record that
structurally satisfies the tested type’s fields
and methods (
anonymous { ... }and interface-style record types both work), or a sum that declares the tested sum as a parent. AT[]test also accepts a fixed-lengthT[n].
Inside the selected arm the subject keeps its own type – the test never reinterprets or rebuilds the value. (Passing the subject onward from the arm goes through the ordinary flow rules, views and rebuilds included.)
infer in the tested type is a hole:
- A hole constrained by the arm’s own use of the subject takes that type:
in
if v: infer { to_bytes(v) }the hole reads asstring, because that is whatto_bytesaccepts. The pinned type must come out the same for every instantiation of the enclosing function – a test whose holes resolve differently per call site is a compile error asking for an annotation. - A hole nothing constrains matches any type:
inferalone matches everything,infer[]matches any array.
Consequences of the per-instance decision:
- Inside a generic (unannotated) function, nothing is decided at the definition; each call site’s instantiation selects and checks its own arm. An arm no instantiation selects is never fully checked, like any other statically dead code.
- A path this instance never passes contributes NOTHING: neither the
unselected arms nor the statements a selected always-returning arm makes
unreachable. Their
returns do not join the instance’s return type – one arm may returnint32and anotherstring, each instance keeping its own – and anerror(...)on such a path does not make the instance fallible. - Fallibility is therefore per instance too: only an instance whose live path
reaches an
error(...)/!returns aResult. An instance whose live path ONLY errors behaves like any function that only returns errors: its Ok payload is uninhabited, and reading it concretely is rejected.
fun length(val) { const bytes = if val: infer { // string (to_bytes pins the hole) to_bytes(val) } else if val: uint8[] { // exact match, beats infer[] val } else if val: infer[] { // any other array val } else { return error("unsupported") } return bytes.len()}length("hi") compiles the first arm only and returns a bare int64;
length(to_bytes("hi")) the second, length([1, 2, 3]) the third – both
also bare. Only length(true) selects the else and returns a Result
(always its Err).
Interfaces
Section titled “Interfaces”type B: A = ... requires B to provide every member of A, checked at
compile time; multiple constraints are comma-separated (type B: A, C). When
A is itself a sum type, the declaration means
declared sum subtyping
instead: exact variant coverage, widening-only variants, and admission at the
parent’s flow sites by rebuild. For record constraints, no implementation is
inherited; the constraint is pure satisfaction:
- a required field must exist with an invariant type (fields are mutable, so a subtype field would be unsound);
- a required method signature compares the full function type: parameter types are contravariant, parameter passing modes must match, and the return type is covariant;
- for a sum type, every variant must satisfy the interface;
- conflicting field requirements from multiple constraints are reported at the type.
Methods
Section titled “Methods”Methods are implemented outside the type with fun T.m(...), in the same
module that declares T. A method whose first parameter is self is an
instance method (called value.m(...)); one without is a static method
(called Type.m(...)). Self in the body names the type. A method is in
scope wherever the type is, with no separate import.
There is no UFCS: a free function is never callable as recv.f(...), and
a method is never callable as f(recv, ...). The standard library defines
methods on primitive and array types with the receivers fun string.m,
fun string[].m, and fun infer[].m; user code cannot add methods to types
it does not declare.
Method return types are inferred like function return types. A method call on a value whose concrete type is not yet known is resolved when it becomes known, per instantiation.
Nullable
Section titled “Nullable”T? is a nullable type. null is its own value; T promotes into T?
freely.
An un-narrowed nullable allows only: the boolean test positions below,
x == null / x != null, and !x. Field access, indexing, arithmetic, or
passing it where T is required are compile errors
(“nullable value must be checked for null before use”).
Narrowing: inside these forms, the value has type T:
if x { ... }andif x != null { ... }: in the truthy branch;if !x { return ... }/if x == null { return ... }: after the guard, when the guard block always returns;if let y = x { ... }:yis the non-null value in the then branch.
A narrowed module global, or a local that a closure assigns, is re-widened after any call, since the call could reassign it.
Absent fields in conditions
Section titled “Absent fields in conditions”Inside a conditional, accessing a field the record does not have yields null
(type never?) instead of a compile error, and the branch folds to its
negative arm. This is what lets structurally typed code probe optional
fields (if person.name { ... }). Outside a condition, a missing field is
still an error, and a missing field on a sum type value is an error even in
a condition.
A condition the type alone decides, whether an absent member (always false)
or a present, non-nullable one (always true), folds statically, and everything
the fold makes unreachable is left unchecked: the arm that is not taken, and,
when the taken arm always returns, the statements after the if. The back end
folds the same branch and never emits that code, so a generic body can probe
its argument and let each instantiation take the arm that fits it:
fun as_text(value) -> string { if value._components { // a Path has this field; a string does not return value.to_string() // for a string: dead, not checked } return value // for a Path: unreachable, not checked}An ordinary bool condition is not statically known, so it never folds; this
does not hide errors in code that can run.
Result
Section titled “Result”T! is Result<T, E> over the Result declared in the prelude
(core/error.cz): an ordinary two-variant sum with Ok { value } and
Err { error }, resolved by normal scoping at every sugar site, so a module
may shadow it. The
error payload type E is inferred from the function’s error sources (all
error sites of one function must reconcile to one payload type).
error(x)is an ordinary prelude function (its name is reserved in call position) that buildsErr { error: Error { value: x, location: <call site>, frames: [] } }. The payload is the preludeErrorrecord, stamped with the caller’s position through the implicitLocationargument. Traces,context, and the rendering are specified in Error traces.- A function is fallible when its body uses
error(...)or a Result-operandexpr!, or its declared return type is a Result. In a fallible function,return vwith a plain value wraps it asOk { value: v }automatically; returning a Result value passes it through whole. - The postfix
!operator propagates:expr!unwraps anOkor returns theErrearly from the enclosing function. A propagated payload that is not already the preludeError(a builtin’s or plugin’s plain string, for example) is lifted into one at the propagation site, which is what lets one body mixerror(..)with!-forwarded builtin failures. !also accepts a declared subtype of the scope’s Result (type MyResult: Result, see Declared sum subtyping): the value is rebuilt as the parent at the operand and!proceeds on it.- On a NULLABLE operand,
expr!unwraps the value, and a null returns null itself early: the enclosing function’s return type gains an outer?(it does not become fallible). A body mixing bare returns,error(...), and a nullable!therefore infersResult<T, E>?: consume it by narrowing the?first, then matching the Result. An explicit non-nullable return annotation rejects a nullable!in the body. !is allowed inside any named function whose return can carry the failure, at the module top level, and inmain(not yet in closures, see Closures). At those two entry points a failed!does not propagate (there is no caller to receive it): the program aborts on stderr with a non-zero exit, using the nested[file:line:col] unhandled error:trace when the payload went througherror(..)/context, or the plainunhandled error: <payload>line (or the null message) when it never did.- Consume a Result by matching
Ok { value }/Err { error }. A payload raised byerror(..), or lifted at a!, matches as theErrorrecord: the original value iserror.value, anderror.display()renders the trace. - A function that can only ever
error(...)(no successful return) cannot be used where a value is required.
Separately, returns of null and returns of T in one function join to
T?.
Polymorphism and inference
Section titled “Polymorphism and inference”- Let-polymorphism:
let id = (x) -> xmay be used at several types; each use instantiates the inferred scheme freshly. - Function polymorphism: an unannotated function is re-checked per call
site with the concrete argument types, then compiled per instantiation. This
is stronger than a single inferred scheme:
fun add1(x) { return x + 1 }works forint32,int64, andfloat64callers alike. inferin a signature marks an inference hole explicitly; each occurrence is independent.- Generic records need no type parameters: leave fields unannotated (or
build containers empty) and each construction site fixes its own
instantiation. Methods share the record’s inferred parameters, so a
container’s
set/getagree on the element type without a witness value. fun T.m(self) -> infer!declares a reflective template whose result type is fixed by each call site’s expected type; see Compile-time reflection.- A reflective
-> infer!target is fixed by the expected type at its call site and specialized during front-end compilation. Native code for the resulting concrete function may still be compiled on first use; this is invisible to the type rules.
There is no explicit type-parameter syntax (<T> does not exist).
Type slots and refinements
Section titled “Type slots and refinements”A record can name its type parameters as slots (members declared with the
type keyword, type slot) and refer to them elsewhere with Self.slot. A
slot has no runtime storage: it never appears in the layout, in fields(), or
in a construction literal. It only names a type another field is expressed
over. (The older spelling slot: type – the type keyword as the member’s
declared type – is also accepted.)
type _Entry = { key value}
type Map = { type key // type slots: the key/value types, no storage type value entries: _Entry { key: Self.key, value: Self.value }?[] count: int64}Base { field: T, ... } is a refinement: it pins the named slots (and
fields) of a record, yielding a concrete instance. Written as the right-hand
side of an alias declaration it gives that instance a name:
type StringInts = Map { key: string, value: int64 }- An omitted slot stays open (inferred), so a partly-refined alias is still generic in the slots it does not mention.
- A slot may be pinned to anything; a real field that already has a concrete type may only be refined to that same type (a mismatch is rejected).
- The alias is not a new nominal: it unifies with any matching instance, so a witness-free value built by the container’s constructor is accepted where the refined type is annotated.
- Annotating a binding with the alias pins the container’s types up front, so
let m: Counts = HashMap.new()(withtype Counts = HashMap { key: string, value: int64 }) is a usablestring -> int64map: subsequent stores are checked against the pinned value type, so a bare integer literal or an int32 value stores as int64.
Field types are resolved like Hindley–Milner inference: each field and slot is
assigned a type variable and Self.field resolves to it. A field whose type
refers back to itself through the Self.field chain (a: Self.b,
b: Self.a) is a circular unification and is rejected by the occurs-check.
Match exhaustiveness
Section titled “Match exhaustiveness”A match whose scrutinee is a sum type must either name every variant or
contain a catch-all arm (_ or a whole-value binding). A variant arm counts
as covering its variant only when all of its field sub-patterns are
irrefutable (a literal field pattern makes the arm partial). Matches over
non-sum values (integers, strings) are not exhaustiveness-checked; add a _
arm. A function with a declared non-void return type must return a value on
every path (while true without break counts as diverging).
Return-flow analysis follows divergence through evaluated positions such as assignment right-hand sides and interpolation segments. Merely constructing a closure whose body returns does not make the enclosing path diverge.
Definite assignment
Section titled “Definite assignment”An annotated let may omit its initializer (let p: Point). The binding must
then be definitely assigned before use:
- assigning the whole binding completes it;
- for a record type whose field skeleton is default-constructible (numbers, bool, string, nullable, arrays, tuples, and records of those, not sums or functions), assigning every field individually also completes it;
- branches join by intersection (both arms must assign); paths that return or diverge drop out;
- a
for field in fields(x)loop that assignsx[field]on every non-exiting path counts as assigning all fields (see Reflection); - reading the binding, or capturing it in a closure, before completion is a
compile error.
typeof(x)andfields(x)only read the type and are allowed; - the same checks apply inside closure bodies and module initializers, and to reads nested in assignment right-hand sides or interpolation segments.
Recursion and program order
Section titled “Recursion and program order”- Self-recursion needs no annotations: a recursive call is typed against the function’s declared or previously inferred return type.
- Mutual recursion should carry return-type annotations on the functions in the cycle; each recursive call then types against the annotation. Without them the checker may be permissive, but the back end can reject the cycle when it cannot fix a concrete return type.
- Functions and types may be used textually before their definitions. A module-level binding may not: globals initialize in order, and reading one before its initializer has run is a compile error.
Closures
Section titled “Closures”Closures capture by reference: the closure sees (and may mutate) the live binding, and mutations through the closure are visible outside. A closure’s parameter and return types are inferred (annotations optional); a closure used polymorphically instantiates per call. A closure parameter shadows a global function of the same name; the local value is called.
Closures cannot yet be fallible: a closure body that uses error(...) or
a Result-operand ! is not supported (it currently fails when the closure is
compiled or called). Move the fallible logic into a named function and call
that from the closure.
Concurrency typing
Section titled “Concurrency typing”spawn(f) requires a zero-parameter closure whose return type is void (or
still open), and returns void; with(c, f) requires a one-parameter closure
and returns the closure’s result; sync() takes nothing. Ownership analysis of
captured values happens after type checking; see the
concurrency reference.