twillv1.7.1MIT licensedEarly prototype

A language where tensors are the primitive.

twill is a small language where grad is built in and a shape mistake is an error you see before the program runs.

Most machine-learning code is a language plus a numeric framework bolted on top. twill goes the other way: differentiation is a language operation rather than a library call, and a static checker reads your shapes before anything executes.

$ go install github.com/twill-lang/twill/cmd/twill@latest

01The whole of it

Price a European call, then differentiate the pricer for its Greeks

No bumping, no second library. grad went through 200,000 simulated paths, a relu payoff and a mean, and landed on the closed-form Greeks.

montecarlo_option.tw
seed(42)
let Z = randn(200000)                              # fixed shocks: the price is smooth in its inputs

fn call_price(S0, K, r, sigma, T) {
  let drift = (r - 0.5 * sigma * sigma) * T
  let ST = S0 * exp(drift + sigma * sqrt(T) * Z)   # simulated terminal prices
  exp(-r * T) * mean(relu(ST - K))                 # discounted expected payoff
}

let price = call_price(100.0, 100.0, 0.05, 0.2, 1.0)
let delta = grad(fn(s) = call_price(s, 100.0, 0.05, 0.2, 1.0))(100.0)
let vega  = grad(fn(v) = call_price(100.0, 100.0, 0.05, v, 1.0))(0.2)
output
$ twill examples/montecarlo_option.tw
European call, S0=100 K=100 r=5% vol=20% T=1y, MC paths: 200000
  price = 10.442696  (Black-Scholes 10.4506)
  delta = 0.636269  (Black-Scholes 0.6368)
  vega  = 37.488476   (Black-Scholes 37.524)

02Before anything runs

The most useful thing twill does is refuse to start

twill check infers tensor shapes across the whole program and reports the ones that cannot line up. Parameters can carry shape annotations, which turn a contract into something the checker enforces at every call site.

model.tw
fn matvec(A: [3, 2], x: [2]) -> [3] {
  A @ x
}
twill check
$ twill check model.tw
model.tw:6: shape error: argument 2 ("x") axis 0 is 3 but the signature expects 2
  6 | let out = matvec(A, [1.0, 2.0, 3.0])
model.tw:2: shape error: shape mismatch in @: [3, 2] @ [3] (inner 2 != 3)
  2 |   A @ x

A dimension can be a literal, or a name. A name used more than once must be the same size, which is what lets the checker verify the return type of fn mm(A: [n, k], B: [k, m]) -> [n, m]. The checker only flags a mismatch when it is certain: code whose shapes depend on runtime values is left alone rather than guessed at, so a clean run means what it says.

03Why

Three things fall out of building the language around it

01

Tensors are the primitive

Every number is a rank-0 tensor, vectors and matrices are literals, and @ is matrix multiply. Broadcasting follows NumPy rules, and the gradients broadcast back correctly.

02

grad is a builtin

Backed by a real reverse-mode engine that follows the structure of its argument. A model held in a list gets a list of gradients back; a model in a record gets a record. No tape, no requires_grad, no .backward().

03

Shapes and units are static

[2,3] @ [4] is an error you see before the program runs, not a stack trace forty minutes into training. Dollars plus shares is refused the same way, and units cost nothing at runtime.

04Units of measure

Price times quantity is money; dollars plus shares is refused

Declare base units, annotate quantities, and the checker tracks units through arithmetic. Units are erased at runtime and cost nothing.

notional.tw
unit USD
unit share

fn notional(px: USD/share, qty: share) -> USD { px * qty }

let price: USD/share = 150.0
let value = notional(price, 200.0)   # USD
twill check
$ twill check bad.tw
bad.tw:6: shape error: unit mismatch: USD*share^-1 + share
  6 | let bad = price + qty

05New in 1.7

The two open questions: what a pattern is, and what can be generic

1.5 made the ecosystem run and 1.6 stopped the language having pieces missing from the middle. This one closes the two entries docs/needs.md called the largest open language questions, and closes them on the Go bootstrap and in the self-hosted compiler together. Nothing written before changes meaning: both are additions at positions that were previously syntax errors. Shipped as v1.7.0 on 20 August 2026.

patterns.tw
mode systems

struct Pair[A, B] { left: A, right: B }      # a declaration of your own, generic

fn describe(e: Expr) -> Str {
  match e {
    Num(v) if v < 0.0 => "a negative constant",   # a guard
    Num(0.0) => "zero",                           # a literal pattern
    Neg(Neg(inner)) => describe(inner),           # a nested pattern
    Mul(p) => "a product",
    other => "something else",                    # a catch-all with a name
  }
}
twill check
$ twill check load.tw
load.tw:14: shape error: match on Opt is not exhaustive: missing Some(Err)
 14 |   match o {
load.tw:31: shape error: "b" is declared Box[I64] but the value is Box[Str]
01

A pattern was a case name and one binder

It is a tree now. Ok(Some(v)) takes a value apart in one place instead of a second match inside the arm; 3, "hi" and true match by ordinary equality, so a match over numbers needs no enum written around it; and Some(n) if n > 0 says the thing a shape cannot. A lower-case name binds rather than naming a case, so a catch-all can say what it caught -- and since every variant in the language and its libraries is upper-case initial, nothing written before changes meaning.

02

Exhaustiveness got more precise, not just still true

It recurses: Some(Ok(v)), Some(Err(e)) and None cover an Opt[Res[..]], and dropping one names the value that gets through rather than passing. The rule underneath is that an arm counts only when nothing but the value's shape decides whether it runs -- so a guarded arm and a narrower nested one prove nothing, and Some(v) if v > 3 together with None is reported as incomplete. That is stricter than 1.6 was, and correct.

03

Only four types could be generic

Arr, Dict, Opt and Res were generic and checked; a declaration in a twill program could not be, and [ after the name was a syntax error. struct Box[T], enum Tree[T] and fn first[T](xs: Arr[T]) -> T now parse, check and run. A Box[I64]'s field is an I64 rather than an unknown, substitution goes under the constructors a parameter is written inside, and a Box[Str] is refused where a Box[I64] was declared.

04

And no monomorphization, which turned out not to be needed

The original plan assumed it. The runtime is a tree walker over dynamically typed values, so the same code runs whatever T is and specialising per instantiation would produce identical copies. The parameters have to reach exactly one place -- the types the checker judges against -- so generics here are substitution in about eighty lines per implementation, and the termination question monomorphization would have raised does not arise.

And the tooling around it

twill lsp
A language server: diagnostics republished as you type, formatting, and hover reporting the inferred type and shape. Hover is the one worth having -- in a tensor-first language the question you actually have is what shape something is, and it is answered from the checker without running anything. No completion, deliberately, until the semantic information is reliable enough to drive one.
std/gradcheck
A gradient checker. There is nothing about a wrong gradient that looks wrong: the model does not crash, it trains to a worse loss, and the search starts at the learning rate. Compares against a central difference quotient, deliberately not built out of grad.
twill doctor
Answers the question a bug report starts with, and finds what is wrong quietly: a stale binary earlier on PATH, a TWILL_STD pointing at last month's checkout, a standard library that will not load.
:type and :shape
Answer from the checker in the REPL without running anything, which for a tensor-first language is the most useful question there is. :shape randn(4096, 4096) @ w costs nothing here and a gigabyte there.
The filesystem, finished
path_exists, mkdir_all, remove_all, rename, mtime, temp_dir, cwd and the seven path operations. A program could read a file and write one, and could not make a directory to write into or clean up after itself. Plus read_file_at, a ranged read: read_file returns the whole file, so a reader following a growing log read all of it again on every poll, and one processing a file larger than memory could not run at all.
mono_ns()
A clock that only goes forward. The wall clock steps when the system time is corrected, so a duration measured across one is wrong by the correction -- for a benchmark, the difference between a number and a fiction.
twill test --filter
Runs the suites whose path contains a substring, alongside twill --version --verbose printing the build.

The release is the two candidates that made it. rc1 was the language work above; rc2 is what nine repositories found when they were moved onto rc1 and made to use it, and none of those findings was reachable from twill’s own sources. Between rc2 and the tag the compiler did not change -- what changed is that those nine now run their suites against it in CI rather than on one developer’s machine: 60 suites, nine repositories, green. Across them twill check reports 10 unresolved names, all of them primitives that genuinely do not exist yet, down from 31. Systems-mode code can newly fail to check, which is the point of the release. Three run-time behaviours change for a program relying on them: an I64 division or modulo by zero is an error rather than an infinity or a NaN, % on two I64s takes the sign of the dividend, and a failing ? at the top level stops with a message rather than exiting 0.

06Self-hosting

twill is being written in twill

As of v1.4.0 this runs. The lexer, parser, checker, evaluator, tensor kernels, formatter and CLI are written in the language itself, and the whole tree executes on the Go bootstrap and reproduces the reference across every stage: twill check matched the Go command byte for byte on every corpus file, and twill fmt on every one it formats.

1.6 held the formatter to that claim rather than asserting it: a corpus test over 461 files now checks that twill fmt parses, is idempotent, and keeps every comment and every statement. It was added because the printer had no case for a unit declaration and --write was deleting them from the file, in the Go printer and the self-hosted one alike.

Designing the subset a compiler needs was the point of doing it. Writing the compiler first is how you find out what the subset has to be, instead of guessing. It has already produced a numbered work queue of what the language still needs, and a real bug in the reference lexer.

07The ecosystem

Ten repositories, one language

Everything downstream of the compiler is written in twill itself, which is the same experiment run again: a real program against the subset, with its own list of what is missing.

08What is not done yet

This is a prototype, and some of it is deliberately left for later

It is interpreted. Tensor ops loop in Go, and there is no vectorized or GPU backend.
Autodiff is reverse-mode and first-order. A gradient inside a gradient is refused wherever it is written, rather than answered with zeros; hessian and jacobian nest legitimately.
The shape checker is best-effort, not a full type system. The systems-mode types are checked as of 1.6, but only where a mismatch is certain: an unresolved type is left alone rather than treated as an error.
The self-hosted compiler runs on the Go bootstrap, not yet as its own Go-free binary.

09Read on

Documentation