Mica’s type system answers one question about every value: what operations does this type support? The answer is not a list of special cases per type — it is a set of capabilities the type carries, and every rule in the compiler is written against capabilities rather than against names.

That is why the rules generalise. for i := lo to hi works over integers, enums, subranges, characters and error codes, because the rule asks for ordinal and not for int64. A generic constraint T is numeric admits exactly the types carrying numeric. A task parameter is admitted when its type carries plain. One vocabulary, read everywhere.

The capabilities

CapabilityWhat it grants
numericthe arithmetic operators + - * / and mod
orderedthe relational operators < <= > >=
equality= and #
logicaland, or, not
integralwhole-number operations (signed and unsigned integers)
fractionaloperations with a fractional part (floating point)
dereferenceablevalue p, reading through a pointer
addressableaddress x, taking the address of a place
convertiblex as T, the explicit conversion
negatableunary minus — signed numeric types only
callablea call, as a statement or as an expression
selectablefield access r.f
indexableelement access a[i]
ordinala value with a position in a discrete, ordered domain — see Ordinals
plainevery byte of the value is the value: no string, dynamic array, pointer or other heap-backed part anywhere in its layout
bitsthe bit-window vocabulary of the bits unit (count, scan, rotate, reverse, extract)
domainstands where an error domain stands — the fails position of a signature or a stream spelling. The constraint a generic adapter writes as E is domain, so one export can pass a source’s failure through unchanged

Two of these carry more weight than their one-line description suggests, and both have their own section below: ordinal, which decides where a value may be counted and dispatched on, and plain, which decides where a value may be transported byte-for-byte.

The primitive types

TypeWidthCapabilities beyond equality, addressable, plain
int8 int16 int32 int641 · 2 · 4 · 8 bytesintegral numeric ordered ordinal negatable convertible
uint8 uint16 uint32 uint641 · 2 · 4 · 8 bytesintegral numeric ordered ordinal convertible bits
float32 float644 · 8 bytesfractional numeric ordered negatable convertible
bool1 bytelogical ordinal
unicode4 bytesordered ordinal

Read the float row against the integer row: a float is numeric and ordered like an integer, and deliberately not ordinal. It has no successor, no position in a discrete domain, and therefore no place in a case, an array index, or a for control variable. That single missing capability is the whole of the difference the rules see.

The constructed types

KindWrittenCapabilities
recordrecord … endequality addressable selectable, plus plain when every field is plain
fixed arrayarray[lo..hi] of Tequality addressable indexable, plus plain when T is plain
dynamic arrayarray of Tequality addressable indexable — never plain: the descriptor names a heap block
conformant arrayarray[lo..hi : int64] of T parameteraddressable indexable — deliberately not equality: a view is a transient borrow
spanspan of Tthe conformant view’s positional spelling — the same borrowed-window descriptor under the same capabilities, declarable anywhere a type is named
vectorvector[N] of Ta fixed array carrying the vector tensor class: equality addressable indexable plain, with 0-based length-spelled dimensions and the linear-algebra operators (+ - elementwise, * scaling and the matrix product, · the dot) joining through their own shape rules
matrixmatrix[R, C] of Tthe two-dimensional tensor, row-major — the vector row’s capabilities and operators over two dimensions; Transpose answers matrix[C, R] of T. A tensor declared on gpu keeps its type and moves its residency: the value lives device-side and crosses only at ToDevice/ToHost
trackedtracked Tnone — deliberately: a tracked value’s operations are the autograd rules that record onto the tape, never the capability-gated machinery. Assignment copies both halves of the pair, so a value’s history travels with it
setset of <ordinal>equality addressable plain
bitsetbitset[N]equality addressable plain indexable convertible
bitrecordbitrecord … endequality addressable plain selectable convertible
subrangelo..hiequality ordered addressable ordinal plain
enum(A, B, C)equality ordered addressable ordinal plain
error domainerror (Code, …)equality addressable ordinal plain — deliberately not ordered and not convertible: codes are categories, and no cast bridges into or out of a domain
wide integerint[128] uint[256] … — the integers unit publishes int128 through uint256 as importable names over the same familynumeric ordered equality integral addressable plain convertible — deliberately not ordinal, because the ordinal machinery computes in an int64 domain a wide value cannot enter
pointerpointer Tequality dereferenceable
stringstringequality indexable ordered
stringbufferstringbufferaddressable
stringpartstringpartequality only — a borrowed view, deliberately minimal
filefile of Taddressable
streamstream of Tnone — a stream is consumed by pulling, never operated on

plain — the transport capability

A value is plain when its bytes are its value: no string, dynamic array, pointer, or other heap-backed part anywhere in its layout. Records and fixed arrays inherit it — they are plain exactly when every part is.

Plainness is what makes a byte copy a correct copy, so it is the admission rule everywhere a value is transported rather than merely assigned:

  • a task parameter, deep-copied at schedule;
  • a generator parameter, deep-copied when the instance opens;
  • a const parameter — the callee’s promise never to write its value, enforced across the callee’s whole body. Every plain type is admitted, and a plain aggregate wider than 16 bytes arrives as one address into caller-owned read-only storage instead of a copy: the same value under the same call-site spelling, with the copy’s absence unobservable by construction;
  • a file of T, whose records are written as raw fixed-size images;
  • the C boundary, where a Mica value is handed to a foreign frame.

A record holding a string is not plain, and the refusal says so: copying its bytes would hand the destination a second name for one owner’s memory, which is the hidden sharing the value model exists to prevent.

Promotion — what happens implicitly

Mica promotes widening, value-preserving conversions automatically, so mixed-width arithmetic reads the way it does on paper:

var
    small : int32;
    large : int64;
    ratio : float64;
begin
    large := small;          { int32 → int64: every value survives }
    ratio := large;          { int64 → float64: the numeric tower widens }
    ratio := ratio * small;  { the int32 operand promotes to float64 }

The rule is exactly “every value of the source type is a value of the target type”. What that rules out is as important as what it allows: int64 → int32 is not promotion, and neither is float64 → float32, because both lose values. Those need a cast, which is you taking responsibility for the range.

Do not write casts that promotion already performs. large := small as int64 says nothing the compiler did not already know, and it trains the eye to skim past casts — which is exactly where the value-losing ones hide.

Casts — what happens explicitly

x as T is the explicit conversion, admitted when the source type carries convertible. It is one syntax over two distinct operations, and the difference matters:

BetweenWhat happens
numeric typesa value conversion2.5 as int64 is 2, and a narrowing integer cast is range-checked on the checked tier
a bitset and an unsigned integera reinterpretation — bit i carries value 2^i, exact-width or widening only

That distinction is why a float64 cannot ride an 8-byte word in the task spawn protocol even though it fits by size: the trip through the word is a numeric conversion, so 2.5 would arrive as 2. The type system’s answer is that a float is transported by its bytes, like a record — the same plain road.

An error domain carries no convertible at all. A domain value is born from its code names and from the failure channel, never from an integer.

Counts and indices — one signed domain

Every count and every index answers in the signed 64-bit domain, so the two meet without a conversion standing between them:

ReaderAnswers
Length(x) — a string, a fixed array, a tensor, a dynamic array, a spanint64, how many elements the value has
Capacity(d) — a dynamic arrayint64, how many its current backing can hold
Low(a) / High(a) — an integer-indexed array, a dynamic array, a spanint64
Low(a) / High(a) — an enum-indexed arraythe enum, because that is what its indices are

A count is signed because a count is arithmetic: Length(x) - 1 over an empty sequence is minus one, which is the answer a loop bound needs, and not the largest representable unsigned value.

Two counts read a shape rather than a run. A matrix answers its whole row-major element block — the same count its read view and every reduction over it walk — because a length says how many elements a value has and nothing else; the shape stays in the type, where the program wrote it. A span answers the width of the window it borrows, not the extent of the storage behind it.

Strings and the build encoding

A string is not plain, and it carries one more property no other type has: its in-memory representation is fixed per build, not per value.

--platform linux,amd64,utf-32 compiles every string in the program as UTF-32; --platform linux,amd64,utf-8 compiles every string as UTF-8. Encoding is never a property a single value carries, because a program in which two strings had different representations would need a tag on every string operation.

This has one consequence a program meets on its first line of output:

EncodingThe WriteLn conversion for a string
utf-32%ls
utf-8%s
WriteLn("hello %ls", name);   { correct under utf-32, refused under utf-8 }
WriteLn("hello %s", name);    { correct under utf-8, refused under utf-32 }

The compiler checks the conversion against the build’s encoding and refuses the mismatch, so this is never a silent wrong-output bug. But it does mean one source file cannot print text under both encodings: an example, a tutorial snippet, or a library’s own sources must pick one. Length is unaffected — it answers a count of runes under both encodings — and so is everything that does not name a conversion specifier.

Text that crosses a boundary is a separate question with a separate answer: the net unit puts text on the wire as UTF-8 whichever encoding the program was compiled for, so a utf-32 program and a utf-8 program read each other exactly.

Case totality — every value answered

A case must say what happens to every value of its selector, and the compiler checks that it does. Over a bounded domain — an enum, an error domain, a bool, a subrange, a char, or an integer width through 32 bits — the arms must cover the domain or the statement must carry an else; the refusal counts the covered values against the domain. A 64-bit selector requires an else unless its range arms provably tile the whole domain. A masked case over a bitset always carries its default arm — an else or a full don’t-care pattern — because coverage by masked patterns is not provable. And an else behind arms that cover the whole domain is refused, exactly as a shadowed arm is: it can never fire, and its absence is what makes a later addition to an enum break every total dispatch site by name instead of being silently swallowed. A subrange dispatches over its declared domain: arms tiling 8..17 need no else, because naming a domain buys dispatch over exactly it.

Where the rules read capabilities

The vocabulary above is not documentation of an implementation detail — it is the surface the diagnostics speak:

The ruleReads
for i := lo to hiordinal on the control variable
case selector and labelsordinal
a[i] on a fixed arrayordinal on the index
set of Tordinal on T
a task or generator parameterplain
file of Tplain on T
a generic constraint T is numericthe named capability, exactly
a stream elementone plain store of the declared width — every scalar, no aggregate
x as Tconvertible on the source
address xaddressable
value pdereferenceable

When a diagnostic says a type “requires convertible” or “must be a plain value”, it is naming the row of this table it consulted — which is why the fix is usually to change the type rather than to argue with the rule.

Where a generic constraint is enforced

A constraint is checked where an operation happens, not where a type is merely stored. A generic type template holds its parameters without ever operating on them, so its parameter list carries no constraint of its own:

type Box of T = record item : T; end;

var
    counted : Box of int32;
    named : Box of string;    { admitted — a box only stores }

The constraint arrives with the first function written over the type, and it is that call which is refused:

function Unwrap(b : Box of T) : T;
gen
    T is numeric;
analyzer error 5210: type argument 'string' for type parameter 'T' does not
satisfy its constraint (requires numeric)

The division is deliberate: bounds belong on the functions that act, not on the definitions that hold. It keeps a container usable at every type it can physically store, and puts the requirement exactly where the requirement is real.