Skip to content

Types

Pluvial is statically typed: every value’s type is known at compile time, and type errors are reported before the VM executes a single instruction. There are no implicit conversions between types — ever. Conversions are always written explicitly.

int # signed integer
float # double-precision floating point
bool # true / false
string # immutable byte sequence
auto # type inferred from the initializer, then fixed

There is no void type. A function that returns nothing leaves the return-type slot empty (see Functions).

int is a signed 49-bit integer: the range is [-2^48, 2^48 - 1] (approximately ±2.81 × 10¹⁴). This range is a deliberate consequence of the NaN-boxed value representation: the qNaN payload has 49 bits available, and Pluvial spends them on the int tag.

For typical scripting workloads (file sizes, timestamps, array indices, loop counters) this range is more than enough. Values that exceed it wrap (two’s-complement truncation to 49 bits) — the behavior is defined but probably not what you want.

CodeResult
int + floatcompile error
string + intcompile error
if (5)compile error — if requires bool
to_float(1) + 2.53.5 (explicit)

Use the conversion built-ins (to_int, to_float, to_string, to_bool) to cross type boundaries.

  • Type errors are compile errors (exit 65). The VM never runs.
  • Runtime errors (exit 70) are reserved for value errors that the type system cannot prevent — integer division by zero, integer % by zero, negative integer exponents, shift counts out of int width, array index out of bounds, pop() on an empty array, slice indices out of range, and stack overflow.

Any basic type can be made nullable by appending ?: int?, float?, bool?, string?.

Non-nullable is the default. A plain int or string is guaranteed to hold a real value — null cannot sneak in through the type system. The literal null (exactly four characters) represents absence.

int? a = null # OK
int? a = 5 # OK — widening T → T? is safe
int x = null # compile error — int is not nullable
int x = 5
x = null # compile error
  • auto a = null is a compile error: the base type cannot be inferred from null alone. Write int? a = null.
  • A nullable that has not been narrowed cannot be used as its base type. Arithmetic, comparisons, passing to a plain-T parameter, returning as plain T, or assigning to a plain-T variable are all compile errors.
  • See Syntax — Flow typing for how to safely access a nullable.

Result<T> represents either a successful value of type T (ok(value)) or an error message (err("…")). Result<T, E> extends this so that the error side can be a user-defined struct.

Result<int> r = to_int("42")
if (r is ok) {
println(r.value) # 42
} else {
println(r.error) # error message (string)
}
struct ParseError { string message int line }
def parse(string s) Result<int, ParseError> {
if (s.is_empty()) {
return err(ParseError { message: "empty", line: 1 })
}
Result<int> r = to_int(s)
if (r is ok) { return ok(r.value) }
return err(ParseError { message: "not a number", line: 1 })
}

Rules:

  • T must be a non-Result, non-nullable base type (int, float, bool, string).
  • E (when supplied) must be a struct. Result<int, string> is a compile error — the string-error case is the job of Result<T>.
  • r.value is only accessible after narrowing with is ok; r.error is only accessible after is err.
  • Result is not a bool. if (r) { ... } is a compile error — use if (r is ok) { ... }.
  • match works on Result too — match r { ok(v) => { ... } err(e) => { ... } }.
  • auto x = err("m") is a compile error: T has no source-level position to pin down. Write Result<int> x = err("m") instead.

array<T> is a same-type, variable-length sequence. There is one array type, not separate fixed-length and dynamic types.

array<int> nums = [1, 2, 3]
array<string> words = ["a", "b"]
array<int?> maybe = [1, null, 3]
  • All elements must share the same type. [1, 2.5] is a compile error.
  • The empty literal [] infers its element type from context (array<int> a = [] is fine; auto a = [] is not).
  • array<T> is invariant: array<int> and array<int?> are different incompatible types.
  • Arrays are reference valuesarray<int> b = a shares the buffer; b.push(2) is visible through a.

Read and write with arr[i]:

  • arr[i] returns the element (typed T, or T? for array<T?>).
  • arr[i] = expr requires expr to match the element type exactly.
  • The index i must be an int. Out-of-bounds access is a runtime error (exit 70).

Element nesting (array<array<T>>, array<Result<T>>) is not currently supported.

See the standard library for the full set of array methods.

map<K, V> is a hash map. The key type K must be string, int, or bool (non-nullable). The value type V is any base type, optionally nullable.

map<string, int> m = {"a": 1, "b": 2}
map<string, int> empty = {} # OK — K/V from context
auto m = {} # compile error — cannot infer

{ is dispatched by position: at statement position it begins a block; at expression position it begins a map literal. The two never collide in the grammar.

Reading and writing:

  • m[key] returns V? — keys that are not present return null, never a runtime error. The caller must narrow before using the value. This is the central safety choice that distinguishes map indexing from array indexing.
  • m[key] = value inserts or overwrites. The types of key and value are checked strictly against K and V.
  • Maps are reference values, like arrays.

Nested maps and maps of arrays (map<K, array<T>>) are not currently supported.

See the standard library for the methods.

struct defines a record type with named fields and optional methods.

struct Point {
int x
int y
def distance() float {
return to_float(self.x * self.x + self.y * self.y)
}
def translate(int dx, int dy) Point {
return Point { x: self.x + dx, y: self.y + dy }
}
}
Point p = Point { x: 3, y: 4 }
float d = p.distance()
Point q = p.translate(1, 1)

Definition:

  • Fields are declared type name. There are no field initializers in the definition.
  • Methods are defined with def inside the struct body. self is an implicit first parameter — you do not list it.

Instantiation:

  • Use Name { field: value, ... }. Every field must be supplied, in any order. Missing, unknown, mistyped, or duplicate fields are compile errors.

Field access:

  • p.field reads. p.field = expr writes. expr’s type must match the field type exactly.
  • Field access does not copy — nested writes like outer.inner.x = 3 work as expected.

Methods:

  • Call with p.method(args). self is bound to the receiver and is read-only (self = other is a compile error). self.field = expr is allowed and updates the caller’s struct.

Value semantics:

  • struct values are copied on assignment, on argument passing, on return, and when written into a struct field. This is the opposite of array and map (which share by reference). The choice is per-use-case: collections share, records are independent.

Nominal typing:

  • Two structs with identical field lists are different types. Point and Color are distinguished by name, not by shape.

Not currently supported in struct: equality (== / !=), default field values, static methods (def Point.create()), inheritance / traits / interfaces, recursive structs, and capturing self from a lambda.

String literals support six escape sequences:

"\n" # newline (0x0A)
"\t" # tab (0x09)
"\\" # backslash
"\"" # double quote
"\r" # carriage return (0x0D)
"\0" # NUL byte (0x00, reserved for future C interop)

Any other escape (\x, \u, \q, …) is a compile error.

Strings are immutable byte sequences. Comparisons (== / !=) are content comparisons. See the standard library for the eighteen string methods.

Case conversion (to_upper, to_lower) and predicates (is_digit, is_alpha) currently operate on the ASCII range only.