type-rb

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: MIT

README

TypeRB

TypeRB is a Ruby-shaped typed language that transpiles one common syntax to Ruby, Go, or TypeScript. Format source with trb fmt, build it with trb build, and use explicit imports when code needs a target-specific API.

Install

brew install type-rb/tap/trb
trb version

Homebrew installs a prebuilt compiler on macOS or Linux. Go, Ruby, and Node are not dependencies of the Formula; install the target toolchain when you want to run generated code or manage that target's packages.

To build the compiler from source instead, use Go 1.26:

go install github.com/type-rb/type-rb/cmd/trb@latest

Getting started

Start a typed REPL from any directory. No configuration file is required; the default mode is Go:

$ trb repl
trb:go> 1 + 2
3 : Integer

Choose another mode for a one-off session without creating or changing a project:

trb repl --mode ruby
trb repl --mode typescript

Create a Go project when you are ready to build and run a program:

trb init --mode go --module example.com/hello hello
cd hello

Create main.trb:

import trb/std/io

def main()
	io.puts("Hello from TypeRB")
	return
end

Then format and run it. trb run builds in a temporary directory first, so a separate build step is unnecessary:

trb fmt
trb run

See STATUS.md for implemented behavior and ROADMAP.md for the path from the current alpha to practical production use.

Commands

# Create trbconfig.jsonc and the target manifest.
trb init --mode ruby .
trb init --mode go --module example.com/acme/app .
trb init --mode typescript .

# Format all .trb files below the current directory in place.
trb fmt

# CI check. Prints files that need formatting and exits non-zero.
trb fmt --check

# Format stdin to stdout.
trb fmt -

# Compile a project to build/. Non-.trb files are copied as well.
trb build

# Compile without writing output.
trb build --check

# Choose a project output directory.
trb build --out-dir dist .

# Compile one file to stdout.
trb build --stdout app/models/post.trb

# Compile the project in a temporary directory and run its top-level main().
trb run
trb run -- first-argument

# Explicitly choose a source file for a one-off run.
trb run test.trb

# Start a typed IR REPL. Without a project config, it uses a scratch Go session.
trb repl
trb repl --mode ruby
trb repl --mode typescript
trb repl --config path/to/trbconfig.jsonc

# Generate Gemfile, go.mod, or package.json from trbconfig.jsonc.
trb sync

# Update dependencies in trbconfig.jsonc and regenerate the manifest.
trb add rails "~> 8.0"
trb add --dev rspec-rails "~> 7.0"
trb remove rspec-rails

# Run bundle install, go mod download, or npm install.
trb install

trb repl discovers trbconfig.jsonc when one is available, so project imports, local packages, and type providers work automatically. Without a config it starts an isolated Go-mode scratch session. --mode selects Ruby, Go, or TypeScript for only that session and takes precedence over a discovered project mode; --config selects a project explicitly.

Each submission is parsed, resolved, type checked, and lowered through the normal compiler pipeline before evaluation, so platform packages are accepted or rejected according to the active mode. The initial evaluator supports portable expressions, state, conditionals, loops, functions, classes, records, enums with exhaustive case, project imports, and portable standard-library intrinsics. A mode-specific intrinsic without a REPL runtime adapter produces an explicit runtime diagnostic.

REPL commands are :type EXPRESSION, :load FILE, :reload, :help, and :quit. Multiline declarations and delimiter-balanced calls use a continuation prompt automatically. Interactive terminals provide colored input/results, Tab completion, cursor editing, Up/Down history, and Ctrl-R reverse search. Common Readline/Emacs navigation is available: Ctrl-B/F moves by character, Ctrl-A/E by line, Alt-B/F by word, and Ctrl-P/N moves vertically or through history. History is retained in .trb/repl_history for project sessions and in the user cache for scratch sessions, separated by mode. Ctrl-C cancels the current input or running evaluation without leaving the REPL, and Ctrl-D exits.

trb build compiles every input before writing any generated file. When a directory is built, non-.trb files are copied by default, producing a runnable project tree. Use --copy=false when only generated source is wanted.

Output names follow the project mode:

  • post.trb -> post.rb for Ruby mode
  • greeter.trb -> greeter.ts for TypeScript mode
  • main.trb -> main.go for Go mode
  • main.go.trb -> main.go (an existing target suffix is not duplicated)

Project configuration

trbconfig.jsonc accepts line comments and block comments. Trailing commas are not allowed, so the configuration remains compatible with strict JSON parsers after comments are removed. Mode belongs here, not in individual source files:

{
  // One target and package ecosystem for the whole project.
  "name": "my-app",
  "version": "0.1.0",
  "mode": "ruby",
  "sourceDir": ".",
  "outDir": "build",
  "copyFiles": true,
  "dependencies": {
    "rails": "~> 8.0"
  },
  "devDependencies": {
    "rspec-rails": "~> 7.0"
  },
  "ruby": {
    "loader": "zeitwerk"
  }
}

Ruby mode owns Gemfile, Go mode owns go.mod, and TypeScript mode owns package.json. These files are deterministic generated views of trbconfig.jsonc; edit dependencies through the config or trb add/remove, then run trb sync.

mode selects only the backend, toolchain, and package ecosystem. It does not select a Ruby, Go, or TypeScript grammar variant and never loosens portable type checking. All TypeRB files use one common syntax and semantics; target-specific capabilities require an explicit trb/platform/<mode>/* import.

A runnable project defines exactly one top-level def main(). main is a language convention rather than a configurable entrypoint, so trbconfig.jsonc does not need an entrypoint field. trb run compiles the project in a temporary directory and invokes main; running trb build first is unnecessary. A library project may omit main, but cannot be run directly.

When TypeRB is embedded in an existing application that already owns its Gemfile, go.mod, or package.json, set "packageManagement": "external". trb build then generates source without reading or modifying the host manifest; sync, add, remove, and install remain intentionally disabled.

To run test.trb, place it below a directory containing trbconfig.jsonc, then run:

trb fmt test.trb
trb run test.trb

From this repository checkout, the executable example is:

trb run examples/ruby/test.trb

Core syntax

import trb/std/io
import { Logger } from "./logger"

interface Named
	name(): String
end

class User implements Named
	readonly @id: Integer
	@name: String
	@_token: String?

	def initialize(id: Integer, name: String)
		@id = id
		@name = name
		@_token = nil
		return
	end

	def name(): String
		return @name
	end
end

def main()
	user := User.new(1, "Alice")
	io.puts(user.name())
	return
end

main()

v0.1 portable syntax includes:

  • explicit imports shared by every target; Go packages are derived from config and source paths
  • classes, inheritance, interfaces, and modules
  • top-level ; statement separators for compact input such as class Empty; end; trb fmt expands them to canonical lines
  • closed nominal enums and exhaustive enum case dispatch
  • typed fields, readonly, methods, parameters, and return types
  • immutable local inference with :=; use mut value := ... when the binding will be reassigned or destructively updated
  • uppercase runtime constants declared with := at top level or directly in a module/class
  • literals, arrays, typed Hash<K, V> values, calls, members, indexes, and unary/binary expressions
  • if/elsif/else, while, return, break, next, integer ranges, and Ruby-shaped each/each_slice/each.with_index iteration
  • non-nullable Boolean conditions without target-specific truthiness
  • checked numeric, string, equality, and Boolean operators with portable Integer division, remainder, exponent, and expression precedence
  • _private methods and @_private fields

Types are written as name: Type. Generics, arrays, and nullable types use Array<String>, String[], and String?.

Collection updates follow the same explicit rule:

values := [1, 2]       # immutable
mut output := [1, 2]   # mutable
output.push(3)

values.push(3), arrays.push(values, 3), or later assignment to values is rejected. Constants are uppercase immutable bindings and may use runtime initializers:

API_NAME := strings.uppercase("typerb")

Portable hashes use String or Integer keys and require both generic arguments. Lookup is strict: a missing key is a runtime error in every target. Use mut for entry insertion or replacement:

mut scores: Hash<String, Integer> := {alice: 1}
scores["bob"] = 2
puts(scores["alice"])

An empty {} is typed from its surrounding annotation or parameter. Hash entry compound assignment is reserved in v0.1; use scores["alice"] = scores["alice"] + 1.

A function that returns no value omits the return annotation, as in def save(). Writing def save(): Void is an error; Void exists only as the compiler's internal representation of no returned value.

Enum members are always explicit and case is exhaustive unless it has an else branch:

enum State
	Open
	Closed
end

case state
when State::Open
	puts("open")
when State::Closed
	puts("closed")
end

For Ruby method definitions, ordinary keyword arguments remain available:

def page(limit: Integer, cache: false, required:)
end

A typed Ruby keyword parameter uses a double colon in v0.1:

def page(cache:: Boolean = false)
end

It transpiles to def page(cache: false) in Ruby.

Rails workflow

Keep normal Rails files that do not need TypeRB as .rb. Rename files being typed to .trb, and select "mode": "ruby" in the project config. Rails DSL calls do not need to be rewritten:

import trb/platform/ruby/rails

class Post < ApplicationRecord
	belongs_to :author
	validates :title, presence: true
	scope :published, -> { where.not(published_at: nil) }

	def summary(limit: Integer = 80): String
		return body.to_s().truncate(limit)
	end
end

Build and run the generated Rails tree:

trb fmt --check
trb build --out-dir build .
cd build
bundle exec rails test

See examples/rails for controllers, models, concerns, jobs, mailers, routes, and migrations.

examples/todo is the first complete v0.1 vertical slice. A single portable record package is compiled into a Go/GORM/net/http API and a React/TypeScript client. Its schema is managed by sqldef, and the running API exercises TodoList-to-TodoItem (1:N) and TodoItem-to-Tag (N:M) relations.

The Ruby interoperability nodes are intentionally rejected in TypeScript and Go projects; portable targets never silently receive Ruby-only semantics.

Imports and standard packages

Imports are resolved before type checking and retained as resolved package and symbol identities in typed IR. A project compile parses every .trb file once, builds a deterministic import graph, and checks constructor, field, method, inheritance, and interface signatures across file boundaries. Import and inheritance cycles, duplicate exported types, and duplicate top-level main definitions are compile errors. Project imports use source-root paths:

import app/models/user
import { UserRepo } from app/repos/user_repo

Most portable standard packages are explicit and compile to target APIs. The small portable prelude includes Ruby-like puts, and the same function remains available through trb/std/io for namespaced code:

import trb/std/io
import trb/std/strings

puts(1 + 2)
io.puts(strings.uppercase("Hello"))

text := 123.to_s()
number := "123".to_i()
safe_number := "123".try_to_i()
length := "Hello".size()

Ruby-like receiver methods on portable built-in and standard types resolve to the same compiler-owned contracts as their package-function forms. They are therefore type checked and lowered consistently instead of exposing Ruby, Go, or TypeScript methods directly.

to_i() accepts only a complete ASCII decimal integer with an optional sign and raises on invalid or non-portable input. try_to_i() returns Result<Integer, String> instead. Portable integers parsed from text are limited to JavaScript's exact safe-integer range so all modes agree.

Binary data uses the distinct portable Bytes type:

import trb/std/bytes

payload := "A😀".to_bytes()
byte_length := payload.size()
first := payload.at(0)
text := bytes.to_string(payload)

String size counts Unicode code points; Bytes size counts encoded bytes. Conversions use UTF-8, and byte concatenation is non-mutating.

Incremental text construction uses a mutable StringBuilder:

import trb/std/string_builder

mut builder := string_builder.new()
builder.append("Hello")
builder.append_codepoint(33)
blank := builder.empty?()
message := builder.to_s()

Destructive builder methods require mut; to_s() returns a String snapshot.

Portable collections expose both package and Ruby-like receiver forms:

import trb/std/arrays
import trb/std/hashes
import { Result } from trb/std/result

mut values := [1, 2]
values.push(3)
first := arrays.first(values)

labels: Hash<Integer, String> := {1 => "one"}
known := labels.key?(1)
label := labels.try_fetch(1)
keys := hashes.keys(labels)

These contracts infer T, K, and V from the collection. fetch, first, and last are strict; try_fetch returns Result<T, String> or Result<V, String>; dup/copy creates a shallow copy.

Value-producing collection blocks are portable syntax backed by typed IR:

labels := [1, 2, 3].map do |value|
	"item-" + value.to_s()
end

visible := labels.select.with_index do |label, index|
	!label.empty?() and index < 2
end

total := [1, 2, 3].reduce(0) do |sum, value|
	sum + value
end

The block's expression is its result. v0.1 transformation blocks contain one result expression; this keeps map, select, and reduce semantics identical while structured multi-statement blocks are developed.

Portable lexical paths are available as compiler-owned TypeRB code:

import trb/std/path

config_path := path.join("config", "../trbconfig.jsonc")
directory := path.directory("src/compiler/main.trb")
parts := path.components("/srv/type-rb")

Paths always use / and do not depend on the target OS or current directory.

Host filesystem access is explicit and returns portable Result values:

import { FileError, read_text } from trb/std/filesystem
import { Result } from trb/std/result

def load_config(path: String): Result<String, FileError>
	return read_text(path)
end

trb/std/filesystem also provides existence checks, UTF-8 and byte writes, recursive directory creation, and sorted directory listing. It accepts host paths; use trb/std/path separately when target-independent lexical path manipulation is required. All failures carry operation, path, and message in FileError instead of leaking target exceptions. Writes and directory creation return Result<Unit, FileError>: Unit is the portable value meaning "completed", while Void remains the absence of a return value and is omitted from function declarations.

Host process access is also explicit and never invokes a shell implicitly:

import { ProcessError, ProcessResult, run } from trb/std/process
import { Result } from trb/std/result

def run_formatter(files: Array<String>): Result<ProcessResult, ProcessError>
	return run("gofmt", files)
end

trb/std/process provides argv, nullable environment lookup, working_directory, and captured execution. A non-zero exit is Ok(ProcessResult) with success == false; Err(ProcessError) means the process could not be started or the host operation itself failed.

Portable JSON and JSONC provide typed record codecs in addition to an explicit value enum:

import { JsonError, decode, encode } from trb/std/json
import { Result } from trb/std/result

record User
	id: Integer @json("user_id")
	name: String
	nickname: String?
end

def decode_user(source: String): Result<User, JsonError>
	return decode<User>(source)
end

def encode_user(user: User): Result<String, JsonError>
	return encode(user)
end

json.parse accepts strict JSON, while jsonc.parse additionally accepts line and block comments. Both reject trailing commas. json.stringify returns a Result<String, JsonError>, and accessors such as json.as_string keep type mismatches explicit. Typed codecs support nullable fields, arrays, Hash<String, V>, nested records, and @json wire names. The compiler derives and retains their schemas in typed IR rather than reflecting over target code.

Unicode scalar classification comes from one compiler-owned data set shared by all targets:

import trb/std/unicode

hiragana_a := 12354
is_letter := unicode.letter(hiragana_a)
character := unicode.from_codepoint(hiragana_a)
points := "A😀".codepoints()

unicode.version() reports the pinned data version (15.0.0 in the Go 1.26 toolchain). The same package also exposes digit, case, whitespace, and TypeRB identifier classification.

v0.1 includes trb/std/io, trb/std/strings, trb/std/bytes, trb/std/string_builder, trb/std/unicode, trb/std/arrays, trb/std/hashes, trb/std/path, trb/std/filesystem, trb/std/numbers, trb/std/json, trb/std/jsonc, trb/std/process, trb/std/unit, and trb/std/result. Result is imported as a named portable type and handled through ordinary exhaustive enum matching:

import { Result } from 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

No Result propagation operator is selected in v0.1; explicit handling provides the baseline for evaluating a future sugar syntax. Platform APIs use mode-checked packages such as trb/platform/ruby/rails, trb/platform/go/context, and trb/platform/typescript/node. Importing a platform package from a mismatched mode is a compile error. Rails projects use ruby.loader: "zeitwerk", making project imports compile-time dependencies without emitting Ruby requires; standalone Ruby uses require_relative.

trb/platform/ruby/rails also activates an automatic Rails type provider. It reads the host project's db/schema.rb and derives ActiveRecord models and column types without application-authored signature files. Controller inheritance, params, render, ActiveRecord::Relation<T>, all, find_by!, as_json, and the pagination helper used by the core API example participate in normal type checking. Library providers share a Declaration IR so future RBS, .d.ts, and Go export-data frontends do not require checker-specific paths.

Formatter guarantees

trb fmt is deterministic and idempotent. It parses before printing and uses the lossless lexer token stream to retain:

  • standalone and trailing # comments;
  • quoted/interpolated strings and percent literals;
  • heredoc bodies and their internal whitespace;
  • Rails symbol/keyword syntax and block parameters.

Indentation is one tab per nesting level and trailing whitespace is removed. Indentation is not configurable in v0.1.

Development

go test ./...
go vet ./...

Compiler phase boundaries live under internal/ast, internal/checker, internal/ir, internal/lower, and internal/codegen. The source language draft is in SPEC.md.

Directories

Path Synopsis
cmd
trb command
internal
ast
Package ast defines the lossless syntax tree.
Package ast defines the lossless syntax tree.
checker
Package checker resolves names, infers local declaration types, validates assignments/returns, and records a type for every portable expression.
Package checker resolves names, infers local declaration types, validates assignments/returns, and records a type for every portable expression.
cli
compiler
Package compiler owns the public compilation pipeline.
Package compiler owns the public compilation pipeline.
declaration
Package declaration defines compiler-owned type information supplied by runtime-library providers.
Package declaration defines compiler-owned type information supplied by runtime-library providers.
formatter
Package formatter implements a deterministic, comment-preserving TypeRB printer.
Package formatter implements a deterministic, comment-preserving TypeRB printer.
ir
Package ir defines the resolved, target-independent representation consumed by every backend.
Package ir defines the resolved, target-independent representation consumed by every backend.
lexer
Package lexer provides the lossless lexical layer used by both the compiler and formatter.
Package lexer provides the lossless lexical layer used by both the compiler and formatter.
lower
Package lower converts checked syntax AST into the normalized IR.
Package lower converts checked syntax AST into the normalized IR.
packages
Package packages generates target package-manager manifests from trbconfig.jsonc.
Package packages generates target package-manager manifests from trbconfig.jsonc.
parser
Package parser implements TypeRB's handwritten recursive-descent and Pratt parsers.
Package parser implements TypeRB's handwritten recursive-descent and Pratt parsers.
project
Package project owns trbconfig.jsonc discovery, validation, and persistence.
Package project owns trbconfig.jsonc discovery, validation, and persistence.
repl
Package repl implements TypeRB's project-aware interactive evaluator.
Package repl implements TypeRB's project-aware interactive evaluator.
resolver
Package resolver turns syntax imports into project or compiler-known package identities before type checking.
Package resolver turns syntax imports into project or compiler-known package identities before type checking.
stdlib
Package stdlib describes compiler-known portable and platform packages.
Package stdlib describes compiler-known portable and platform packages.
typeprovider
Package typeprovider loads compiler-owned declaration graphs for explicitly imported platform packages.
Package typeprovider loads compiler-owned declaration graphs for explicitly imported platform packages.
typeprovider/rails
Package rails supplies TypeRB declarations for Rails and derives ActiveRecord model types from db/schema.rb.
Package rails supplies TypeRB declarations for Rails and derives ActiveRecord model types from db/schema.rb.
typeprovider/rails/schema
Package schema defines the normalized AST for the subset of Rails schema DSL that contributes model types.
Package schema defines the normalized AST for the subset of Rails schema DSL that contributes model types.
types
Package types contains target-independent semantic types.
Package types contains target-independent semantic types.
selfhost

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL