Skip to content

Types

The base types are:

  • int — a signed integer
  • float — a double
  • bool
  • string
  • auto — inference placeholder (see Variables)

There is no void type. A function that returns nothing leaves the return-type position empty.

int is a signed 49-bit integer, with a range of [-2^48, 2^48 − 1] (about ±2.81 × 10^14). This is a deliberate trade-off: the NaN-boxed 8-byte value representation reserves bits for the type tag, leaving 49 bits for the integer payload.

For everyday scripting — file sizes, timestamps in seconds, array indices, loop counters — this is more than enough (±2.81e14 is roughly 8900 years of seconds, or 281 TB of bytes). Values beyond the range are truncated to the low 49 bits with sign extension (two’s complement wraparound).

Type-crossing conversions never happen implicitly. Every conversion is written explicitly. This rule has no exceptions:

int + float # compile error (no automatic promotion) — write to_float(1) + 2.5
string + int # compile error
if (5) # compile error — conditions must be bool

Use the built-in conversion functions (to_int, to_float, to_string, to_bool) to convert between types. See Built-in functions.

Type errors are caught before the VM executes a single instruction — this is what “statically typed” means in Pluvial. Type errors are exit 65 (compile error). Only errors that cannot be known statically are runtime errors (exit 70): integer division by zero and stack overflow (deep recursion).

Any base type can be made nullable by appending ?: int?, float?, bool?, string?. A T? value is either a T or null (absence).

Non-nullable is the default. A plain int or string can never hold null. This is the core of Pluvial’s null safety — null cannot sneak in through a type. The literal null (exactly four characters) represents absence.

int? a = null # OK
int? a = 5 # OK — widening T → T? is a safe expansion
int x = null # compile error — 'int' is not nullable
int x = 5
x = null # compile error — cannot assign null to a non-nullable variable
  • Widening (T → T?) is allowed; the reverse (T? → T) requires narrowing — see Control flow — Flow typing.
  • An un-narrowed nullable cannot be used as its base type (in arithmetic, comparison, as a plain-T argument or return value, etc.) — there is no implicit unwrap.

array<T> is a homogeneous, growable collection of elements of type T — Python-list style, with one kind only (no separate fixed/variable-length types).

The element type T is a base type, optionally nullable: array<int>, array<string>, array<int?>. Nesting (array<array<T>>) and array<map<string, T>> are supported (one level deep, base-typed leaves).

array<int> a = [1, 2, 3] # element type inferred
array<string> s = ["a", "b"]
auto mixed = [1, null, 3] # array<int?> — null makes the element nullable
array<int> empty = [] # empty literal: element type from context
  • All elements must be the same type — [1, 2.5] is a compile error.
  • [null] alone is a compile error (cannot infer a base type).
  • auto a = [] is a compile error (cannot infer an element type).
  • Invariance: array<int> and array<int?> are distinct, incompatible types. This closes the unsafe covariance hole found in Java arrays.
arr[i] # read — returns T (array<int?> yields int?, needs narrowing)
arr[i] = expr # write — expr's type must match T exactly

The index i must be an int. An out-of-bounds access (i < 0 or i >= length) is a runtime error (exit 70), reported as "array index N out of bounds (length M)".

Arrays are mutable reference values — assignment copies the reference, not the contents:

array<int> a = [1]
array<int> b = a # shares the same array
b.push(2)
a # [1, 2]

There is no implicit deep copy.

MemberKindResult
arr.lengthpropertyelement count (int)
arr.push(x)methodappend x (no value)
arr.pop()methodremove and return the last element; runtime error on an empty array
arr.contains(x)methodbool — linear search
arr.reverse()methodreverse in place (no value)
arr.join(sep)methodstringarray<string> only
arr.sort()methodsort ascending in place (no value); non-nullable int/float/bool/string only
arr.slice(start, end)methoda new array for the half-open range [start, end); out-of-range is a runtime error
arr.map(fn)methoda new array applying fn to each element
arr.filter(pred)methoda new array of elements where pred returns true
arr.reduce(init, fn)methodfold over the array
array<int> a = [3, 1, 4, 1, 5, 9]
a.sort() # [1, 1, 3, 4, 5, 9]
a.reverse() # [9, 5, 4, 3, 1, 1]
println(a.contains(5)) # true
int last = a.pop() # last == 1
array<int> head = a.slice(0, 3)
array<string> words = ["one", "two", "three"]
println(words.join(" / ")) # "one / two / three"

Spreading is supported in literals: [...a, 4, 5].

map<K,V> is a mutable hash map from keys of type K to values of type V.

  • K must be a non-nullable string, int, or bool — nullable keys are forbidden.
  • V is a base type, optionally nullable. (One level of nesting such as map<string, array<T>> with base-typed leaves is supported.)
map<string, int> m = {"a": 1, "b": 2}
map<string, int> empty = {} # K/V from context
auto m = {} # compile error — cannot infer

The { token is a block in statement position and a map literal in expression position, so there is no grammatical ambiguity.

m[key] # read — returns V? (always nullable)
m[key] = value # write — key: K and value: V are required exactly

This is the key safety choice: where an array reports an out-of-range read as a runtime error, a map reports a missing key by returning null. Reading a map index always returns V? and must be narrowed before use:

int? n = m["x"]
if (n != null) {
println(n + 1)
}

Writing never raises a runtime error — it inserts or overwrites.

MemberKindResult
m.lengthpropertylive entry count (int)
m.has(key)methodbool
m.delete(key)methodbool — whether the key existed
m.keys()methodarray<K> (hash-slot order, not insertion order)
m.values()methodarray<V?>
m.clear()methodremove all entries (no value)

Maps are iterable in two forms:

for k in m { ... } # keys only (k: K, read-only)
for (k, v) in m { ... } # key/value pairs (k: K, v: V?, both read-only)

Map literals support spreading: {...m, "c": 3} (later duplicate keys win).

A tuple is an anonymous, fixed-shape group of values:

auto t = (1, "hello") # a (int, string)
println(t.0) # 1 — fields are accessed by index
println(t.1) # hello
auto (x, y) = t # destructuring
def min_max(int a, int b) (int, int) { # a tuple return type
if (a < b) { return (a, b) }
return (b, a)
}
auto (lo, hi) = min_max(5, 2)

Tuples are structurally typed: two (int, string) tuples are the same type. They are implemented as anonymous structs and need at least two elements.

User-defined record types (struct) and tagged types (enum) are covered on their own pages:

  • Structs — fields, methods, value semantics, struct update syntax.
  • Enums come in three forms: simple, value-bearing, and data-carrying.
enum Direction { North South East West } # simple
enum Status { Ok = 200 NotFound = 404 Error = 500 } # value
enum Shape { Circle(float radius) # data
Rectangle(float w, float h)
Point }
  • Simple and value enums are represented at runtime as plain ints; only value enums expose .value.
  • Data enum variants carry payloads and are matched and destructured with match.
  • array<Direction> and similar arrays of enums are supported.

Result<T> is either an ok holding a value of type T, or an err holding a string error message.

Result<T, E> lets the error be a struct type E instead of a string, so you can carry structured errors:

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 })
}
Result<int, ParseError> r = parse("bad")
if (r is err) {
println(r.error.message + " at line " + to_string(r.error.line))
}
  • T is a non-Result, non-nullable base type. E must be a struct (so Result<int, string> is a compile error — string errors belong to Result<T>).
  • Errors are nominally typed: two structs with identical fields are still distinct types.
  • Result is not a bool: if (r) { } is a compile error. Use if (r is ok) { }.
  • An un-narrowed Result cannot be used directly; narrow it with is ok / is err before reading .value / .error, or destructure it with match. See Error handling.
  • ok(expr) produces a success Result; the static type comes from expr.
  • err("msg") produces a failure with a string message; the success type T is inferred from context.
  • err(StructInstance) produces a failure carrying a struct (for Result<T, E>).
Result<int> r = ok(5) # OK
Result<int> r = err("bad") # OK
Result<int> r = ok("x") # compile error — ok carries a string
auto x = err("m") # compile error — no context for T
struct AppError { string kind }
Result<int, AppError> r = err(AppError{kind:"io"}) # OK
Result<int, AppError> r = ok(5) # OK — ok leaves E unspecified
Result<int, AppError> r = err("bad") # compile error — string ↛ AppError

Nesting (Result<Result<...>>) and Result<int?> are not allowed.