· 5 min read

The language lives in the prelude

Ducklang implements structs, attributes, derivation, and protocol dispatch as compile-time Duck code over a smaller set of privileged compiler operations.

A wooden letterpress type case holds rows of individual metal letters in small compartments.
Michellecornelison, CC BY-SA 4.0

type Point = struct { .x = I32 } looks like a compiler feature. Delete one import, though, and Ducklang reports Unbound core value: struct. The supposed type constructor is a const function in the repository's prelude.

This is the detail I find lovely in Ducklang. The compiler exposes type values, staging, and privileged @ operations. Duck source builds structs, newtypes, attributes, derivation, and protocol-style dispatch on top. You can open the feature's implementation before either backend sees it.

point.duck
const { struct } = import "duck:prelude/types" ()type Point = struct { .x = I32, .y = I32 }let point = Point.new { .x = 20, .y = 22 }Point.x point

Struct assembles a type#

The import above resolves to prelude_types.duck. Its struct function receives a compile-time shape, builds a product type from the field values, and extends that type with named projections. The constructor is another value attached to the resulting namespace.

prelude_types.duck — struct, abridged
const struct = (const shape) => {  let product_type = []  for field in shape {    product_type = [...product_type, field.value]  }  for index, field in shape {    product_type = product_type :+ (field.name, value => value[index])  }  let new = (const values) => { /* checked product construction */ }  product_type = product_type :+ { shape, new }  product_type}
  1. 1The shape is a const argument, so the function inspects fields during compilation.
  2. 2The first loop turns the field types into the stored product representation.
  3. 3Each field name becomes a projection function attached with type extension.
  4. 4The omitted body checks named constructor values and casts the ordered product.
  5. 5The type value leaves with its shape metadata and constructor in its namespace.
struct from the prelude, abridged to the two loops and the checked constructor the notes walk through.

The compiler still supplies the sharp tools — @cast verifies representation identity, @type.extend adds namespace members, the compile-time evaluator walks the shape — but Duck decides what a struct actually is: the field order, the projections, the constructor sitting in the namespace.

This is a better customization point than a macro that prints an AST fragment and hopes the parser agrees. A replacement struct can be an ordinary imported function, provided it returns type values the rest of the compiler understands.

Attributes return data#

Attributes take the same gamble. The compiler calls each attribute expression with the current target, then interprets a small result union: keep it, drop it, export it, or replace it. The bundled test attribute reads import.meta.mode and chooses whether the binding exists in the build. Cheeky, but inspectable.

addition.duck
const { test } = import "duck:prelude/attributes" ()@[test]const addition_returns_the_sum: () -> Unit = () => {  assert(add(20, 22) == 42)}

derive is more revealing. It receives a compile-time pack of generators, applies each one to the evolving type, and returns Replace with the extended result. The compiler owns attribute execution and the four action cases, while the composition loop lives in Duck source — so another derivation strategy costs a function, not a parser keyword.

Duck dispatch disappears#

The repository's protocol mechanism is named duck, because subtlety had already left the room. A declaration names roles and required members. Lexical extend blocks supply implementations for concrete types.

src/frontend/ducklang.test.ts — structural addition
duck Add Self Other Output {  .add = [Self, Other] -> Output}extend I32 {  .add = [left, right] => @wasm.add_i32 [left, right]}Add.add [20, 22]

During frontend elaboration, the duck resolver infers the role types from the call, checks the selected extension's signature, and rewrites the member call to a hidden const binding. The test above inspects the generated WAT and finds one i32.add. No protocol dictionary survives into that artifact.

The restriction is equally concrete. When a role cannot be known during specialization, the elaborator rejects an obligation that “escapes without a statically known role” — open runtime polymorphism sits outside the mechanism entirely. I think the narrowness helps, although a large application may expose costs the repository examples cannot show yet.

The floor is still steel#

Source-defined features leave plenty of compiler machinery underneath. The shared frontend expands attributes, infers signatures, computes source facts, elaborates ducks, checks affine use, and only then selects the Interaction Calculus or structured Core route. The current pipeline even runs signature inference on both sides of duck elaboration because each stage exposes facts the other needs.

You can also tell at a glance which side of the line any name sits on. @type.extend, @cast, and @wasm.add_i32 cross into compiler machinery, and any name without the sigil is replaceable library code.

One test in ducklang.test.ts keeps the whole argument in a dozen lines: delete the import and struct is an unbound core value, put it back and the generated WAT contains i32.const 20.