Skip to content

Types, constants and generics

Magscript is statically typed. Signal types are:

  • scalars
  • fixed length arrays
  • named types (composite or enum), declared locally or imported

Numeric types do not implicitly convert: f32, f64, and i32 must agree. A float literal such as 1.0 takes f32 or f64 from context. An integer literal is always i32, so an f64 needs 1.0, not 1.

Type Meaning
f64, f32 64 bit or 32 bit floating point
i32 32 bit signed integer
bool true or false
[T; N] fixed length array of N values of T

Array lengths are part of the type. Arrays can nest, and an array literal uses square brackets: [1.0, 2.0, 3.0]. Lengths and indexes must be integer literals; indexes are zero based and checked against the declared length.

  • + and - work on matching numeric scalars or same shaped numeric arrays.
  • * and / work on matching scalars; a scalar can also scale an array (a * v or v * a, and v / a).
  • .* and ./ are same shaped array arithmetic; use dot(a, b) for an inner product.
  • % is numeric scalar only.
  • Comparisons return bool: ordering requires numeric scalars; == and != also accept booleans and enums. Arrays and composites cannot be compared.
  • Boolean operators require bool.
composite State {
position: [f64; 3],
enabled: bool,
}
enum Mode { Idle, Running }

A composite is a value record. Construct it with State { position: p, enabled: true } and read fields with state.position or state.position[0]. Supply every field exactly once, or use State { enabled: false, ..state } to copy omitted fields from a value of the same type. Composites can nest but cannot contain themselves, directly or indirectly.

Enums contain named variants without payloads, written Mode::Idle. They support equality, but no arithmetic, ordering, or ZERO value.

Named types are identified by their declaring module and name. Identical declarations in different files are different types. Model fields can also hold child model instances; see modules and generics.

const declares a typed, immutable data value at module scope. Import it with use to share it across files.

const RADIUS: f64 = 0.15;
const SPEED: f64 = 3.6;
const OMEGA: f64 = SPEED / RADIUS;
const AXIS: [f64; 3] = [0.0, 0.0, 1.0];

Constants and parameter constructors use the same value rules:

Allowed Examples
Number and boolean literals 2.0, 3, true
Constants and enum variants OMEGA, PI, Mode::Idle
Arithmetic 2.0 * OMEGA, -RADIUS
Comparisons and logic SPEED > 0.0, not false
Arrays and records [1.0, 2.0], State { angle: 0.0 }
Indexing and field access AXIS[2], INITIAL.angle
Contextual zero ZERO

Constructor bodies can also read their arguments and earlier let bindings, and construct child models. A const cannot have a model type.

These are not constructor values:

  • signal reads
  • self
  • dt
  • fby
  • if
  • expression blocks
  • ordinary function calls, builtins included

For a builtin, compute a value such as sqrt(2.0) externally or use SQRT_2.

Values must match the declared or expected type. Constants cannot depend on themselves, directly or indirectly. A const array is currently limited to six elements per array dimension.

These names are available without an import, at either f32 or f64:

PI TAU E SQRT_2
FRAC_PI_2 FRAC_PI_3 FRAC_PI_4 FRAC_PI_6 FRAC_PI_8
FRAC_1_PI FRAC_2_PI FRAC_1_SQRT_2 FRAC_2_SQRT_PI
LN_2 LN_10 LOG2_E LOG2_10 LOG10_E LOG10_2

Names are case-sensitive and reserved: E is Euler’s number; e may be a user binding.

ZERO takes its type from context: numeric zero, false, or an array or composite of zeros. It requires an expected type, so write let x: f64 = ZERO;. Enums and model instances have no ZERO value.

INFINITY and NEG_INFINITY take their type from context the same way: an infinity at f32 or f64, and one in every element of a float array. They suit an unbounded limit, which is how the Integrator’s defaults are written.

A model can declare type parameters. Its step and params items use the same parameters without redeclaring them.

hold.mag
model Hold<T = f64> {
ic: T,
}
step Hold(u: T) -> (y: T) {
y = self.ic fby u;
}
params Hold(ic: T = ZERO) {
Self { ic }
}

An instance supplies arguments after the model name. An instance that omits them takes them from what it is stepped with, and a parameter no input determines takes its declared default:

// Fields in another model; import Hold with use crate::hold::Hold.
follows: Hold, // T is the type of what self.follows(..) is fed
vector: Hold<[f64; 3]>, // T is [f64; 3], whatever it is fed

Stepping follows with an f32 makes it a Hold<f32>; stepping it with a bare literal, self.follows(0.0), leaves it at the default f64. An instance stepped in a loop takes the loop’s type, like a local does. Spelled arguments are never read from the call: vector fed an f64 is a type error.

  • Type parameters may represent:
    • scalars
    • arrays
    • composites
    • enums
  • Parameters with defaults must follow parameters without defaults.
  • Generic bodies have no trait bounds. Each used instantiation is checked with its concrete types; arithmetic valid for f64 may fail for bool.
  • When all type parameters have defaults, the default instantiation is also checked.

A constructor default must be valid at the instance’s type. 0.0 cannot initialize an array; ZERO can initialize any type that has a zero value. Otherwise, pass an explicit value, such as Hold([1.0, 2.0, 3.0]) for the vector field.

In a generic step, fby must be the whole right-hand side of an output assignment or an annotated local: let previous: T = self.ic fby u;. Probed locals also require type annotations. These declarations specify the state and telemetry types shared by the model’s instantiations.

A generic model cannot be a simulation entry. Run a concrete wrapper model that instantiates it. Generic models may use inline fields or an extern step under the same boundary rules as other models.