A small procedural language with an ML-style type system.
Turkey brings type inference, algebraic data types, and first-class functions to code with familiar loops, early returns, and mutable state. The aim is to make straightforward programs easy to write, while keeping the type system strong enough to support reusable abstractions.
- Write the algorithm directly. Evaluation is strict, functions take ordinary argument lists, and loops and mutation are part of everyday code.
- Let the compiler connect the types. Types are checked statically and inferred across functions. Add annotations where they help explain an interface.
- Make alternatives explicit. Model choices with algebraic data types,
handle them with pattern matching, and compose
OptionandEitherwith?. - Build power in the library. Operators, iteration, indexing, and generic functions are defined through type classes. User-defined types can participate in the same syntax as the standard library.
Parse a few rectangles and add up their areas:
type Rectangle = Rectangle { width : Int, height : Int }
fun parseRectangle(text) {
let (width, height) = String.splitOnce(text, "x")?
Some(Rectangle {
width = Int.parse(width)?,
height = Int.parse(height)?
})
}
fun area(rect) = rect.width * rect.height
fun main() {
var total = 0
for text in ["3x4", "5x2", "oops"] {
match parseRectangle(text) {
Some(rect) -> {
total = total + area(rect)
}
None -> print("Skipping: " + text)
}
}
print(total)
}Skipping: oops
22
The compiler infers parseRectangle : fun(String) -> Option Rectangle.
Each ? unwraps a successful result; a missing separator or invalid integer
makes the parser produce None. The caller handles both cases explicitly.
Tuple destructuring, named record fields, and an ordinary mutable accumulator
work together without type annotations on the functions.
The compiler is written in Turkey. On an arm64 Mac running macOS 14.4 or
later (the runtime sleeps on os_sync_wait_on_address), with a C compiler
installed, it builds from the assembly committed in bootstrap/ (for Linux,
see below):
sh scripts/build.shThat leaves a compiler in build/stages/stage2. Save the example as
rectangles.gob, then compile and run it:
build/stages/stage2 build -o rectangles.s rectangles.gob
cc -o rectangles rectangles.s
./rectanglesOn Linux, link with $TURKEY_CC (below) and add -lm, since glibc keeps
the maths functions the library calls out of libc.
Run the compiler from the repository root, where it finds lib/. It has two
commands. build writes the program to -o, in the format the file's
extension names (only assembly, .s, for now) or -f says; -o - -f asm
writes it to standard output. check compiles as far as the last stage that
can reject a program and writes nothing. Either takes --dump STAGE,... to
print what a stage produced: build/stages/stage2 check --dump types rectangles.gob shows the inferred types, and running the compiler with no
arguments lists the stages. build --timings reports on standard error how
long each phase took, how the backend's time is spread over functions, and how
much of the optimizer's work could run in parallel.
A program's tasks (System.Task, System.Parallel) run on one worker thread
per performance core -- every core, on a Mac without performance levels or on
Linux -- and the compiler is such a program: it compiles in parallel, and emits
the same bytes however many workers it has. TURKEY_WORKERS=N sets the number
of workers; TURKEY_WORKERS=1 runs tasks interleaved on the program's own
thread, in a fixed order. The test suite sets it to 1 for everything it runs
unless a test asks for more, since it runs a process per core already.
The compiler that builds it is bootstrap/, which holds its own arm64
assembly for macOS and for Linux, gzip'd. scripts/build.sh builds for whichever of the two the C compiler
links for. sh scripts/build.sh --fixpoint checks the fixed point: the compiler built from today's source
emits exactly the assembly it was built from. scripts/bump-bootstrap.sh
replaces the committed compiler, which is rare and deliberate; both scripts
explain themselves at the top.
Two environment variables choose the toolchain, for scripts/build.sh and for
the tests alike. Each is split into words the way a shell would split it.
TURKEY_CCis the C compiler that assembles and links a program. Unset, it iscc.TURKEY_RUNis a prefix for running a program that compiler linked, or a built compiler. Unset or empty, the program runs directly.
On x86-64 Linux, the arm64 output runs under qemu-user. Link it statically, because a dynamically linked arm64 binary would need an arm64 libc to load. Set up the machine with:
apt-get update
apt-get install -y \
qemu-user-static \
gcc-aarch64-linux-gnu libc6-dev-arm64-cross \
python3-pytest python3-pytest-xdist
B=/proc/sys/fs/binfmt_misc
if [ -d $B ] && [ ! -e $B/register ]; then
mount -t binfmt_misc binfmt_misc $B 2>/dev/null
fi
if [ -w $B/register ]; then
cat /usr/lib/binfmt.d/qemu-aarch64.conf > $B/register 2>/dev/null || true
fiThen set TURKEY_CC="aarch64-linux-gnu-gcc -static" and leave TURKEY_RUN
empty. The last step of the setup registers qemu with the kernel's binfmt_misc,
so an arm64 binary runs as ./program, with no prefix, mounting binfmt_misc
first where the container has not. That registration is
lost when a machine is restored from a snapshot, so the repository's
.claude/settings.json runs the same line as a SessionStart hook. On macOS the
hook does nothing. Where binfmt_misc cannot be written, set
TURKEY_RUN=qemu-aarch64-static instead. pytest comes from apt here, because Ubuntu
24.04 refuses a system-wide pip install.
With those set, sh scripts/build.sh builds the compiler for arm64 Linux from
bootstrap/arm64-linux.s.gz, and --target arm64-linux makes a compiler on
any platform emit Linux assembly.
The whole test suite runs and passes this way, pytest -m bootstrap and the
fixed point included, and the goldens in tests/programs/ are the same on
both platforms. It is slow: qemu runs the compiler about fifteen times slower
than an arm64 Mac, so one self-compile takes about 26 minutes rather than
105 seconds. On a 4-core x86-64 machine sh scripts/build.sh takes about
27 minutes and --fixpoint about 80. python3 -m pytest takes about 75
minutes once boot is built and two hours from a cold cache; the few tests
that compile the compiler's own source are most of that.
On an arm64 Linux machine none of that is needed: cc links for it and the
programs run directly. CI does this on GitHub's arm64 runners
(.github/workflows/linux.yml), building from the bootstrap once under gcc and once
under clang, and running the suite and the fixed point split across jobs by
scripts/ci-shard.sh.
One group of tests depends on the C compiler, and skips, saying why, where it
cannot do what it needs: under
TURKEY_CC="clang --target=aarch64-linux-gnu -static", the UBSan probes in
test_allocators.py and test_runtime_gc.py. Linking -fsanitize=undefined
statically needs compiler-rt built for aarch64, which Ubuntu's clang does not
ship. gcc's cross toolchain has libubsan.a, so under gcc they run.
Turkey is an experimental language under active development. The compiler is written in Turkey and compiles itself, with an arm64 backend that emits assembly directly. The standard library is written in Turkey over a small set of runtime primitives. A Python implementation served as the reference while the compiler was ported to Turkey, and was retired once the tests ran against the self-hosted one.
For more depth:
- Language reference: the syntax and meaning of every construct
- Compiler written in Turkey
The tests are Python. Install their dependencies with
python3 -m pip install -e '.[dev]', then run python3 -m pytest tests -q.
The tests compile their programs with boot, which is built from the
committed bootstrap on first use; TURKEY_BOOT points them at one you built
yourself instead.
The suite runs in parallel by default, and three commands cover it:
python3 -m pytest tests -q # everything but the fixed point
python3 -m pytest tests -q -m bootstrap # the fixed point alone
python3 -m pytest tests -q -m '' # both, in one run
To run both, use the last one rather than the first two in turn. The fixed
point is a chain nothing can parallelize -- boot compiling the compiler,
linking the result, and that compiling it again, about two minutes a step --
and in one run it overlaps the rest of the suite instead of starting after it.
On a 16-core arm64 Mac, after a change to src/ (so boot is rebuilt), the
combined run takes about nine minutes. The build of boot and everything the tests
compute from it are cached in $TMPDIR by content hash, so a second run
with nothing changed is quicker; -n0 runs serially, for debugging.
To check the heap while debugging a program, compile it with
boot build --gc-verify. Each collection independently checks object bounds,
traced references, shadow-stack and native roots, marking completeness,
region bookkeeping before and after sweeping, and the generational
invariants: an old object holding a young one must have been remembered by
the write barrier. Failures report
heap verifier: and terminate the process with exit status 1 before proceeding
to the next collection phase.
Run it with TURKEY_GC_STRESS=1 to collect and verify at every allocation.
At the compiler's own scale that does not finish; TURKEY_GC_STRESS=N
collects at random intervals averaging N allocations instead, and
TURKEY_GC_STRESS_SEED picks the sequence (1 by default), so a failure can be
repeated and several seeds put the collections at different allocations.
Under stress one collection in eight is full and the rest are minor;
TURKEY_GC_GENERATIONAL=0 makes every collection full, to tell a collector
bug from a missed write barrier.
Verification scans the allocated heap and uses temporary native memory for a
region index. A program compiled without --gc-verify has no verifier in it
at all, and says so on stderr if it is run with TURKEY_GC_VERIFY set. It
checks the roots and layouts the compiler supplies, so behavioral stress tests
are still needed to catch references omitted from those descriptions.
boot build --gc-stats compiles in the collector's accounting: a line per
collection and a summary at exit, on stderr. Counting sends every allocation
through the runtime rather than the code compiled in line for it, so a stats
build's times are slower than a plain one's.