Language guide
TypeRB uses one grammar and one set of portable semantics in every mode. The mode selects code generation and package tooling, while explicit imports expose target-specific APIs.
This guide summarizes the implemented language. The specification is the detailed source of truth.
Program structure
A runnable project contains exactly one top-level main() function:
def main()
puts("Hello from TypeRB")
end
Functions and methods use def and end. Calls include parentheses. A
function that returns no value omits the return annotation and may either fall
through or use a bare return. The recommended terminal style is fallthrough;
trb lint --fix removes a redundant final bare return:
def print_name(name: String)
puts(name)
end
A function with an explicit return type must use a value-bearing return on
every path. Final expressions are not implicit returns. Complete if flow and
exhaustive enum or union case flow can satisfy this rule without a trailing
return after the construct:
def label(ready: Boolean): String
if ready
return "ready"
else
return "waiting"
end
end
A bare * separates positional-only parameters from named-only parameters:
def request(
url: String,
*,
timeout: Integer = 30,
retry_count: Integer = 2,
): String
return url
end
request("https://example.com", retry_count: 4)
Named-only parameters can be required, and named arguments may be reordered:
def connect(*, host: String, port: Integer = 443): String
return host + ":" + port.to_s()
end
connect(port: 8443, host: "example.com")
Function and method parameters are immutable bindings by default. Add mut
before a parameter name only when its implementation reassigns that binding or
uses it for a destructive operation:
def advance(mut value: Integer, *, amount: Integer = 1): Integer
value += amount
return value
end
mut here is not inout: assigning another value to value does not assign
that value to the caller's binding. It is not part of the function's call
signature, so callers, interfaces, and overrides do not repeat it.
Parameters before * cannot be supplied by name, and positional arguments
cannot follow a named argument. Positional defaults remain available for
naturally ordered APIs. Explicit argument expressions run left to right;
omitted defaults run at the selected callee's entry in declaration order.
Payload enum variants use the same positional-only | * | named-only boundary,
but every payload field is required. Record construction labels are field
labels and follow their existing record rules.
Writing : Void on a def or fn declaration is an error; omit the return
annotation instead. Void appears in function types when a stored callable
has no result, for example (String) -> Void.
Typed function values use fn and capture their lexical environment:
def apply(value: Integer, callable: (Integer) -> String): String
return callable(value)
end
prefix := "item "
label := fn(value: Integer): String
return prefix + value.to_s()
end
Each fn parameter has a type. A function value with no result omits its
return annotation, just like def. Its return exits the function value, not
the enclosing method. A parameter is immutable unless written with mut, for
example fn(mut value: Integer): Integer. A function value that may return a
recoverable error uses the same ordinary Result return in its declaration and
type:
loader: () -> Result<String, LoadError> := fn(): Result<String, LoadError>
return read_name()
end
result := loader()
TypeScript may lower a Result-returning callback to async when it reaches a
Promise-based platform API. The TypeRB signature and Result control flow remain
the same.
Outside delimiters, ; can separate complete statements:
class Empty; end
trb fmt expands separators into canonical lines.
Imports and modes
Imports are explicit and resolved before type checking:
import app/models/user
import app/repos/user_repo
import { Contract } from acme/contracts
import trb/std/strings
Portable libraries use trb/std/*. Target-specific APIs use
trb/platform/<mode>/* and are rejected when imported from another mode:
import trb/platform/go/http
Project package identities come from paths below sourceDir; source files do
not declare target packages. External TypeRB packages also use explicit
imports. A project may configure a short import such as acme/contracts; its
lock maps that name to the canonical package identity without changing source
syntax by target mode.
Ordinary imports must be used. Package imports require a member reference, and
each symbol named inside { ... } must be referenced. Compiler integration
imports count as semantic uses when they activate their documented syntax or
type provider.
Types and bindings
Type annotations use name: Type. := declares a local binding and infers its
type when no annotation is present:
name := "Ada"
count: Integer := 3
nickname: String? := nil
names: Array<String> := ["Ada", "Grace"]
scores: Hash<String, Integer> := {ada: 10}
Type names are case-sensitive and have one canonical spelling. Use
Integer, Boolean, String, Float, Array, and Hash; aliases from
target languages such as Int, int, bool, number, and Map are errors.
User-defined types must be declared in the project or imported explicitly.
Bindings declared with := are immutable. Add mut at declaration time when
the binding will be reassigned or used by a destructive collection operation:
mut count := 0
count = count + 1
mut names := ["Ada"]
names.push("Grace")
An immutable reference cannot become mutable by assigning it to a new mut
binding.
The same default applies to def and fn parameters. Parameter mut changes
only the binding inside that implementation and does not add caller-side
writeback. Iterator and call-block bindings keep their current behavior while
explicit mutable block patterns and element ownership remain under design.
Compare a nullable binding with nil to narrow its non-nil path. A returning
guard also narrows the statements that follow it:
def display_name(name: String?): String
if name == nil
return "Anonymous"
end
return name + "!"
end
The same narrowing is available in the matching branch of name != nil, in
the remaining elsif or else path of name == nil, in while, and on the
right side of a compatible short-circuit && or ||. A plain assignment is
still checked against the binding's declared nullable type, but subsequent
statements in that path use the assigned value's more precise type:
def normalized(mut name: String?): String
if name == nil
return "anonymous"
end
name = name.strip().downcase()
return name
end
An assignment that occurs only inside a conditional, loop, or callback does not narrow the binding after that construct, because the path may not execute.
A direct nullable record field or readonly class field follows the same
rule when its receiver is a lexical binding:
def display_profile(profile: Profile): String
if profile.nickname == nil
return "Anonymous"
end
return profile.nickname
end
Reassigning profile invalidates the field narrowing. Mutable class fields,
indexes, calls, and chained member paths must be read into a local binding
before narrowing.
Double-quoted Strings support interpolation with #{expression}. The embedded
expression must already be a non-nullable String; TypeRB does not inherit a
backend's implicit conversion rules. Convert other values explicitly:
radius := 2.5
description := "circle r=#{radius.to_s()}"
A nullable String must be narrowed or converted to a non-nullable String before it can be interpolated.
Identifiers beginning with an uppercase letter are immutable constants. They are allowed at top level or directly inside a module or class:
API_NAME := "TypeRB"
DEFAULT_LIMIT := 100
Constant initializers may be runtime expressions, but constants cannot be rebound or passed to destructive APIs.
Local bindings declared inside methods must be used. Iterator and enum-pattern
bindings follow the same rule. The exact name _ discards a value and cannot
be read. A descriptive name beginning with _ remains readable but does not
produce an unused-binding error:
values.each do |_value|
puts("tick")
end
values.each do |_value|
puts(_value) # _value remains readable
end
case result
when Result::Ok(value)
puts(value)
when Result::Err(_error)
puts("failed")
end
Use _ for a value that is intentionally inaccessible and _name when the
role should remain visible or the binding may be referenced later.
This does not make a local binding private. Leading _ denotes privacy only on
class members; ordinary lexical scope still distinguishes a local _value
from a private _value() method.
Method parameters, fields, constants, and top-level bindings are not rejected solely for being unused.
Aliases and newtypes
Use alias for a transparent shorthand. It does not create a distinct type:
alias UserList = Array<User>
alias LoadResult<T> = Result<T, LoadError>
Use newtype when a domain value must remain distinct even if it has the same
representation as another value:
newtype UserId = Integer
newtype ProductId = Integer
newtype ProductIds = Array<ProductId>
def load(id: UserId): UserId
return UserId.new(id.value())
end
A newtype target may be any concrete, fully instantiated, non-nullable type.
The declaration itself is not generic in the initial design, so
newtype Id<T> = T is rejected while newtype ProductIds = Array<ProductId>
is valid. Express absence outside the newtype as UserId?; a nullable target
such as newtype MaybeUserId = Integer? is rejected.
Newtypes are nominal in ordinary TypeRB code. A base value, another newtype
with the same representation, and the newtype itself are not interchangeable.
Construct one with UserId.new(value) and unwrap it with id.value().
The generated new() is an infallible nominal wrap after its argument passes
ordinary type checking; it does not validate domain invariants. Keep fallible
validation in an ordinary Result-returning function and call new() only
after that validation succeeds.
Underlying members are not forwarded. Two values of the same newtype support
== only when their representation has portable equality.
Typed serialization and persistence boundaries may explicitly use a
newtype's representation. The built-in JSON codecs, trb/web binding, Jobs
payloads, and ORM value parameters use this rule. Other functions and package
APIs remain nominal unless their declaration marks the same boundary. Backend
output may erase the physical wrapper while the TypeRB checker and typed IR
retain the nominal distinction.
Conditions and operators
Conditions must have the non-nullable Boolean type. TypeRB does not inherit
truthiness rules from a target:
if count > 0
puts("non-empty")
else
puts("empty")
end
Use a conditional expression for a short two-value choice. Its condition is
strictly Boolean, only the selected branch is evaluated, and branch types use
the same safe common-type rule as a value-producing if:
label := enabled ? "enabled" : "disabled"
predicate_label := ready?() ? "ready" : "waiting"
Nested conditional expressions require explicit parentheses and are usually
clearer as a complete if. The formatter writes spaces around ? and :.
A simple early exit or loop transfer can use the conditional-transfer form:
return cached if cached != nil
return if finished
next if item.hidden?()
break if complete
Only return, next, and break accept trailing if. TypeRB does not admit
a general modifier form such as notify() if ready, and it does not add
unless. The transfer value is evaluated only when its condition is true.
Numeric expressions may mix Integer and Float. The Integer operand is
widened to Float, and typed IR retains that conversion for every backend and
the REPL. The same safe widening is available in typed initialization,
assignment, arguments, and returns; narrowing to Integer remains explicit.
Integer division truncates toward zero in every target.
Classes, interfaces, and modules
interface Named
name(): String
end
interface Repository<T>
find(id: Integer): T?
end
class User implements Named
readonly @id: Integer
@_name: String
def initialize(id: Integer, name: String)
@id = id
@_name = name
end
def name(): String
return @_name
end
end
Instance fields are declared at class scope. Names beginning with _ and
@_ are private. readonly fields can be assigned during initialization but
not externally. Class methods use def self.name().
Classes support inheritance, generic interfaces, modules, class constants, and checked instance/class member access. Superclass construction, override rules, generic interface methods, and a final field/method collision rule remain alpha design work.
Classes explicitly marked with implements can be passed and returned through
that interface type. Subclasses inherit the declared conformance, while a class
with merely matching methods does not conform implicitly. Fresh literals such
as values: Array<Named> := [User.new(...)] use the expected interface element
type; existing mutable arrays remain invariant.
Generic interface arguments specialize every method in the contract. A class
may implement a concrete application such as Repository<User>, or a generic
class may pass through one of its own type parameters. Generic interfaces are
invariant and conformance remains explicit.
Records
record declares a closed product type for data shared across targets:
record Message
id: Integer
text: String
delivered: Boolean = false
tags: Array<String> = []
end
message := Message.new(id: 1, text: "Hello")
Construction is keyword-only. A required field must precede fields with
defaults. Defaults are checked against their field type, evaluated for each
construction in declaration order, and may refer to earlier fields. Explicit
arguments are evaluated in source order; explicit nil is different from an
omitted field. Constructor defaults do not automatically apply to JSON, ORM,
or web decoding. Records cannot inherit. Go emits a value struct, Ruby emits
Data, and TypeScript emits an interface.
Enums, raw values, and sum types
TypeRB uses enum for two related but distinct models. An ordinary enum is a
closed set of named values:
enum TrafficLight
Red
Yellow
Green
end
Every member may instead bind an explicit String or Integer representation for storage and JSON boundaries. This is a raw-value enum: it extends the ordinary enumeration model rather than introducing a different runtime type.
enum OrderStatus
Pending = "PENDING"
Completed = "COMPLETED"
def terminal?(): Boolean
return self == OrderStatus::Completed
end
end
status := OrderStatus::Completed
puts(status.terminal?())
puts(status.raw_value())
parsed := OrderStatus.from_raw("PENDING")
The enum remains a nominal OrderStatus; conversion is never implicit.
from_raw() returns Result<OrderStatus, EnumValueError>. Every member of a
raw-value enum must declare a distinct raw value of the same type.
A payload enum is TypeRB's closed sum-type model. Its alternatives may carry different typed data, and it may mix payload-bearing and payloadless variants:
enum Token
Text(value: String)
Renamed(id: Integer, *, before: String, after: String)
EOF
end
def describe(token: Token): String
case token
when Token::Text(value)
return value
when Token::Renamed(id, after: current, before: previous)
return id.to_s() + ": " + previous + " -> " + current
when Token::EOF
return "eof"
end
end
renamed := Token::Renamed(7, after: "new", before: "old")
describe(renamed)
Enum members may carry postfix attributes after their payload or raw value.
Attributes are inert language metadata until a compiler-integrated package
defines their meaning. trb/cli, for example, uses them for
subcommand names and descriptions.
A case without else must handle every member. Payload patterns introduce
immutable bindings with types from the variant declaration. Positional fields
are matched in order; named-only fields are matched by label and may be
reordered after the positional bindings. Every field must currently be bound.
A payload enum cannot also declare raw values. Result<T, E> is the standard
generic payload enum, with Ok(value: T) and Err(error: E) variants.
Ordinary, raw-value, and payload enums all remain nominal, use qualified member
names, support exhaustive case, and may define instance methods after their
members. TypeRB does not add a separate sum declaration.
User-defined generics support payload enums, transparent aliases, records, classes, top-level functions, and instance methods with explicit type arguments:
def identity<T>(value: T): T
return value
end
text := identity<String>("value")
class Box<T>
@value: T
def initialize(value: T)
@value = value
end
def echo<U>(value: U): U
return value
end
end
box := Box<Integer>.new(1)
label := box.echo<String>("one")
Generic arguments are invariant. Type-argument inference, generic interface methods, and generic class methods are not part of the current language.
Control-flow expressions
Complete if and exhaustive case constructs can produce values. An if
expression always requires else; a case expression requires either an
else branch or exhaustive enum/union coverage:
label := if enabled
"enabled"
else
"disabled"
end
short_label := enabled ? "enabled" : "disabled"
description := case token
when Token::Text(value)
"text: " + value
when Token::Integer(value)
value.to_s()
when Token::EOF
"eof"
end
Each branch ends with its result expression. Earlier statements in that branch
run before the result is evaluated. Branch types must be equivalent or have a
safe common type such as Float for Integer and Float; TypeRB does not
silently fall back to Any or create a new union for incompatible branches.
A branch may instead leave its enclosing function or loop with return,
break, or next. Such a branch does not participate in the common result
type:
value := case result
when Result::Ok(found)
found
when Result::Err(error)
return fallback(error)
end
The ordinary placement rules still apply: return requires a function or
method, while break and next require a loop. The internal Never type used
to model these paths is not source syntax. A return inside a value-producing
collection block remains unsupported; use explicit each when a transformation
needs enclosing control flow. The statement forms remain available when no
value is needed. Prefer the conditional expression only for a short two-value
choice; complete branch bodies remain clearer as if/else.
Literal types and discriminated unions
Integer and String literals can constrain data fields. An exhaustive case
on a readonly literal field narrows the complete union value:
record Created
status: 201
body: String
end
record Invalid
status: 422
body: Array<String>
end
alias Response = Created | Invalid
def message(response: Response): String
case response.status
when 201
return response.body
when 422
return response.body[0]
end
end
Record fields are immutable. A class field used as a discriminant must be
readonly. Alternatives may share a literal, in which case that branch keeps
their remaining union. Ordinary scalar cases are also available when no
contract is present:
case response.status
when 200
puts("ok")
when 404
puts("missing")
else
puts("unexpected response")
end
Arrays, hashes, and iteration
Arrays and hashes are homogeneous collections. Hash keys are non-nullable
String or Integer values in the current alpha:
numbers := [1, 2.5] # Array<Float>
values := [1, "two"] # Array<Integer | String>
fields := {count: 1, name: "Ada"} # Hash<String, Integer | String>
Literal inference retains one equivalent type, uses a safe common type such as Float for mixed Integer and Float values, and otherwise constructs a union. Narrow a scalar union with an exhaustive type case before using alternative-specific operations:
case fields[:count]
when Integer(number)
puts(number + 1)
when String(text)
puts(text)
end
An unannotated fresh mutable collection is refined by its first constraining statement. Every write nested in that statement participates before the type is fixed; later statements are checked against the fixed type:
mut numbers := []
[1, 2.5].each do |number|
numbers.push(number)
end
# numbers is Array<Float>
include_name := true
mut fields := {}
if include_name
fields["value"] = 1
else
fields["value"] = "unknown"
end
# fields is Hash<String, Integer | String>
A fully typed assignment, parameter, or return context can provide the type
instead. Hash keys remain one homogeneous String or Integer type and never
form a key union. A named function's implementation is not inspected to refine
its caller, and a pending collection that reaches an untyped boundary or the
end of its scope requires an explicit annotation. At the interactive REPL,
top-level pending bindings can instead be refined by a later submission.
Ordinary homogeneous collection operations remain unchanged:
mut scores: Hash<String, Integer> := {ada: 1}
scores["grace"] = 2
puts(scores["ada"])
snapshot := scores.merge({linus: 3})
scores.update({ada: 10})
removed := scores.delete("grace")
scores.each do |name, score|
puts(name + ": " + score.to_s())
end
Array and String element lookup uses the single canonical strict form
value[index]. Nonnegative indexes count from the start, while negative
indexes count from the end (-1 is the last element); lookup fails at runtime
when the normalized position is absent. Hash lookup and deletion are strict as
well. Safe Result-returning lookup is available with try_fetch and follows
the same index rules. Subsequences deliberately use slice(range) rather than
value[range]; try_slice(range) is its safe counterpart. Lookup failures use
IndexLookupError, SliceRangeError, and KeyLookupError values from
trb/std/errors. merge is non-destructive; update and delete require a
mut receiver.
Destructive Array operations require a mut binding, while reverse returns
a new shallow Array:
mut values := [2, 3]
first := values.shift()
values.unshift(1)
reversed := values.reverse()
known := values.include?(2)
position := values.index(3)
occurrences := values.count(3)
Membership and occurrence counting use portable == and are therefore
available for numeric, Boolean, String, and payloadless enum elements. They do
not implicitly enable target-native structural equality for nested values.
index(value) uses that same equality and returns the first position as
Integer?, while the block-based find_index remains the predicate search.
Array sorting is stable and returns a new Array:
ascending := [3, 1, 2].sort()
descending := [3, 1, 2].sort_descending()
shortest_first := ["three", "one", "four"].sort_by do |word|
word.size()
end
Natural ordering and sort_by keys currently support non-nullable Integer,
Float, and String. Key expressions are evaluated exactly once per element
and cannot use an operation that may fail. Equal values retain their original
order, including with sort_descending and sort_by_descending.
uniq() removes later duplicates without changing the receiver, while
concat() returns a new Array in left-to-right order:
values := [3, 1, 3, 2]
deduplicated := values.uniq() # [3, 1, 2]
combined := values.concat([4, 5]) # [3, 1, 3, 2, 4, 5]
uniq() uses portable == and retains the first occurrence. TypeRB
concat() is non-destructive, unlike Ruby's Array#concat.
Arrays, integer ranges, and hashes support structured iteration:
[1, 2, 3].each do |value|
puts(value)
end
(0...10).each { |index| puts(index) }
digits := (0...10).to_a()
values.each_slice(2).with_index do |slice, index|
puts(index)
end
scores.each do |name, score|
puts(name)
puts(score)
end
break exits the innermost loop and next skips to its next iteration.
return exits the enclosing function, including from an iterator block.
Hash iteration always binds key and value separately. Its enumeration order is
unspecified, and the entries are captured in a shallow snapshot before the
first iteration.
Value-producing collection blocks are part of the typed IR:
labels := [1, 2, 3].map do |value|
prefix := "item-"
prefix + value.to_s()
end
visible := labels.select.with_index do |label, index|
!label.empty?() && index < 2
end
total := [1, 2, 3].reduce(0) do |sum, value|
next_sum := sum + value
next_sum
end
has_large := [1, 20, 3].any? do |value|
value > 10
end
all_positive := [1, 20, 3].all? do |value|
value > 0
end
none_negative := [1, 20, 3].none? do |value|
value < 0
end
first_large := [1, 20, 3].find do |value|
value > 10
end
first_large_index := [1, 20, 3].find_index do |value|
value > 10
end
These transformations currently operate on Arrays. A block may contain
ordinary statements and must end with the expression that produces its value.
The block locals are evaluated separately for each element. First-class fn
values are available independently.
Use concurrent_map for bounded I/O fan-out without importing a package or
writing task-management code:
pages := urls.concurrent_map do |url|
fetch_page(url)
end
thumbnails := images.concurrent_map(limit: 4) do |image|
create_thumbnail(image)
end
The result keeps input order even when element work finishes in another order.
At most 8 element blocks are active when limit is omitted. An explicit
positive limit changes that bound. Nested calls share the outer task group's
capacity instead of multiplying it. The call waits for all of its child work,
and cancellation is propagated cooperatively through supported I/O APIs.
concurrent_map returns the block values unchanged. A block returning
Result<Page, FetchError> therefore produces
Array<Result<Page, FetchError>>; it does not aggregate errors. The block
cannot assign an outer binding or capture mutable containers and class
instances. Keep per-element mutation in values created inside the block, and
pass shared service access through package operations that support the hidden
execution scope. CPU parallelism is not promised.
any?, all?, and none? short-circuit and require a non-nullable Boolean
result. On an empty Array, they return false, true, and true,
respectively. find and find_index use the same predicate and stop at the
first match. They return a nullable element or nullable Integer; an empty or
unmatched Array returns nil.
Result control flow
trb/std/result provides Result<T, E> with Ok and Err variants:
import trb/std/result
def unwrap(result: Result<Integer, String>): Integer
case result
when Result::Ok(value)
return value
when Result::Err(_error)
return 0
end
end
Result is an ordinary value: it can be stored, returned, and handled
explicitly with exhaustive case. Prefix try unwraps Ok and returns Err
from the nearest compatible Result-returning function:
def recent_posts(): DbResult<Array<Post>>
posts := try Post.order(created_at: :desc).limit(20).all()
return DbResult<Array<Post>>::Ok(posts)
end
Postfix catch handles Err locally while yielding the success value on the
ordinary path:
posts := recent_posts() catch |error|
puts(error.message)
return []
end
The catch handler must produce the same success type or transfer control with a
valid return, break, or next. It handles only Result::Err, not native
exceptions. Standard Result values must be used with try, catch, exhaustive
case, return, passing, or storage. At the REPL top level, a Result is shown
as Ok or Err; top-level try is rejected so the session remains a stable
inspection boundary.
Formatting
The canonical indentation is one tab per nesting level. trb fmt preserves
comments, literal spelling, heredoc contents, and supported platform DSL
syntax. Formatting is deterministic and idempotent.