Otter - write safe, efficient NIFs easily in Rust

I am happy to announce I’ve published otter 0.3.0 to crates.io (as otter-nif). It’s a Rust library for writing Erlang NIFs. It should be is fully functional, and I am still actively seeking feedback as I push toward a 1.0 release. Please don’t be shy.

otter is built from the ground up to give Erlang (and Elixir) programmers an easy way to write safe and efficient NIFs in Rust. The design is driven by two foundational principles:

  1. Functionality. The first priority is an API surface that faithfully captures the capabilities of the underlying erl_nif C API — and where the safe surface isn’t enough, the raw API can be exposed by enabling the raw feature. If an Erlang programmer wouldn’t recognise a concept, it doesn’t belong: no serde, no foreign type system bolted on, no linker magic. All eleven term types are present, and they mirror Erlang rather than a convenient subset of it — a List is a real cons cell (Nil | Cell(head, tail)), so improper lists are first-class and a codec rejects a bad tail with a clean error instead of papering over it.

  2. Speed. One of the prime motivations for writing a NIF is to reach speed and memory efficiency the Erlang VM can’t. otter’s datatypes are designed to pay only for what you need, through controlled layering of enif_* calls, with extensive work to enforce constraints at compile time rather than runtime.

A few ways those principles show up in the surface:

  • Pay only for what you need. Reading a term through the C API is a cascade — enif_term_type, the enif_is_* family, enif_get_tuple handing back opaque elements — and a full decode can cost many calls into the VM, most of them unnecessary. otter mirrors that progression in its types (AnyTermTypedTerm → a concrete term type → a native Rust value), so you stop at exactly the depth you need. Resolution is lazy: constructing a term is one machine word plus a zero-sized brand marker, and nothing is read off the BEAM heap until an accessor asks for it.
  • Compile-time env identity. Each term is branded by the env that produced it, via a generative lifetime. A term from one env cannot be used with another — the compiler rejects it, with no per-operation runtime check. The BEAM treats a cross-env term as undefined behaviour; this construction makes that mistake impossible to write.
  • Honest codecs. A decoder never lies: 300 into a u8 is an IntegerOverflow error, not a silently-truncated 44, and a float won’t quietly swallow an integer. A failed decode on an argument becomes badarg; a failed encode on a return becomes badret.
  • The full send matrix. Message passing is a 2×2 of four free verbs: copy a live term or steal an OwnedEnvArena heap in O(1), each available off-thread (NULL caller) or caller-attributed (in-NIF). You can build a message on a worker thread with no env in hand and move its whole heap into a process in a single send.
  • Hot code upgrade without cross-build ABI assumptions. Every otter module is hot-code-upgradeable. A second build can load beside the first and inherit its live state, and the two builds need not share a compiler, allocator, or even byte-identical source — so the upgrade boundary is treated as a foreign-ABI boundary. Outside raw, otter never assumes allocator/layout compatibility across it; the safe path holds by construction.

A complete NIF looks like this:

use otter::types::CallEnv;

// Declare the module: name, NIF table, and any atoms to pre-intern.
otter::init!("my_nif", [add], atoms = [ok, error]);

// A NIF that adds two integers, decoded straight to `i64` and encoded back.
#[otter::nif]
fn add(_env: CallEnv<'_>, a: i64, b: i64) -> i64 {
    a + b
}

You can see a longer demo at GitHub - cubelio/otter-demo · GitHub.

The rebar3 otter new scaffolder generates the crate and prints the rebar.config hooks; you write the Erlang loader module yourself (the plugin never generates Erlang source).

Requirements: OTP 26+ (NIF 2.17) runtime floor, Rust 1.82, cargo on PATH. The rebar3 plugin needs OTP 27.

2 Likes