v1.0.0-rc5 · open source, release candidate

Real types.
One binary.
Isolated heaps.

d6 is a typed, object-oriented language. The compiler checks the annotations you write, a register VM with an optional JIT runs the result, and d6 build turns a program into one executable — cross-compiled, or fully static if you like. Threads copy values instead of sharing a heap.

C23 VM core WebAssembly browser REPL x86-64 JIT MIT licensed
d6 repl WebAssembly
d6 runs in your browser.
Type an expression, or pick an example below.
{>

The interpreter loads in the background.


Familiar ground

You have probably written something like this

Pieces of d6 will feel familiar depending on where you are coming from. The names are different; the ideas are not new.

Blocks, classes, and end

Classes, mixins, blocks and end-delimited bodies, with an expressive object model. Types are optional annotations layered on top, checked when you write them.

class Order
  include Comparable

  def initialize(total: Int)
    @total = total
  end

  def <=>(other: Order) = @total <=> other.total()
  def total() = @total
end

orders = [Order.new(30), Order.new(10), Order.new(20)]
orders.sort().map() do |order|
  order.total()
end
# => [10, 20, 30]

Real threads, light fibers

A Thread is an OS thread with its own heap. Ordinary objects are copied across thread boundaries; channels coordinate communication. Fibers give you generators and coroutines inside one thread.

left = Thread.new(square, 6)
right = Thread.new(square, 7)
left.join() + right.join()   # => 85

steps = Fiber.new(countdown)
steps.resume()   # => 3
steps.resume()   # => 2

Mailboxes and supervisors

Threads talk through bounded channels, and a supervisor restarts a worker that crashes. With the default one-for-one policy, the other workers keep running. This excerpt uses application-defined producer and fetch_loop functions.

jobs = Channel.new(4)
worker = Thread.new(producer, jobs)

item = jobs.receive()   # blocks until a value arrives

sup = Supervisor.new()
sup.add_child(fetch_loop)
# fetch_loop crashes? it is restarted;
# nothing else is touched

Packages you install, not frameworks you adopt

Mjolnir is a data mapper: entities are plain structs, queries are values, and one Repo does the I/O. HTTP servers, routing, GraphQL, jobs, Redis and WebSockets are packages too.

struct User(id: Int, name: String, email: String)
end

users = Mjolnir::Schema.new("users",
  {"id": :int, "name": :string, "email": :string},
  build_user)
repo = Mjolnir::Repo.new(db)

changeset = Mjolnir::Changeset.cast(users, nil, attrs,
    ["name", "email"])
  .validate_required(["name", "email"])
  .validate_format("email", "@")

case repo.insert(changeset)
when Mjolnir::Ok  then "saved"
when Mjolnir::Err then "rejected"
end

All packages →


Recent changes

1.0.0-rc5 is the fifth release candidate for d6 1.0, with everyday collection conveniences and faster JIT-compiled code.

Everyday argument shapes

any? without a block, sum(0.0), min(n), flatten(depth), merge with a conflict block and more no longer raise arity errors.

Faster JIT-compiled code

With the JIT on, fib takes half the cycles and mandelbrot a third less; select, reduce and times run their blocks as native loops.

No silent thread failures

A thread that fails and is never joined now prints its exception and backtrace on stderr instead of losing it.

Release candidate. d6 is exercised mostly on Fedora and Ubuntu, with CI on macOS, FreeBSD, Alpine, and arm64. It hasn't had a security audit, so don't rely on it for security-sensitive work yet.

Full changelog →
Types

A type checker that catches real mistakes

Annotate as much or as little as you like. What you annotate is checked before the program starts wherever the compiler can prove a mistake, and when the call runs wherever it can't. Each output is what d6 printed for the file shown.

greet.d6
def find(id: Int) -> String | Nil
  id > 0 ? "Ada" : nil
end

def greet(name: String) -> String = "hello, #{name}"

def greet_user(id: Int) -> String
  name = find(id)
  return "hello, stranger" if name == nil
  greet(name)              # the guard above narrowed name to String
end

puts(greet_user(1))
greet(find(2))             # find can return nil
d6 greet.d6
greet.d6:11:1: error: argument 'name' of greet: expected String, got String | Nil
greet(find(2)) # find can return nil
^

A value that might be nil can't go where a String is required, and the program doesn't start until that's fixed. Returning early when it's nil, or testing it with != nil or is String, narrows the type for the code that follows.

box.d6
class Box[T]
  def initialize(value: T)
    @value = value
  end

  def value() -> T = @value
  def map[U](transform: Callable[[T], U]) -> Box[U] = Box[U].new(transform(@value))
end

def label(n: Int) -> String = "item #{n}"
def double(n: Int) -> Int = n * 2

box = Box.new(21)          # Box[Int]: T inferred from the argument
names = box.map(label)     # Box[String]: U inferred from label's signature
puts(double(box.value()))
double(names.value())      # a String where an Int is required
d6 box.d6
box.d6:16:1: error: argument 'n' of double: expected Int, got String
double(names.value()) # a String where an Int is required
^

Generic classes, structs and methods infer their type arguments from what you pass, through callbacks and chained calls. Box[Int].new(...) states them when you'd rather.

named.d6
interface Named
  def name() -> String
end

struct City(name: String, population: Int)
end
struct Tag[T](name: T)
end

# No "implements": anything with a matching name() -> String is Named.
def introduce(thing: Named) -> String = "This is #{thing.name()}."

puts(introduce(City.new("Oslo", 717_710)))
puts(introduce(Tag.new("urgent")))     # Tag[String] has name() -> String
introduce(Tag.new(42))                 # Tag[Int] does not
d6 named.d6
named.d6:15:1: error: argument 'thing' of introduce: expected Named, got Tag[Int]
introduce(Tag.new(42)) # Tag[Int] does not
^

Interfaces are structural: a class, a struct or a native String, Array or Hash satisfies one by having the methods, with no implements. A generic instance satisfies it for the type arguments it has.

shapes.d6
struct Circle(radius: Float)
end
struct Rect(width: Float, height: Float)
end
struct Triangle(base: Float, height: Float)
end

def area(shape: Circle | Rect | Triangle) -> Float
  case shape
  when Circle{radius: r} then 3.14159 * r * r
  when Rect{width: w, height: h} then w * h
  end
end
d6 shapes.d6
shapes.d6:9:3: error: case is not exhaustive over its subject's known closed type; missing: Triangle; add the missing branch or an 'else'
case shape
^

A case over a union or a sealed class hierarchy must cover every member, or say else. Object patterns bind fields as they match.

models.d6
class Model
  def self.find(id: Int) -> Self | Nil
    id > 0 ? self.new() : nil
  end

  def touch() -> Self
    @touched_at = Time.now()
    self
  end
end

class Author < Model
  def greet() -> String = "hello from an Author"
end
class Book < Model
end

def welcome(author: Author) -> String = author.touch().greet()

author = Author.find(1)        # Author | Nil, not Model | Nil
if author != nil
  puts(welcome(author))        # narrowed to Author
end
book = Book.find(1)
if book != nil
  welcome(book)                # a Book is not an Author
end
d6 models.d6
models.d6:23:3: error: argument 'author' of welcome: expected Author, got Book
welcome(book) # a Book is not an Author
^

A method shared through inheritance can return Self: Author.find gives an Author | Nil and Book.find a Book | Nil, from one definition.

gradual.d6
def parse(text) = text.to_i()          # no annotations: as dynamic as a script
def half(n: Int) -> Int = n / 2

puts(half(parse("84")))
settings = {"port": "8080"}
half(settings["port"])                 # unknown until it runs, so checked then
d6 gradual.d6
42
gradual.d6: runtime error: expected Int, got String
at half:2:13
at gradual.d6:6:22

Annotations are optional, and code without them runs as it would in a script. Where the compiler can't prove a value's type, the annotation is checked when the call runs, as a TypeError you can rescue.


Under the hood

From source to native code

One flat compile per program, checked types, then an interpreter and a JIT that share one VM. The same compiled bytecode feeds the on-disk cache and d6 build.

  1. 01

    Load

    require and require_cut splice every file into one source buffer. There is no separate compilation unit.

  2. 02

    Compile

    A discovery pass collects declarations; a Pratt compiler then checks annotations, unions, generics, and exhaustive case.

  3. 03

    Bytecode

    About 170 opcodes over 16-bit register operands, cached in a fingerprinted .d6c file next to the source.

  4. 04

    Interpret

    Inline caches, object shapes, and quickening on a VM with a generational, nursery-based garbage collector.

  5. 05

    JIT

    The opt-in JIT compiles hot typed functions on glibc Linux x86-64. Other platforms and unsupported operations use the interpreter.

Ship a binary

d6 build embeds the compiled program in a native executable. An installed toolchain links it against a prebuilt runtime kit with one compiler call.

Isolated threads

Each Thread runs its own VM and heap. Arguments, results, and Channel messages are deep-copied. Channel handles are shared deliberately so threads can coordinate without sharing an object heap.

Capability sandbox

--sandbox checks filesystem, network, and subprocess access at each native opcode, with resource budgets and a per-capability allow-list.

Tooling in the box

A language server, a Debug Adapter Protocol step debugger, a formatter, and a lexer and parser written in d6 itself.


See it in action

The same language, six angles

Structs that extend each other and take type parameters, nested pattern matching, supervised threads, fibers, a sandbox with budgets, and tail calls in constant stack. Every output is from running the file shown.

structs.d6
struct Point(x: Int, y: Int)
end

# A struct can extend another: the parent's fields come first.
struct Point3D(z: Int) < Point
  def length() -> Float = Math.sqrt(x() * x() + y() * y() + z() * z())
end

# Generic structs bind their type parameters from the arguments.
struct Pair[A, B](left: A, right: B)
end

p = Point3D.new(2, 3, 6)
puts(p.to_s())
puts(p.length())
puts(p == Point3D.new(x: 2, y: 3, z: 6))
pair = Pair.new("answer", 42)    # Pair[String, Int]
puts(pair.right() + 1)
d6 structs.d6
Point3D(x: 2, y: 3, z: 6)
7.0
true
43

struct generates initialize, readers, == and to_s. Structs extend other structs, take type parameters and still hold hand-written methods.

events.d6
struct Player(name: String, level: Int)
end

def describe(event) -> String
  case event
  when ["join", Player{name: name, level: 1}]
    "#{name} joined for the first time"
  when ["join", Player{name: name}]
    "#{name} is back"
  when {"score": points, "player": Player{name: name}} if points >= 100
    "#{name} hit #{points}!"
  when {"score": points, **rest}
    "#{points} points (#{rest.length()} more fields)"
  else
    "unknown event"
  end
end

puts(describe(["join", Player.new("Ada", 1)]))
puts(describe(["join", Player.new("Grace", 7)]))
puts(describe({"score": 120, "player": Player.new("Ada", 1)}))
puts(describe({"score": 40, "team": "red", "round": 3}))
d6 events.d6
Ada joined for the first time
Grace is back
Ada hit 120!
40 points (2 more fields)

Array, Hash and object patterns nest, bind as they match and take if guards. **rest collects the remaining keys, and ^name compares with an existing local.

workers.d6
def worker(jobs, results)
  loop do
    job = jobs.receive()
    break if job == nil
    raise "bad job" if job == 3      # crashes; the supervisor restarts it
    results.send(job * job)
  end
end

jobs = Channel.new(16)
results = Channel.new(16)
stop = Channel.new(1)

sup = Supervisor.new()
sup.restart_policy({"max_restarts": 3, "within_ms": 1000, "backoff_ms": 10})
child = sup.add_child(worker, jobs, results)
[1, 2, 3, 4].each() do |n| jobs.send(n) end

loop do
  picked = Channel.select([stop, results], Time.monotonic() + 0.5)
  break if picked == nil || picked[0] == 0
  puts("result: #{picked[1]}")
end
jobs.close()
sup.join()
puts("restarts: #{sup.restart_count(child)}, last: #{sup.last_error(child)}")
d6 workers.d6
result: 1
result: 4
result: 16
restarts: 1, last: uncaught exception: bad job

Each worker is an OS thread with its own heap. A supervisor restarts a crashed worker within its restart policy, and Channel.select takes from whichever channel is ready, up to a deadline.

counter_fiber.d6
def counter()
  total = 0
  loop do
    step = Fiber.yield(total)      # hand back the total, wait for the next step
    total += step
  end
end

running = Fiber.new(counter)
puts(running.resume(0))
puts(running.resume(10))
puts(running.resume(5))
d6 counter_fiber.d6
0
10
15

Cooperative concurrency inside one OS thread: a fiber runs until it finishes or yields, and each resume passes a value in. Reach for Thread when work should run in parallel.

untrusted.d6
puts("sum: #{(1..10).sum()}")      # plain computation always runs

begin
  text = File.read("/etc/os-release")
  puts("read #{text.length()} bytes")
rescue error: SandboxError
  puts("denied: #{error.message()}")
end

i = 0
loop do                            # a runaway loop
  i += 1
end
DIAMOND_MAX_INSTRUCTIONS=1000000 d6 --sandbox untrusted.d6
sum: 55
denied: sandbox denies File.read
untrusted.d6: runtime error: resource limit exceeded
at untrusted.d6:12:9
DIAMOND_SANDBOX_ALLOW=filesystem DIAMOND_MAX_WALL_MILLISECONDS=200 d6 --sandbox untrusted.d6
sum: 55
read 700 bytes
untrusted.d6: runtime error: resource limit exceeded
at untrusted.d6:12:9

--sandbox denies files, network, databases, subprocesses and signal handlers, each of which can be allowed by name. Budgets on instructions, wall time and memory stop a program that won't.

parity.d6
def even?(n: Int) -> Bool = n == 0 ? true : odd?(n - 1)
def odd?(n: Int) -> Bool = n == 0 ? false : even?(n - 1)

puts(even?(1_000_000))
d6 parity.d6
true

A call in tail position reuses the caller's frame, including between mutually recursive functions, so recursion a million deep runs in constant stack.



Install

Build it, run it, ship it

Fedora 44 and Ubuntu 26.04 install from a package; other platforms build from source. Bytecode is cached automatically between runs, and d6 build produces a self-contained native binary when you're ready to ship. A C23 compiler with #embed support is required (GCC 15+ or Clang 19+) to build from source. On x86-64, the supported baseline is x86-64-v3 (AVX2, BMI2, FMA). Pick your platform to get started.

Fedora is the maintainer's own dev machine and Ubuntu runs Skindicate in production, on top of both getting full CI coverage. Alpine, FreeBSD, and macOS are currently validated on GitHub Actions only — if you hit a problem there, please open an issue or a PR.

Alpine
# Install system dependencies (musl -- also needs libucontext)
apk add git make gcc musl-dev openssl-dev sqlite-dev libpq-dev \
mariadb-connector-c-dev zlib-dev libucontext libucontext-dev
# Download the source
git clone https://github.com/diamond-language/diamond.git
cd diamond
# Build d6
make all facet
# Put d6 and facet on PATH
export PATH="$PWD/build:$PATH"
FreeBSD
# Install system dependencies
pkg install git gmake sqlite3 openssl postgresql16-client \
mariadb-connector-c
# Download the source
git clone https://github.com/diamond-language/diamond.git
cd diamond
# Build d6 (FreeBSD's GNU Make is gmake, not make)
gmake all facet CC=clang \
CPPFLAGS_EXTRA='-I/usr/local/include -I/usr/local/include/mariadb' \
LDFLAGS_EXTRA='-L/usr/local/lib -L/usr/local/lib/mariadb'
# Put d6 and facet on PATH
export PATH="$PWD/build:$PATH"
macOS
# Install Xcode command-line tools and Homebrew first; Clang 19+
brew install coreutils gnu-sed bash openssl@3 sqlite \
postgresql@16 mariadb-connector-c
o=$(brew --prefix)/opt
export PATH="$o/coreutils/libexec/gnubin:$PATH"
export PATH="$o/gnu-sed/libexec/gnubin:$o/bash/bin:$PATH"
# Download and build d6
git clone https://github.com/diamond-language/diamond.git
cd diamond
make all facet CC=clang LDLIBS_CRYPT= \
CPPFLAGS_EXTRA="-I$o/openssl@3/include -I$o/sqlite/include \
-I$o/postgresql@16/include -I$o/mariadb-connector-c/include \
-I$o/mariadb-connector-c/include/mariadb" \
LDFLAGS_EXTRA="-L$o/openssl@3/lib -L$o/sqlite/lib \
-L$o/postgresql@16/lib -L$o/mariadb-connector-c/lib \
-L$o/mariadb-connector-c/lib/mariadb"
# Put d6 and facet on PATH
export PATH="$PWD/build:$PATH"

It's all on GitHub

The source, the design docs, and the roadmap all live on GitHub. Clone it, build it, break it.