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.