Chuks v0.2.0-rc.2: What the Candidate Found

chuks.org · 2026-09-27 · by Long Nguyen ·generic
Chuks is a modern backend language that combines structured concurrency, async/await, and a high-performance HTTP runtime with high performance. Built for real-world services and game backends.

Chuks v0.2.0-rc.2: What the Candidate Found

Chuks v0.2.0-rc.2: What the Candidate Found

Copy page

Copy pageCopy this page as Markdown for LLMsView as MarkdownView this page as plain textOpen in ChatGPTAsk questions about this pageOpen in ClaudeAsk questions about this pageOpen in PerplexityAsk questions about this pageOpen in GeminiAsk questions about this page

Sep 27, 2026

Chuks Team Language Design & Engineering

Chuks v0.2.0-rc.2 is rc.1 with forty-five fixes. `chuks --version` prints `0.2.0-rc.2`.

Almost none of these came from the test suite. They came from writing programs: a mobile framework, the apps built on it, a package registry, an HTTP service. A release candidate exists so that programs nobody planned for can find what planned tests cannot, and that is what happened.

Read the breaking changes first if you are already on rc.1. Five of them are real, and four exist because a program that used to compile was wrong.

A name means what the file that wrote it says

Section titled “A name means what the file that wrote it says”

The largest family, and the one worth understanding, because every bug in it looked like something else.

The native backend decided what some names meant by consulting tables keyed by the bare word across the whole program. That works until two modules use the same word, which real projects do constantly.

export function later(f: function(): string): string { return f() }
export function act(result: any): string {    const run: function(): string = function(): string { return "captured:" + string(result) }    return later(run)}
// api.chuks, which ui.chuks does not importfunction result(n: int): string { return "helper:" + string(n) }export function useIt(): string { return result(1) }

The closure in `act` names `result`, which is that function’s own parameter. The backend asked “is `result` a top level function anywhere in this program?” before asking “is it this function’s parameter?”, and `api.chuks` answered yes. The closure was compiled to call the other module’s function. Where that module sat downstream, the framework package ended up importing the app and the build refused; everywhere else it silently called the wrong thing.

  • A closure keeps the binding it was made under. A parameter, a local or a loop variable wins over a function of that name in another module. A loop variable is rebound on each pass under a name the compiler gives it, and a closure capturing it printed that other function’s address on the native backend where the VM printed the value.
  • An import is a binding in the file that wrote it. A module importing a type called `Signal` used to rename that word inside every other module too, so a module importing a function called `Signal` compiled a call to the type. Import aliases are per importing module now and resolve through re-export chains, so a name a barrel passes on lands on the module that defines it, and a name imported as a value is never rewritten to a type.
  • A reference resolves from the package that asks: what that module imported, then what its package declares. When two packages define the same name and nothing says which is meant, the compiler stops rather than guessing, and the build error names your file and line instead of blaming a variable in a nested if/else. Names the compiler generates are exempt, because they are unique by construction.
  • Names are case-sensitive everywhere, on both backends. `Card`, `card` and `CARD` are three names in a module, a class, a dataType, a function body and an import list. The native backend used to lower a name by capitalising its first letter and nothing else, so `function card` beside `function Card` failed the build, `var count` beside `var Count` merged into one variable (the VM printed 10 and the binary 11), and a local `const Card` wrote to the module’s `card`. Three checker rules existed only to keep those shapes away from the backend; they are gone, so `class Deck` may have `card` and `Card` members and `class Card` may sit beside `function card`.
  • A class is the declaration it came from. Two modules may each declare a private `class Box`, and one module’s `Box` is not the other’s. Assigning across them is a type error that names both declaring files. The checker used to match by name alone. Breaking for code that passed one module’s class where another’s same-named class was declared: import the class rather than redeclaring it.
  • Generated names cannot meet user names. Every name the backend invents carries a character a Chuks identifier cannot contain, so `class XT_Card` or `class Cell_int64` beside `Cell<int>` are ordinary names now. Native traces show a type as `XTʼSession`.
  • A variable named `as_check` no longer breaks every `as` in the program. The step behind `as` was an internal builtin with a spellable name, and a variable of that name shadowed it. Every name the compiler invents on the VM path now carries that same character, as the backend’s already did.
  • A class is a value on the native backend too. `const ref: any = Counter`, `ref.count()`, `ref.made`, a static method taken as a function, `string(ref)` and `typeof(ref)` all work as on the VM. The binary used to reference an undeclared variable.
  • An instance prints the same on both backends. `string(obj)` on the VM printed fields in a different order every run and listed statics as `null`. It prints instance fields sorted by name now, which is the native order, and an embedded base class’s fields are the object’s own.
  • An array is one object

    Section titled “An array is one object”

    A `[]T` was a slice header held by value in every variable, field and map slot on the native backend, so `push` grew only the name you pushed through:

    const list: []Item = cart.itemslist.push(item)       // cart.items was unchanged, natively

    A method doing `const l = this.items; l.push(...)` left the field empty, and a push through a value held as `any` was dropped without a word. Every holder now shares the one array, as the VM always did: an alias, a field read into a local, a map value, an `any`, a parameter, an element of another array, a closure, and `reserve` and `grow` through any of them. An array with a single name in a function still compiles to a slice on the stack, and a loop that only indexes one reads a hoisted header, so the benchmarks are where they were.

    Corrected on the way: `slice()` on a `[]any` shared its backing store with the original, `flatMap` and `flat` did not flatten typed sub-arrays held in an `any`, and `sort()` on an aliased number array compared as strings. `xs.push(v)` now answers the new length natively, `static var names: []string = []` builds, and `pop()` on an `any` holding a typed array no longer crashes the binary.

    Breaking, and enforced by the checker on both backends: a typed array may not be passed where a `[]any` parameter is grown in place, because the two are different arrays natively and the growth could not reach the caller’s. Declare the parameter `[]string`, or make the argument a `[]any`. Reading a typed array through a `[]any` parameter is unchanged.

    Two more rules that follow from arrays being objects:

  • An array born untyped becomes yours when you read it as typed. `const xs: []int = doc["xs"]` from a JSON document is a new array whose elements are checked, with “type mismatch: expected \[\]int, got string at index 0” when one is not. From then on `xs` is one object through every name, but it is not the document’s array. A native build always had to build the typed array there and used to coerce the elements, turning `"a"` into 0; the VM handed back the document’s own array.
  • A lock on an array is a lock on the one array, natively. It was keyed on the array’s backing memory, which a push inside the lock moves, so the unlock missed and the next task to lock that array waited forever. It happened about one run in four with two spawned workers.
  • A loop walks the array as it is. `for (var x of xs)` reads the length on every pass, so an element the body pushes is visited and one it pops is not, exactly as an index loop behaves. The VM read the length once and a pop in the body ended in “index out of range” on the last pass.
  • A typed map passed where `map[string]any` is declared builds, widened by a converting copy, as a typed array into `[]any` already was.
  • Values that cross `any`

    Section titled “Values that cross any”

    `any` is where a typed program meets data it did not write. The two backends disagreed about what happens there; now they answer one rule.

  • An `any` is checked where it meets a type. `var n: int = raw` is allowed by the checker on the understanding that `raw` holds an int, and that is now verified at that line when it runs: for a variable, a field, an array element, a map value, a parameter, a constructor argument, a return, and the right operand of `count + raw`. Anything else is “type mismatch: expected int, got float”, and a null is the non-nullable error. The VM used to store whatever the `any` held and a native build coerced it, so the same program printed two different numbers. Breaking only for a program that relied on either behaviour.
  • A field declared as a type parameter is checked against the type argument the instance was made with, and a field reached through an `any` receiver against its declared type. Inside a generic body, a value typed `any` placed where a `T` is declared is checked through the instance’s type arguments, resolved through `extends Base<U>`. `value as T` says it explicitly and used to throw on the VM.
  • A call through `any` checks its argument count on both backends. `const f: any = two; f(1)` is “wrong number of arguments: want=2, got=1”. The binary used to fill a missing argument with zero and drop a surplus one, for functions, closures, async functions, methods, inherited and static methods, with optional ranges reported as “want=1..2”. The VM did not check a method reached through `any` at all.
  • A closure passed through a generic function slot keeps its own argument count, rather than having its arguments padded or dropped by an adapter.
  • Operators on `any` operands answer one rule. `+` with a string on either side concatenates, int and float mix as float including `%`, and a pairing with no meaning is a runtime error at the operator on both backends: “type mismatch: string - int”. The native helpers used to answer 0 or false, so `"x" - 1` was 0, `true 2` was 2 and `null + 1` was 1, while the VM named an opcode. With the `any` on the left the result is `any`, so `f - 1` on an `any` holding 2.5 is 1.5 on both.
  • `7.5 % 2.0` was an addition on the native backend, answering 9.5. It is 1.5, and `% 0.0` is “division by zero”.
  • `readonly`, `private` and `protected` are the checker’s. Through `any` a member is stored and read as it is, on both backends. The VM used to refuse some of these by inspecting the calling frame, which a native build has no frame to do, and its own fast read path never did either: `a.secret` read 42 while `a.secret = 5` was refused.
  • Types, inference and generics

    Section titled “Types, inference and generics”

    An arrow function reads its parameter types from where it lands. Passed to a typed parameter, stored in a typed field or variable, or returned from a function with a declared return type, it may leave its parameters unannotated: `each(xs, x => ...)`, `{ onChange: v => ... }`, `const f: function(x: int): int = x => x 2`. The array methods declare their callback shape, so `xs.map(x => x.name)` and `xs.filter((x, i) => ...)` type the element and index from the receiver. Nothing becomes implicitly `any`: an arrow with nothing to read from says so. The return type is read from the slot too, so `() => ({ x: 0, y: 0 })` in a `function(): Point` slot checks the literal as a Point. `async () => ...` parses, and lands in the `Task` arm of a handler union.

  • A type parameter binds through a function argument’s shape, and through the union arm the argument fits. `Comp(Card, { id: "1" })` reads `P` from `Card`’s parameter and checks the record as a CardProps, where a misspelt prop is an error. Before, a function or union parameter bound nothing and `P` came from the record literal, as a map.
  • A record returned from an async function is checked as the task’s inner type, nullable included. `async function f(): Task<Post?> { return { id } }` reached the return check with the Task unwrapped but the nullable not, so the literal was inferred as a map and refused, while the same literal in a sync function was checked as a Post.
  • A dataType field’s type must exist, where it is written. `dataType Props { onDelete: Handlerr }` was accepted, and reported only when a value was checked against the field, at that call, twice, and never when nothing was. It is “unknown type ‘Handlerr’” at the field now, once, including names inside a function type. Naming a type in a field counts as using it, so an import named only there is no longer “imported but never used”. This found a nonexistent `createdAt: DateTime` in one of our own goldens.
  • A handler union is callable. A union whose arms are function types with the same parameters can be called as one, where `p.onPress()` used to be “attempt to call non-function type” and forced component authors through `any`. The call’s type is the union of the arms’ returns, `await` awaits the Task arm and passes the other through, and arms that disagree on parameters stay uncallable.
  • A member access on a number or a bool is a type error. `f.toFixed(1)`, `flag.toString()` and `n.length` were runtime errors on both backends; the checker refuses them where they are written and names `string(f)` where a conversion was meant. Strings keep their methods.
  • A call to a function declared further down the file is typed by its signature. The checker reserved a later function’s name but not its type, so the call was `any`: `const s: string = await later()` passed with `later` returning `Task<int>`. Every top level function, and every dataType a signature names, is known before any body is checked. Breaking only for code that was already wrong.
  • A generic function’s own `T` is checked on both backends. `T` exists only while the call runs, so the VM had nothing to check against inside the body and `firstInto<T>(xs: []T, v: any)` given a string stored it in a `[]int`, where a native build refused. The VM now keeps what the call named its type parameters, including a generic passing its own `T` to another, a closure made inside the body, and an async body.
  • A generic function called with a type of the entry file compiles. `useState(new Ticket())` with `Ticket` declared in the entry module used to place the specialisation in a package that cannot name `Ticket`.
  • A function keeps its identity through a generic parameter on the native backend. `Comp(A, props)` handed `A` through a fresh adapter on every call, so `a.comp == b.comp` was false for the same `A` and a reconciler took the component for a new one each frame, resetting its state. A named function has one adapter per package now.
  • A thunk built from loop variables and passed to a generic slot read them late, so every layer of a navigation stack rendered the topmost route. The value is bound when the argument is evaluated.
  • A method taken as a value builds. `const bm: any = p.m` was declared with the method’s return type and failed; a static method, an inherited method, a builtin function and a builtin method of a string, array or map taken as values referenced an undeclared temporary.
  • A capturing closure with an optional parameter runs its default. `function(a: int, b: int = 5)` written inside a function that captured a local was typed as `int` for `b` natively, so the default never applied.
  • The web and data surface

    Section titled “The web and data surface”

  • `req.parseBody(CreateTodoDto)` is typed. It was `any`, which type-checked, ran on the VM and failed the native build. It answers `T?` now: the record you name, or `null` when the body is empty or is not JSON. Breaking: `var body: T = req.parseBody(T)` becomes `var body: T? = ...` plus a check. `req.parseBody()` without a type stays `any`.
  • `req.header(name)` and `req.cookie(name)` are `string?`, because a header the request did not send is not an empty string. Breaking: add `?? ""` or a null check where the value was assigned to a `string`.
  • `res.redirect(url, status?)` and `res.cookie(name, value, options?)` accept the optional argument they always supported; the checker rejected the call.
  • One cookie rule on both backends: `Path=/` unless given, then `domain`, `maxAge`, `httpOnly`, `secure`, `sameSite`. Natively, a cookie not sent used to be `""`, `res.cookie` omitted the default path so a cookie set by a binary was scoped to the request path, `res.clearCookie` omitted it too so browsers kept the cookie, and `res.json(null)` failed the build. On the VM, `res.json(null)` sent an empty body with no content type; it is `null` as JSON.
  • `json.stringify` of a value that refers to itself is a runtime error, “json.stringify: the value refers to itself”, on both backends. Both used to recurse until the stack was gone: the binary crashed and an embedding host was killed for its memory. A value merely shared twice still serialises.
  • A timestamp column reads back as RFC3339 everywhere. The pg path on the VM gave `2026-06-12T13:11:42+01:00` while the native runtime and the generic `database/sql` path gave a different layout, so a program parsed its own timestamps on one backend and threw on the other.
  • The tools around the language

    Section titled “The tools around the language”

  • The editor no longer writes files into your project. The language server checked the buffer you were typing by writing it beside your file as `._lsp_check_<name>.chuks`, on every keystroke, from twelve request paths. It flickered in file trees, `chuks watch` took it for a source change and restarted the app, and a server killed mid-check left it behind. The buffer reaches the compiler in memory now, `chuks watch` ignores dot-prefixed names, and the server clears anything earlier versions left behind when it starts.
  • The dev server and a device engine agree on the compiler, or say so. Every build of `chuks` and of the mobile engine carries a compiler id: the version and a hash of the language sources it was built from. `chuks serve` reads the engine’s id on every bundle and delta request and answers 409 when they differ, so a stale engine is named on the device’s dev overlay instead of reporting type errors in code that was fine.
  • A linked package is not checked against the lockfile. `chuks link` points a package at a working copy you are editing, so there is no published artifact that was consented to and no hash to check against. The build used to say “permission violation” and stop; it says once that the package is linked and carries on.
  • `chuks serve` no longer leaves a module out of a delta when it entered the bundle between two syncs, which the device reported as a missing import until a full reload.
  • An error a builtin raises is reported at its own line on the VM. A narrowing or an `as` cast named the previous statement’s line inside a block, and no line at all at the top of a file.
  • Breaking changes

    Section titled “Breaking changes”

    1. `var body: T = req.parseBody(T)` becomes `var body: T? = ...` plus a null check. 2. `req.header(name)` and `req.cookie(name)` are `string?`: add `?? ""` or a check. 3. A typed array may not be passed where a `[]any` parameter is grown in place. Declare the parameter as the typed array, or pass a `[]any`. 4. A class from one module is not the same type as a same-named class declared in another. Import it rather than redeclaring it. 5. A value arriving through `any` is checked against the declared type at the line it lands. Code that relied on a float truncating through an `int`, or a string arriving in an `int` slot, now fails there.

    A forward call whose types disagree is also an error now, which only affects code that was already wrong.

    How this one was tested

    Section titled “How this one was tested”

    Every compiler change in rc.2 had to pass the whole battery before it could be committed, not before the release:

  • 466 golden programs on the bytecode VM and 466 on the native binary, compared against expected output.
  • 27 differential suites, roughly 1,900 cells, each a shape whose printed output must match on both backends. Two are new: `array_identity_differential.py` with 32 cells for every place an array can have a second name, and 15 new cells in `case_names_differential.py` for every place a name can have a case twin.
  • Generated programs, single file and multi module, diffed between the two backends.
  • The package consumers’ own test files, 27 of them, byte-identical both ways.
  • `coverage_gaps.py` runs the whole gate through a coverage-instrumented compiler and lists the branches no test reaches, and `coverage_gaps_differential.py` holds the programs written to reach them. Three of the fixes above came from that list, including a `flatMap` fast path that had been unreachable since arrays became one object.

Two of those goldens are new in another sense: their expected output was written by hand from the language’s rules before either backend ran, rather than recorded from a run. That is slower to write, and it is the only kind of test that can catch both backends being wrong in the same way.

What we are asking for

Section titled “What we are asking for”

The same as rc.1, and it worked. Build something, or point an existing program at both modes:

chuks run main.chuks          # the bytecode VMchuks build main.chuks        # the native binary

Where they disagree, or where either is wrong, that is a bug we want, at <https://github.com/chuks-programming-language/releases/issues/new?template=bug%5Freport.yml>with `chuks --version` and the smallest file that shows it. A message beginning “Internal compiler error” is always ours.

chuks.org · 1 images · 56 links

Comments (0)

No comments yet