Types
Primitive Types
| Type | Description | Operations |
|---|---|---|
int |
Arbitrary-precision signed integer (no overflow). | Numeric methods |
decimal |
Exact base-10 number (arbitrary precision, no binary rounding). | Numeric methods |
float |
64-bit IEEE-754 binary floating point. | Numeric methods |
bool |
true or false. |
Boolean methods |
string |
Immutable UTF-8 text. A single character is just a length-one string; there is no separate char type. |
String methods |
bytes |
Immutable sequence of raw bytes. | bytes instance methods |
null |
The absence of a value. | - |
any |
A dynamic value at typed boundaries (decoded JSON, request data). | - |
Collection types:
| Type | Description | Operations |
|---|---|---|
list<T> / T[] |
Ordered, growable sequence. | Collection methods |
dict<K, V> |
Insertion-ordered key/value map. | Collection methods |
set<T> |
Unordered collection of unique values. | Collection methods |
Every method list in this chapter is the set the engine actually
recognises - the same source powers dir(value), editor completion,
and geblang check, so completion never lags the language.
Runtime and framework types include Type, Task<T>, generator<T>,
iterable<T>, classes, interfaces, and enums.
func (or its aliases callable and function) is the catch-all type for
any callable value - a function, lambda, method reference, or closure. Use it
when storing a function in a field or accepting one as a parameter:
class Scheduler {
func cb;
func Scheduler(func cb) { this.cb = cb; }
func fire(): void {
let handler = this.cb;
handler();
}
}
Use concrete collection element types whenever possible:
list<string> names = ["Ada", "Grace"];
dict<string, int> scores = {"Ada": 10};
Use any at dynamic boundaries such as decoded JSON, request dictionaries, and
extension responses. Convert or validate it before passing values deeper into
typed application code.
Numeric methods
int, decimal, and float share a core set of numeric operations.
The rounding family returns the same type (keeping a decimal a
decimal), unlike math.floor/ceil/round, which return int. See
stdlib/11-math-datetime for the math
module.
Common to all three numeric types:
| Method | Returns | Description |
|---|---|---|
abs() |
same type | Absolute value. |
sign() |
int |
-1, 0, or 1 by sign. |
clamp(lo, hi) |
same type | Constrain to the range [lo, hi]. |
isPositive() |
bool |
True if > 0. |
isNegative() |
bool |
True if < 0. |
isZero() |
bool |
True if == 0. |
toString() |
string |
Text form (see per-type notes below). |
int also has:
| Method | Returns | Description |
|---|---|---|
isEven() |
bool |
True if divisible by 2. |
isOdd() |
bool |
True if not divisible by 2. |
toString(base = 10) |
string |
Format in base 2-36. |
toDecimal(places = 0) |
decimal |
Convert, optionally rounded. |
toFloat() |
float |
Convert to float. |
decimal and float add the value-keeping rounding family (each takes
an optional places, default 0, and rounds half away from zero for
round):
| Method | Returns | Description |
|---|---|---|
round(places = 0) |
same type | Round to places digits. |
floor(places = 0) |
same type | Round down to places digits. |
ceil(places = 0) |
same type | Round up to places digits. |
truncate(places = 0) |
same type | Round toward zero to places digits. |
float additionally has isNaN() and isInf(). decimal
additionally has format(scale) and toString(scale = 10) for
fixed-scale rendering (a decimal is an exact value with no stored
scale, so the default display is 10 places; pass a scale for more).
Both float and decimal have isInt(), which reports whether the
value is a whole number. float.isInt() is true only for a finite
value equal to its truncation (3.0f.isInt() is true, 3.5f, NaN,
and infinities are false); decimal.isInt() is true when the exact
value has denominator 1.
A numeric literal like 2.9 is a decimal; floats are written with an
f suffix (2.9f). Scientific notation follows the same rule: 1e3
is the exact decimal 1000, while 1e3f is a float. Decimals display
at 10 places by default (they store an exact value, not a scale), so use
toString(scale) or a format spec to control the rendered precision:
import io;
let r = (2.567 as decimal).round(2);
io.println(r); # 2.5700000000 (rounded to 2 places; 10-place default display)
io.println(r.toString(2)); # 2.57 (pick the display scale)
io.println((2.9).floor()); # 2.0000000000 (2.9 is a decimal; floor keeps the type)
io.println((-7).sign()); # -1
io.println((12).clamp(0, 10)); # 10
io.println((4).isEven()); # true
io.println((255).toString(16)); # ff
io.println((3.1415926536 as decimal).toString(13)); # 3.1415926536000
Rendering at a fixed scale rounds. format(scale) and toString(scale)
round half away from zero to the requested number of places, which is the
usual expectation for displaying a value. To scale a value down without
rounding, truncate it to that scale first and then render - the value
already sits at the target scale, so the render step has nothing left to
round:
import io;
decimal price = 1.246;
io.println(price.format(2)); # 1.25 (rounds when rendering)
io.println(price.truncate(2).format(2)); # 1.24 (cut off toward zero, then render)
io.println((-1.246).truncate(2).format(2)); # -1.24 (truncates toward zero, not -1.25)
The same composition works with toString: price.truncate(2).toString(2)
yields "1.24". truncate does the cutting-off; format / toString
just choose how many places to show.
Mixing numeric types
The three numeric types interact by a single rule, designed to protect the exact types from silent precision loss:
intanddecimalmix freely and stay exact:3 + 2.5is adecimal,1 / 2is the exactdecimal0.5(not truncated).intandfloatmix in arithmetic by promoting theinttofloat:3 + 2.5fis thefloat5.5.decimalandfloatdo not mix in arithmetic -2.5 + 2.5fis an error, reported bygeblang checkwhen both operand types are known and at runtime otherwise. Adecimalis exact and afloatis not, so the result type would be ambiguous and lossy. Cast one side, choosing the tradeoff:as floatis fast but drops the decimal's exactness;as decimalkeeps an exact type but adopts the float's binary imprecision.
Because / is true division, its result is a decimal (or float) even when it
divides evenly, so int n = a / b is a compile-time error. Use // (floor
division) for an integer result, int n = a // b, or truncate explicitly with
int n = (a / b) as int.
Comparisons (== != < > <= >=) and membership (in, .contains()) work across
all numeric types and compare by exact value - they never error and never lose
precision. This means they tell the truth: 3 == 3.0f is true, 2.5 == 2.5f
is true (2.5 is exactly representable as a float), but 0.1 == 0.1f is
false, because the binary float 0.1f is not exactly one tenth.
io.println(3 + 2.5f); # 5.5 (int promoted to float)
io.println(3 + 2.5); # 5.5000000000 (decimal, exact)
# io.println(2.5 + 2.5f); # error: cannot mix decimal and float in +
io.println(3 == 3.0f); # true
io.println(0.1 == 0.1f); # false (they genuinely differ)
Boolean methods
| Method | Returns | Description |
|---|---|---|
not() |
bool |
Logical negation. |
toString() |
string |
"true" or "false". |
toInt() |
int |
true -> 1, false -> 0. |
Nullability
Types are non-null by default. Prefix with ? to allow null:
string name = "Ada";
?string maybeName = null;
if (maybeName != null) {
io.println(maybeName.length());
}
A nullable value must be checked or handled before calling methods that require a non-null value. Optional chaining is useful when absence is acceptable:
let city = user?.address?.city ?? "unknown";
Union types
A parameter or return type can list alternatives separated by |. The
caller may pass any value matching one of the branches; the runtime
enforces "any branch matches".
func get(int | string id): User {
if (id instanceof string) { return User.byName(id as string); }
return User.byId(id as int);
}
get(42); # int branch
get("ada"); # string branch
Union types compose with nullability: ?int | string is a three-way
union of int, string, and null. You can write the ? on the
union as a whole or on individual branches; the meaning is the same.
func parseAge(?int | string raw): int {
if (raw == null) { return 0; }
if (raw instanceof int) { return raw as int; }
return (raw as string).toInt();
}
Returns work the same way. The body must produce a value matching one
of the declared branches; the caller can narrow with instanceof /
as or just consume the union directly when an any-typed slot
suffices.
func lookup(string key): User | NotFoundError {
let row = db.findByKey(key);
if (row == null) { return NotFoundError("no user with key " + key); }
return User.fromRow(row);
}
When a mismatching value reaches the function boundary, the runtime
throws a RuntimeError with the expected and actual types:
get expects int | string for parameter 'id', got bool
Catch with the standard try / catch (RuntimeError e) form - but only when
the mismatch is genuinely runtime-opaque (the value arrives through an
any-typed or otherwise dynamic path). A bad value the compiler can already
see at the call site (get(true)) is reported as a static error and aborts
before the try body runs, so it is not catchable.
The intersection operator & is also supported in parameter and
return positions: a value must match every branch. It's mainly useful
for interface intersection, e.g. Comparable & Hashable.
Variable-level unions (let x: int | string;) are deferred to a
future release; today the union form is only enforced at the function
boundary. Inside a function body, a union-typed parameter can be
narrowed with instanceof and re-bound via as.
Casts And Type Checks
let text = value as string;
io.println(value instanceof string);
io.println(user instanceof User);
typeof(value) returns a Type value. Compare it with a built-in type name,
class name, or .type selector for exact runtime type equality; use
instanceof when subclasses or interface implementations should match:
io.println(typeof("x")); # string
io.println(typeof(typeof("x"))); # Type
io.println(typeof("x") == string); # true
io.println(typeof("x") == "string"); # true (compare to a type-name string)
io.println("x" instanceof string); # true
class User {}
User u = User();
io.println(typeof(u) == User); # true
io.println(typeof(u) == "User"); # true
io.println(u.type == User); # true (shorthand for typeof)
io.println("x".type == string); # true
io.println(string.type); # string
The .type selector is a shorthand for typeof - expr.type is equivalent to
typeof(expr). Primitive type names (string, int, bool, decimal, etc.)
and class names can be used directly as type values in comparisons.
A Type is its own value, not a string, so it does not concatenate with
+ directly. Compare it to a type or a type-name string (both shown above);
to build a message from it, convert with as string or interpolate it:
io.println(typeof(u) as string); # "User"
io.println("got a ${typeof(u)}"); # "got a User"
# "got a " + typeof(u) # error: a Type is not a string
Conversion methods
value as type is the idiomatic cast. Primitives also carry conversion
methods (toInt, toDecimal, toFloat, toString, toBool) that do
the same thing but chain cleanly and, in two cases, offer finer control:
toDecimal(places)rounds to a precision while converting, returning adecimal(math.pi().toDecimal(4)->3.1416). With no argument it is a plain cast.toInt(base)on a string parses in the given base.
To test before converting (instead of catching a thrown error), a string carries three non-throwing predicates:
isInt()istrueexactly whentoInt()would succeed (same parse: signs,0b/0o/0xbases, and_separators).isDecimal()istrueexactly whentoDecimal()would succeed.isNumeric()istruewhen the string parses as an int or a decimal (isInt() || isDecimal()).
The value-keeping rounding methods (round, floor, ceil,
truncate) and the sign / clamp helpers also live on the numeric
types; see Numeric methods above for the full list.
Generic type parameters and built-in collections
typeof([1, 2, 3]) returns list (not list<int>); the base type
name remains the canonical identifier for the kind. There is no
Type<T> expression syntax.
The element-type bindings on list<T>, dict<K,V>, and set<T>
are preserved when the value flows through a typed declaration or
parameter boundary (since 1.0.2). The runtime attaches a reified
tag that downstream reflect.typeBindings and instanceof checks
can read:
import reflect;
let list<int> xs = [1, 2, 3];
reflect.typeBindings(xs); # {"T": "int"}
xs instanceof list<int>; # true
xs instanceof list<string>; # false
# An untagged collection (no typed boundary) has no bindings;
# reflect.typeBindings returns an empty dict and instanceof
# walks the elements structurally.
let raw = [1, 2, 3];
reflect.typeBindings(raw); # {}
raw instanceof list<int>; # true (every element is int)
raw instanceof list<string>; # false
any is universally accepting: every list satisfies list<any>,
every dict satisfies dict<K, any>, and so on. Union arguments
match elementwise on untagged collections and tag-satisfies on
tagged ones, so both cases below hold:
[1, "f", true] instanceof list<string|bool|int>; # true
xs instanceof list<int|string>; # true (since 1.5.1)
dict<K,V> exposes the tag as {"K": "...", "V": "..."};
set<T> exposes {"T": "..."}.
Type annotations on parameters using generic collections are enforced at typed
function/method call boundaries and typed declaration boundaries:
list<int> on a parameter checks that the value is a list and that every
element is an int when the call is made. The same boundary check applies when
a typed variable declaration is initialized.
func sumInts(list<int> items): int {
let total = 0;
for (item in items) { total = total + item; }
return total;
}
sumInts([1, 2, 3]); # ok
sumInts(["a", "b"]); # runtime type error - list<string> does not satisfy list<int>
list<int> nums = [1, 2, 3]; # ok
list<int> bad = ["a", "b"]; # runtime type error - list<string> does not satisfy list<int>
The error message names the function (or variable) and describes both the expected and actual element types, e.g.:
sumInts expects list<int> for parameter 'items', got list<string>
type error: cannot assign list<string> to list<int>
If you need a custom error message you can inspect elements yourself before calling or assigning.
These checks do not make primitive collections permanently typed containers. After a value has been accepted at a boundary, normal collection mutation methods still operate on the mutable runtime value. Re-check at the next typed boundary, or validate before mutation when the collection is shared widely.
Reified generics on user-defined classes
Generic type parameters are preserved for user-defined generic classes.
reflect.typeBindings(instance) returns a dict mapping each type parameter
name to the concrete type it was bound with:
import reflect;
class Box<T> {
T value;
func Box(T v) { this.value = v; }
}
Box<string> b = Box("hello");
io.println(typeof(b) == Box); # true
io.println(reflect.typeBindings(b)); # {"T": "string"}
io.println(reflect.typeBindings(b)["T"]); # string
Use reflect.typeOf(value) when you want a stdlib function form that can be
passed around like any other callable.
The bytes Type
bytes represents a raw, immutable sequence of octets. It is the correct type
for binary data: file content read in binary mode, cryptographic hashes and
ciphertext, compressed payloads, HTTP request/response bodies that are not
guaranteed to be valid UTF-8, and any value that must survive a round-trip
without text encoding assumptions.
bytes is distinct from string. A string is always valid text encoded as
UTF-8. A bytes value is just a sequence of unsigned octets with no inherent
encoding; converting it to string requires an explicit call.
Creating bytes values
Use the bytes module to produce bytes values:
import bytes;
let raw = bytes.fromString("hello"); # encode UTF-8 string to bytes
let hex = bytes.fromHex("48656c6c6f"); # decode hexadecimal
let b64 = bytes.fromBase64("aGVsbG8="); # decode Base64
let both = bytes.concat([raw, hex]); # join two byte sequences
bytes instance methods
bytes values expose these methods directly:
| Method | Returns | Description |
|---|---|---|
length() |
int |
Number of bytes (also available as the .length property) |
isEmpty() |
bool |
True when length is zero |
get(int index) |
int |
Byte value at position (0-255) |
contains(int byte) |
bool |
True if the byte value appears anywhere |
toString() |
string |
Decode as UTF-8; throws on invalid bytes |
toHex() |
string |
Lowercase hex representation |
toBase64() |
string |
Standard Base64 encoding |
import bytes;
let data = bytes.fromString("Geblang");
io.println(data.length); # 7 (property form)
io.println(data.length()); # 7 (method form)
io.println(data.toHex()); # 4765626c616e67
io.println(data.toBase64()); # R2VibGFuZw==
io.println(data.get(0)); # 71 (ASCII 'G')
The
.lengthproperty works on every sequence-like type:list,dict,set,string,bytes, andrange. It is identical to calling.length()and is preferred for readability when no other arguments are needed.
Common patterns
Cryptography and hashing: hash and sign functions in the crypt module
return bytes. Pass them directly to bytes.toHex() or bytes.toBase64() for
storage or transport:
import crypt;
import bytes;
let hash = crypt.sha256("password123");
io.println(hash.toHex()); # hex digest
io.println(hash.toBase64()); # Base64 digest
Compression: compress.gzip takes and returns bytes:
import bytes;
import compress;
import io;
let payload = bytes.fromString("lots of repeated text...");
let packed = compress.gzip(payload);
let unpacked = compress.gunzip(packed);
io.println(unpacked.toString());
HTTP bodies: when a response body is not guaranteed to be text, read it as
bytes and convert only when you know the encoding:
import http;
import bytes;
import io;
let resp = http.get("https://example.com/data.bin");
let body = bytes.fromString(resp["body"]); # or use bodyBytes() on Response
io.println(body.length());
Binary file I/O: read/write binary files using the io module's binary
helpers; the value in memory is bytes.
bytes vs string summary
string |
bytes |
|
|---|---|---|
| Content | Valid UTF-8 text | Raw octets, any content |
| Literal syntax | "hello" or 'hello' |
No literal; use bytes.fromString / bytes.fromHex |
| Length | Characters (Unicode) | Octets |
| Indexing | Not directly - use .chars() |
.get(i) returns int (0-255) |
| Concatenation | + operator |
bytes.concat([a, b]) |
| Conversion | value as string |
.toString() (UTF-8 decode) / .toHex() / .toBase64() |
Type Aliases
type UserId = string;
type Money = decimal;
type IntList = int[];
Aliases document intent but do not create distinct runtime types.
Ranges and lists
A range expression start..end (or start..<end exclusive) produces
a Range value. Ranges are iterable - for (x in 1..5) { ... } walks
the inclusive sequence - and support .length, .contains(n),
.first, .last, and .toList(). The list form is the canonical way
to assign a range to a typed declaration:
let xs = (1..5).toList(); # [1, 2, 3, 4, 5]
list<int> ys = (1..5).toList();
For convenience, the top-level range(start, end[, step]) builtin
returns the list directly without an intermediate Range:
let xs = range(1, 5); # [1, 2, 3, 4, 5]
let ys = range(10, 2, -2); # [10, 8, 6, 4, 2]
let zs = range(5, 1); # [5, 4, 3, 2, 1] (auto-negative step)
Both forms are inclusive of both endpoints. Use ..< for a half-open
range ((1..<5).toList() is [1, 2, 3, 4]).
A char-range literal 'a'..'e' evaluates eagerly to a list<string>
of single-character entries:
let letters = 'a'..'e'; # ["a", "b", "c", "d", "e"]
let digits = '0'..<'5'; # ["0", "1", "2", "3"]
list<string> alphabet = 'a'..'z'; # works in a typed declaration too
Watch out for the bracket trap.
['a'..'z']is a list containing one element which is itself the char-range list. The char range is the list; you don't wrap it. Single and double quotes both work for the bounds ("a".."z"is equivalent to'a'..'z').
Char ranges don't produce a Range value - they go straight to the
list, so .toList() on the result is a no-op (which list.toList()
supports for symmetry).
Geblang has no separate char type. Single characters are simply
strings of length one, so the element type of a char range is
string.
Generics
Functions, classes, methods, and interfaces can declare type parameters inside angle brackets. The type parameter is then available as a type name throughout the declaration:
func identity<T>(T value): T {
return value;
}
class Box<T> {
T value;
func Box(T value) { this.value = value; }
func get(): T { return this.value; }
}
let b = Box<string>("hello");
io.println(b.get()); # hello
Reified type bindings
Geblang generics are reified - type bindings are tracked at runtime, not
erased. This means instanceof T works inside generic functions and methods,
and framework code can inspect actual bound types with reflect.typeBindings:
func assertIs<T>(any value): bool {
return value instanceof T;
}
io.println(assertIs<string>("hello")); # true
io.println(assertIs<int>("hello")); # false
The parameter is any value because the function's purpose is to test
the value's type against T at runtime. T is bound from the explicit
<int> / <string> at the call site, and instanceof T consults that
binding inside the body.
Call-site bindings also project through to instances the body constructs (1.16.0). A generic function returning a generic class reports the concrete types, whether T was inferred from an argument or passed explicitly:
class Pair<A, B> {
A first;
B second;
func Pair(A first, B second) { this.first = first; this.second = second; }
}
func make<T>(T v): Pair<T, T> {
return Pair(v, v);
}
reflect.typeBindings(make("hello")); # {"A": "string", "B": "string"}
reflect.typeBindings(make(42)); # {"A": "int", "B": "int"}
Strict binding. Explicit type arguments on a generic call replace T in every position of the signature - parameters, return type, and the body - just like in Kotlin, Swift, Rust, and C#. If you write
func id<T>(T value): T { return value; }thenid<int>("hi")is a type error (T is int; the string argument doesn't satisfy T). When you want a function that accepts any value but tests its type, useanyon the parameter asassertIsdoes above.
Type bindings flow through call chains. When you call a method on a generic class instance, the method sees the concrete binding that was established when the instance was created:
class Typed<T> {
T value;
func Typed(T v) { this.value = v; }
func isCorrectType(any candidate): bool {
return candidate instanceof T; # T is the concrete type at runtime
}
}
let t = Typed<string>("Ada");
io.println(t.isCorrectType("Grace")); # true
io.println(t.isCorrectType(42)); # false
Inspecting type bindings
reflect.typeBindings(instance) returns a dict mapping each type parameter
name to its concrete bound type. This is how frameworks discover what types a
container, validator, or wrapper is parameterized with:
import reflect;
class Container<T> {
list<T> items = [];
func add(T item): void {
this.items.push(item);
}
}
let c = Container<string>();
io.println(reflect.typeBindings(c)); # {"T": "string"}
Enforcement of explicit type arguments
Explicit type arguments are a contract, not a hint. When a constructor is
called with an explicit <TypeArgs> clause, every constructor parameter
typed with a bound type parameter is validated against the binding at
runtime, on both runtimes:
class Box<T> {
T value;
func Box(T value) { this.value = value; }
}
let ok = Box<string>("hello"); # fine
let bad = Box<string>(42); # RuntimeError: Box expects T for parameter 'value', got int
Subtype arguments still pass (Pen<Animal>(Dog()) is fine), and a call
without explicit type arguments stays inference-open (Box(42) binds
T = int from the argument). When the contradiction is statically visible -
a literal or a typed variable against an explicit type argument -
geblang check reports it as error[semantic] before anything runs, and so
do geblang run, test, and build.
Method parameters typed with a class-level type parameter are enforced the same way, against the instance's reified bindings:
let b = Box<string>("hello");
b.put(42); # RuntimeError: Box.put expects T for parameter 'value', got int
This applies to inherited methods too: with class IntBox extends Box<int>,
calling put("nope") on an IntBox throws, because the extends clause bound
T = int for every instance. A method's own type parameters
(func tag<U>(U label)) are unaffected - they bind per call as always.
instanceof consults the same bindings. A parameterized check against a user
generic class is true when the class matches and the recorded bindings match
the type arguments exactly (the same invariant model as list<int>):
let s = Box<string>("hi");
s instanceof Box<string>; # true
s instanceof Box<int>; # false
s instanceof Box; # true (bare-name check is unchanged)
A declaration annotation over a direct constructor call of the same class is
explicit type arguments by another spelling: Box<int> x = Box("text")
validates exactly like Box<int>("text") and is rejected - statically by
geblang check when the contradiction is visible, at runtime otherwise. The
annotation still wins over inference for the recorded bindings; a let
declaration without an annotation stays inference-open.
Explicit type arguments also resolve constructor overloads. With both
Box(T value) and Box(int value) declared, Box<string>(42) is not
ambiguous: binding T = string rules the generic constructor out for an
int, so Box(int value) is selected. The bindings only break ties - a
single-candidate mismatch still reports the precise per-parameter error.
Type inference
The type parameter is inferred from the call arguments - you rarely need to write it explicitly for functions:
func first<T>(list<T> items): ?T {
return items.length() > 0 ? items[0] : null;
}
let names = ["Ada", "Grace"];
let name = first(names); # T inferred as string; returns ?string
For classes, specify the type parameter at construction when inference is not possible from the constructor arguments:
let empty = Box<int>(); # T cannot be inferred from an empty constructor
Invariance
User-defined generic class types are invariant in their type parameters.
Even when Sub extends Base, a Box<Sub> is not assignable to a
Box<Base> parameter or typed variable - the analyzer rejects it at compile
time, and the runtime rejects it at the function-parameter boundary when the
caller's reified bindings disagree with the callee's declared bindings:
class Base {}
class Sub extends Base {}
class Box<T> { func Box() {} }
Box<Base> b = Box<Sub>(); # static error: cannot assign Box<Sub> to Box<Base>
func consume(Box<Base> b): void {}
consume(Box<Sub>()); # runtime error: consume expects Box<Base> for parameter 'b'
This is the standard invariance rule. Without it, a function declared with
Box<Base> could be called with a Box<Sub> and then mutate the box with a
sibling subtype, leaving the original Box<Sub> containing values that
violate its declared element type. Invariance closes that hole.
When the value is constructed without explicit type arguments (so its reified bindings are inferred rather than pinned), the runtime accepts the call - the bindings inherit from the parameter type at that point.
Covariance for built-in collections
Built-in collections (list<T>, set<T>, dict<K,V>) are covariant
in their element types, unlike the user-defined generics above. A
list<Dog> is accepted where a list<Animal> is expected, and any
collection is accepted where the element type is any:
class Animal {}
class Dog extends Animal {}
func count(list<Animal> xs): int { return xs.length(); }
list<Dog> dogs = [Dog(), Dog()];
count(dogs); # ok - Dog is an Animal
func countAny(list<any> xs): int { return xs.length(); }
countAny([1, 2, 3]); # ok - any element type satisfies list<any>
An unrelated element type is rejected, both by geblang check
statically and at the runtime boundary. There is no numeric widening:
a list<int> does not satisfy list<float> or list<string>.
func countStrings(list<string> xs): int { return xs.length(); }
list<int> ints = [1, 2, 3];
countStrings(ints); # static + runtime error: list<int> is not list<string>
Covariant passing is sound because every typed collection carries its
reified element tag and enforces it on every write - push, insert,
prepend, unshift, index assignment, list.set, dict key and value
writes, and set.add. A function that received your list<Dog> through a
list<Animal> parameter cannot smuggle a Cat into it; the write throws
TypeError against the real tag, whatever the static view says:
func sneak(list<Animal> xs): void {
xs.push(Cat()); # TypeError: cannot push Cat to list<Dog>
}
list<Dog> dogs = [Dog()];
sneak(dogs);
Writes that honestly satisfy the tag pass by hierarchy: a Dog or
anything implementing a tagged interface goes into a list<Animal> or
list<Scored> like you would expect. A nullable element type accepts
null: null may be written into a list<?int> or a dict<string, ?int>,
while a non-nullable list<int> rejects it.
The write barrier checks the outer element type only. Writing into a
list<list<int>> verifies the value is a list, not that its elements are
ints (declaration-time element checking goes deeper; per-write checking
is shallow to keep mutation cheap).
Covariance is convenient but, combined with mutation, is not fully
sound: a function that takes list<Animal> could insert a Cat into a
list the caller declared as list<Dog>. Re-validate at the next typed
boundary, or copy, when a collection is both shared and mutated across a
covariant boundary.
Container types
Generic collection type hints document and enforce call/declaration boundaries.
The type binder infers T from the element type in the call arguments:
func sumBy<T>(list<T> items, func selector): int {
int total = 0;
for (item in items) {
total = total + selector(item);
}
return total;
}
let total = sumBy(orders, func(Order o): int { return o.amount; });
Constraints
Add implements InterfaceName after the type parameter to require the
concrete type to satisfy an interface. The constraint lets you call interface
methods inside the function:
interface Printable {
func print(): string;
}
func show<T implements Printable>(T item): string {
return item.print(); # valid because T implements Printable
}
Without the constraint, calling methods on T is a type error because the
compiler cannot verify that the method exists.
Union constraints (|) mean the type must satisfy at least one branch.
Intersection constraints (&, or a comma after implements) mean it must
satisfy all of them:
# T must implement Swimmer OR Runner
func move<T implements Swimmer | Runner>(T item): void {}
# T must implement both Printable AND Persistable
func persist<T implements Printable, Persistable>(T item): void {}
func archive<T implements Printable & Persistable>(T item): void {}
Constraint branches are not limited to interfaces. A primitive name
(string, int, float, bool, decimal, bytes) is satisfied by that
exact type, and a class name is satisfied by the class or any subclass:
func pick<T implements string|int>(T v): T { return v; }
pick(42); # ok: int branch
pick("ada"); # ok: string branch
pick(true); # RuntimeError: type bool does not satisfy constraint string|int for type parameter T
When the constraint is not an interface, the implements keyword can be
dropped - the bare form reads more naturally and means the same thing, on
functions and classes alike:
func pick<T string|int>(T v): T { return v; }
class Holder<T string|int> {
T value;
func Holder(T value) { this.value = value; }
}
Holder(7); # ok, T = int
Holder(true); # RuntimeError: constraint violated at construction
Generics on methods and interfaces
Type parameters can appear on individual methods inside a class, not just on the class itself. Interface declarations can also be generic:
class Converter {
func to<T>(string raw): T {
return raw as T;
}
}
interface Container<T> {
func get(): T;
func set(T value): void;
}
Generics across function boundaries
A lambda declared inside a generic function's body inherits the
outer call site's type bindings. The same is true when a generic
function is referenced by name and passed as a value: the reference
captures the surrounding generic frame's bindings at the moment the
value is taken. This lets higher-order code keep its type guarantees
even when control flows through a stdlib helper such as
collections.maxBy.
import collections;
interface Scored { func score(): int; }
func topBy<T implements Scored>(list<T> items): T {
# The lambda's `T x` parameter resolves to the same concrete type
# as the outer call - if topBy is called with a list<Player>, T is
# Player inside the lambda too, and a non-Player would be rejected
# at the parameter boundary.
return collections.maxBy(items, func(T x): int { return x.score(); });
}
If the surrounding frame has no binding for the named type parameter (for example when the lambda escapes its creation scope and is called later from somewhere else), the matcher falls back to the literal type-parameter behaviour and accepts any value.