DEV Community

Karac Thweatt
Karac Thweatt

Posted on

The Language After Rust and Zig

Systems programming has been having a moment. After decades of C and C++ as the only real options for performance-critical work, two serious challengers arrived and changed the conversation. Rust pushed the idea that memory safety and zero-cost abstractions can coexist, backed by a borrow checker that enforces ownership at compile time. Zig pushed back by arguing that simplicity, explicit allocation, and comptime evaluation are more honest than a borrow checker, and that the right amount of language is less, not more.

Both made real contributions. Both have production users, strong communities, and codebases to prove the ideas work. But a newer language called Flux is asking a different question entirely: what if the language trusted you to be a programmer, gave you powerful and composable primitives, stayed out of your way where you didn’t need it, and offered safety tools you could reach for by choice rather than mandated throughout? What if you could have hardware-level control over memory layout, arbitrary-width integer types, opt-in ownership semantics, compile-time contract enforcement, and macro-level metaprogramming, all in a language that reads as cleanly as anything else in the C family?

This article is an honest comparison. Not a hit piece on either Rust or Zig — both are serious work by serious people. But the tradeoffs they made are real, and Flux makes different ones.


The Ownership Problem
Rust’s borrow checker is its most famous feature and its most famous source of friction. The core promise is compelling: by tracking ownership and lifetimes throughout your program at compile time, the compiler can guarantee that no two pieces of code mutate the same data simultaneously, that no use-after-free ever occurs, and that no data races are possible in safe code. When it works, it is remarkable. Programs that compile are, in a meaningful sense, correct with respect to a large class of memory errors.

The cost is real though. You annotate lifetimes on functions three layers deep. You reach for Arc<Mutex<T>> to share state between components in a way that would be two lines in C. You fight the borrow checker over patterns that are obviously correct to a human reader but opaque to a system that must be conservative. The borrow checker is an all-or-nothing proposition: either your entire program is in the system, or you are in unsafe and writing C inside a Rust wrapper. There is no middle ground where you say "I want move semantics on this specific type, but I don't need the compiler tracking every reference in this module."

Zig went the other direction. There are no ownership semantics in the Zig type system at all. Memory safety comes from discipline, explicit allocators, and defer for cleanup. Every function that allocates takes an allocator as a parameter. Nothing is hidden. You know exactly where memory comes from and where it goes because you wrote it. This is honest and predictable, but it leaves the safety guarantees entirely to the programmer's discipline and code review.

Flux sits between the two, and the solution is the tie operator ~. Ownership in Flux is opt-in at the type level. You apply it to the types and values where it matters, and the rest of your program runs without ownership overhead of any kind, mental or compiled.

The rules for tied values are deliberately simple:

  • A tied value must be explicitly untied on use with the ~ prefix
  • A tied value can only move to a parameter or variable of tied type
  • Once a value is untied, the original reference is invalidated — use after untie is a compile error
def foo(~int z) -> void  // accepts a tied parameter
{
    return;
};

def main() -> int
{
    ~int x;
    int  y;
    foo(~x);   // Untie x from main's scope, tie it into foo's parameter
    foo(~x);   // Compile error: x has already been untied
    foo(y);    // Compile error: foo expects a tied parameter, y is not tied
    return 0;
};
Enter fullscreen mode Exit fullscreen mode

You use this where you actually want it: a raw buffer being handed to an allocator, a file handle that must not be duplicated, a socket that should have one owner. Nowhere else. Nowhere you did not ask for it. The compiler enforces the contract on those specific types while your hash table, your string buffer, and your integer math proceed without a borrow checker in sight.

This is also consistent with Flux’s broader philosophy: the type system gives you tools you compose deliberately, rather than a global constraint you live inside.


Compile-Time Execution
Zig’s comptime is one of its best ideas. You mark code comptime and it runs during compilation using the same syntax as runtime code — no separate macro language, no special rules for what you can and cannot write. It is just Zig, running at compile time.

Flux has the same property, and then goes further in almost every direction.

Compile-time code in Flux is Flux. You wrap code in a comptime block and it executes during the compiler's code generation pass, on a dedicated virtual machine — the FVM — that was built specifically to handle compile-time execution and also powers the REPL. You can define functions, declare variables, call functions, do I/O, run loops, branch on conditions — anything you can do at runtime, in the same syntax:

#import <standard.fx>;

comptime
{
    def foo() -> void
    {
        compiler.io.console.print("Hello from compile time!\n");
    };
    foo();
};

def main() -> int
{
    return 0;
};
Enter fullscreen mode Exit fullscreen mode

This prints Hello from compile time! during compilation, interleaved with the compiler's own output. Multiple comptime blocks share scope — functions defined in one are callable from another, and variables persist across blocks.

Rust has no equivalent to this. const fn is a restricted subset of the language that excludes most interesting operations. Procedural macros run at compile time but are a completely separate Rust program operating on token trees, not a Rust function running in the same language with the same syntax. The mental model is fractured across three separate systems. In Flux, compile-time code is just code.

The FVM goes deeper than that. Because the compile-time executor is a real virtual machine with its own instruction set, you can drop into raw FVM assembly inside a comptime block using a fluxvm block, operate directly on comptime-scope variables, and have those modifications visible to subsequent Flux comptime code:

comptime
{
    int x = 5;

    fluxvm
    {
        LOCAL_GET x
        PUSH 10
        ADD
        LOCAL_SET x
    };
    compiler.io.console.print(f"FluxVM modified int x = {x}\n");
};
Enter fullscreen mode Exit fullscreen mode

After the fluxvm block executes, x is 15 and the print statement sees that value. The FVM instructions operate on the same variable table as the surrounding Flux comptime code. This is not a simulation or a separate address space — it is the same VM that is running the comptime execution, exposed directly.

And you can dump the FVM assembly of your comptime code to a file for independent execution:

compiler.fvm.dump("my_comptime.fvm")
Enter fullscreen mode Exit fullscreen mode

That file can be executed standalone by the FVM outside of a compilation, which means comptime logic can be extracted, inspected, shared, or reused as independent programs. The compile-time executor is not a black box baked into the compiler. It is a documented, accessible VM with a text assembly format you can read and write directly.

Compare this to Zig: Zig’s comptime also runs the same language at compile time, which is the right idea, but the compile-time executor is an internal interpreter with no exposed instruction set, no dump capability, and no path to independent execution. What Flux adds is the full stack — a real VM, a readable assembly format, direct inline access to that VM from comptime code, and the ability to treat comptime programs as first-class artifacts.

Zig also does not have an equivalent to emitflux, which is covered in the next section.

Named comptime blocks add another dimension entirely. A comptime block can be given a name, and any comptime code can goto that name like a label. Because comptime blocks share scope, jumping between two named blocks repeatedly is a valid loop:

comptime A
{
    // setup or per-iteration work
    goto B;
};

comptime B
{
    // more work
    if (condition) { goto A; };   // loop back
    // otherwise fall through and stop
};
Enter fullscreen mode Exit fullscreen mode

This means the control flow of your comptime execution is not limited to the structured loops and branches inside a single block. You can build arbitrary comptime control flow graphs out of named blocks, with goto as the edge. Combined with emitflux, this means the structure of your code generation can itself be driven by non-linear comptime execution — not just a single loop that emits things in order, but a full state machine at the meta level that decides what to emit and when based on conditions that evolve across block boundaries.

Code Generation with emitflux
An emitflux block inside a comptime block injects Flux code into the compilation unit at the scope where the enclosing comptime block lives, in the order the emitflux blocks appear. The comptime code is the generator. The emitflux blocks are the output — real Flux definitions that land in the file as if you had written them there by hand, fully type-checked, compiled, and available to the rest of the program.

The practical consequence is that you can drive code generation with arbitrary Flux logic running at compile time: loops that iterate over data, conditionals that branch on computed values, string interpolation and codification that build identifiers from comptime variables. Whatever the comptime execution produces gets emitted as concrete Flux into the module.

Here is a complete example — a state machine where the valid transition predicates are generated from a data table rather than written by hand:

#import <standard.fx>;

using standard::io::console;

enum State { Idle, Running, Paused, Stopped };

comptime
{
    int[] trans_from = [0, 1, 2, 1];
    int[] trans_to   = [1, 2, 1, 3];
    int   tcount     = 4;
    emitflux
    {
        def state_name(int s) -> byte*
        {
            if (s == 0) { return "Idle"; };
            if (s == 1) { return "Running"; };
            if (s == 2) { return "Paused"; };
            if (s == 3) { return "Stopped"; };
            return "Unknown";
        };
    };
    for (int tidx = 0; tidx < tcount; tidx++)
    {
        emitflux
        {
            def ~$i"can_trans_{}_{}":{trans_from[tidx];trans_to[tidx];}() -> bool { return true; };
        };
    };
    emitflux
    {
        def transition(int fx, int to) -> int
        {
            if (fx == 0 & to == 1 & can_trans_0_1()) { return to; };
            if (fx == 1 & to == 2 & can_trans_1_2()) { return to; };
            if (fx == 2 & to == 1 & can_trans_2_1()) { return to; };
            if (fx == 1 & to == 3 & can_trans_1_3()) { return to; };
            println(f"Invalid: {fx}:{state_name(fx)} -> {to}:{state_name(to)}");
            return fx;
        };
    };
};
Enter fullscreen mode Exit fullscreen mode

The comptime loop runs four times. Each iteration emits one can_trans_X_Y() function into the module scope. The function name is built from the transition table values: ~$ is the codify operator — the inverse of stringification, turning a string into a valid code identifier — combined with an i-string that interpolates the comptime variables into the name. By the time the final emitflux block emits transition(), all four predicate functions already exist in the compilation unit because they were emitted in order above.

The result is a complete, type-checked state machine. Adding a new valid transition means adding one entry to the comptime data arrays. The predicate functions, the transition() dispatcher — none of that is touched. The generator produces it all.

This is what Rust’s procedural macros are attempting to do. But in Rust, that requires a separate crate compiled as a separate program, a separate registration mechanism, and working with token tree manipulation through an API that is distinct from ordinary Rust. You are not writing Rust — you are writing a Rust program that writes Rust tokens. In Flux, you write Flux that writes Flux. Everything is in one file, in one syntax, with a direct and readable relationship between generator and output.


Expression Macros
Separate from comptime, emitflux, and the FVM, Flux has expression-level macros for inline call-site substitution. A macro definition looks like a function but expands before code generation, substituting its arguments into an expression at every call site:

macro xyz(a, b, c)
{
    (a + b) ^ c
};

println(f"xyz(1,2,3) = {xyz(1, 2, 3)}");
// Expands to: println(f"xyz(1,2,3) = {(1 + 2) ^ 3}")
// Prints: xyz(1,2,3) = 27
Enter fullscreen mode Exit fullscreen mode

The macro body is an expression, not a statement list or a token tree. It cannot inject control flow into a call site. It substitutes its arguments and the resulting expression appears at the call site. Macros compose cleanly with contracts — a contract can use macros, and both attach to functions or operators through the same syntax:

macro macNZ(x)      { x != 0 };

contract ctNonZero(a, b)
{
    assert(macNZ(a), "a must be nonzero");
    assert(macNZ(b), "b must be nonzero");
};
Enter fullscreen mode Exit fullscreen mode

Macros can also be self-referential, allowing recursion:

#import <standard.fx>;

using standard::io::console;
macro factorial(n)
{
    n * factorial(--n) if (n > 1) else n
};
def main() -> int
{
    int x = factorial(5);
    println(x); // 120
    return 0;
};
Enter fullscreen mode Exit fullscreen mode

Here’s a more advanced example of metaprogramming in Flux:

///
    metademo.fx -- a CLI calculator whose dispatch is built entirely
    at compile time via comptime + emitflux.

    metaprogramming techniques used:
      - macro          : compile-time expression helpers
      - comptime       : generate code and print build-time diagnostics
      - emitflux       : inject generated Flux source into scope
      - $              : stringify identifiers into byte* names
      - ~$             : codify strings back into identifiers / source
      - i-string       : build identifier names from loop variables
      - g-string       : deduplicated global string constants
      - self-ref macro : FVM-evaluated factorial baked in as a constant
///

#import <standard.fx>;

using standard::io::console;

// -------------------------------------------------------------------
// Step 1: macros
// -------------------------------------------------------------------

macro clamp(v, lo, hi)
{
    (v if (v > lo) else lo) if ((v if (v > lo) else lo) < hi) else hi
};

// self-referential -- FVM constant-folds entirely at compile time
macro ct_fact(n)
{
    n * ct_fact(--n) if (n > 1) else n
};

// stringify a variable name and print it alongside its value
macro named_print(val)
{
    println(f"{$val} = {val}")
};

// number of ops -- change this and everything below regenerates
#def NUM_OPS 4;

// -------------------------------------------------------------------
// Step 2: comptime generates one handler per op, a dispatch function
//         with a generated switch body, and a names table
// -------------------------------------------------------------------

comptime
{
    byte*[NUM_OPS] op_names = ["add", "sub", "mul", "div"],
                   op_syms  = ["+",   "-",   "*",   "/"],
                   op_exprs = ["a + b", "a - b", "a * b", "a / b"];

    compiler.io.console.println("[comptime] generating op handlers...");

    // emit one handler function per op
    int i;
    byte* nm, expr;
    while (i < NUM_OPS)
    {
        nm   = op_names[i];
        expr = op_exprs[i];

        compiler.io.console.println(f"[comptime]   op_{nm}");

        emitflux
        {
            def ~$i"op_{}":{nm} (int a, int b) -> int { return ~$expr; };
        };

        i++;
    };

    // emit dispatch(int id, int a, int b) -> int
    // body is a switch with one generated case per op
    compiler.io.console.println("[comptime] generating dispatch...");

    emitflux { def dispatch(int id, int a, int b) -> int;  };

    // we need to emit the full function body as one emitflux,
    // so build the case list by emitting each case individually
    // inside a generated function shell

    // emit the switch shell open
    emitflux
    {
        def dispatch(int id, int a, int b) -> int
        {
            switch (id)
            {
    }#;

    // emit one case per op
    int j, jv;
    while (j < NUM_OPS)
    {
        nm = op_names[j];
        jv = j;

        emitflux
        {
                case (~$i"{}":{jv;}) { return ~$i"op_{}":{nm;} (a, b); }
        };

        j++;
    };

    // close the switch and function
    emitflux
    {
                default { return -1; };
           #};
       #};
    };

    // emit the op name and symbol tables as plain arrays
    emitflux
    {
        byte*[NUM_OPS] g_op_names = [g"add", g"sub", g"mul", g"div"],
                       g_op_syms  = [g"+",   g"-",   g"*",   g"/"];
    };

    int fingerprint = ct_fact(6);
    compiler.io.console.println(f"[comptime] fingerprint = {fingerprint}");

    emitflux
    {
        int g_fingerprint = ~$f"{fingerprint}";
    };

    compiler.io.console.println("[comptime] done.");
};

// -------------------------------------------------------------------
// Step 3: runtime
// -------------------------------------------------------------------

def usage(byte* prog) -> void
{
    println(f"usage: {prog} <op> <a> <b>");
    println(g"ops:");
    int i = 0;
    while (i < NUM_OPS)
    {
        println(f"  {g_op_names[i]}  ({g_op_syms[i]})");
        i++;
    };
    println(f"build fingerprint: {g_fingerprint}");
};

def find_op(byte* name, int len) -> int
{
    int i, j, match;
    byte* candidate;
    while (i < NUM_OPS)
    {
        candidate = g_op_names[i];
        j         = 0;
        match     = 1;

        while (j < len)
        {
            { match = 0; break; } if (name[j] != candidate[j]);
            j++;
        };

        { return i; } if (match == 1);
        i++;
    };
    return -1;
};

def main() -> int
{
    int demo = clamp(42, 0, 100);
    named_print(demo);

    byte* prog    = g"metademo";
    byte* op_name = g"div";
    int   op_len  = 3;
    int   a       = 21;
    int   b       = 3;

    int op_id = find_op(op_name, op_len);

    if (op_id < 0)
    {
        println(g"unknown op");
        usage(prog);
        return 1;
    };

    int result = dispatch(op_id, a, b);

    println(f"{a} {g_op_syms[op_id]} {b} = {result}");
    println(f"stored in '{$result}': {result}");

    return 0;
};
Enter fullscreen mode Exit fullscreen mode

Templates
Templates in Flux are type substitution. No SFINAE, no concept maps, no enable_if, no multi-line where clauses. They apply to functions, structs, objects, and operator definitions, and the compiler infers template parameters at call sites so angle brackets stay out of everyday code:

def foo<T>(T x) -> T { return x; };

int z   = foo(3);     // T inferred as int
float y = foo(5.5f);  // T inferred as float
Enter fullscreen mode Exit fullscreen mode

Where Flux’s template system goes beyond Rust or Zig is constraint sets — named, reusable relational expressions that describe not just properties of a single type but how a family of types may interact throughout a template body:

constraint NoNarrowing(A)
{
    A !`< A    // A must never be narrowed anywhere in this template body
};

def serialize<T: int, :{NoNarrowing}>(T x) -> byte
{
    return 5 + x;   // Compile error: narrowing T to byte violates NoNarrowing
};
Enter fullscreen mode Exit fullscreen mode
!`< // This operator (no bit lower)
Enter fullscreen mode Exit fullscreen mode

is verified by walking the instantiated function body — not checked against a declaration, but found by analysis of actual operations. The example errors because5 + xwithx: intreturned intobyte` is an implicit narrowing, and the compiler finds it. Circular multi-parameter expressions describe type geometry in a single line:

D !~= B & [A !@ A] !~= C !< D !-= A`

Values of type A cannot have their address taken. D must be incompatible with B and A. B and A must be incompatible with C. C and D cannot appear on either side of a narrowing. D cannot be used in signed operations with A. This is not “T must implement Trait.” It is a relational specification of how a family of types may and may not interact, verified by the compiler across the entire template body.


Safety You Compose, Not Safety You Survive
Rust’s safety guarantee is structural: if it compiles without unsafe, a well-defined set of memory errors cannot occur. The tradeoff is friction, annotation, and the fact that the borrow checker is a conservative approximation that rejects some correct programs.

Zig’s safety guarantee is operational: sanitizers catch issues at runtime in debug builds, and you have an allocator and defer, and the language is simple enough that you can reason about it carefully. There is no type-level enforcement beyond what you build by convention.

Flux adds contracts, which are named code blocks you attach to functions as part of their specification. A contract ends up inside the function body. It is a modifier on the function’s declaration that the compiler splices into the generated code. Pre-contracts go before the function body. Post-contracts go before each return:

contract NonZero(a, b)
{
    assert(a != 0, "a must be nonzero");
    assert(b != 0, "b must be nonzero");
};

contract ResultPositive(a, b)
{
    assert(a + b > 0, "result must be positive");
};

def add(int a, int b) -> int : NonZero(a, b)
{
    return a + b;
} : ResultPositive(a, b);
Enter fullscreen mode Exit fullscreen mode

The contract transforms the function into:

def add(int a, int b) -> int
{
    assert(a != 0, "a must be nonzero");
    assert(b != 0, "b must be nonzero");
    assert(a + b > 0, "result must be positive");
    return a + b;
};
Enter fullscreen mode Exit fullscreen mode

Contracts can go on operator overloads. If you define a custom addition operator for a BigInt type, you can attach a contract that verifies invariants on every call without that logic living inside the function itself:

operator (int L, BigInt R) [+] -> BigInt : NonZero(L, R)
{
    // Implementation
};
Enter fullscreen mode Exit fullscreen mode

The contract runs on every dispatch to that overload. The verification is real. The overhead is proportional to what you assert, not to a global memory model.

Traits in Flux serve the same role as in Rust — enforcing interface compliance at compile time — but without the orphan rules, coherence requirements, or the separation between defining a trait and implementing it that makes Rust trait code fragile across crate boundaries. A trait is a list of prototypes an object must implement:

trait Drawable
{
    def draw() -> void;
};

Drawable object myObj
{
    def __init() -> this { return this; };
    def __exit() -> void {};
    def __expr() -> myObj* { return this; };
    def draw() -> void
    {
        // Must have a body; an empty implementation is a compile error
    };
};
Enter fullscreen mode Exit fullscreen mode

Interfaces go further than traits by restricting which methods two objects may call on each other to an explicitly declared list. Calling any method not in the interface’s listed set — even a public one — is a compile error:

interface Stream(A: Readable, B: Writable)
{
    A : B { read(byte*,int)->int, write(byte*,int)->int, flush()->int };
    B : A { ack()->int };
};

object Pipe {} : Stream(this, Socket);
Enter fullscreen mode Exit fullscreen mode

Once Pipe declares this interface, the compiler verifies at every Socket call site that the method being called is in the declared list. Socket does not need to know about the interface. The enforcement is on Pipe's use of Socket, not on Socket's definition. This is a different and often more practical model than Rust's, where coherence rules dictate what can be implemented where.

Deprecation is a first-class language feature rather than a compiler attribute or documentation convention:

deprecate test1::test2;

Referencing a deprecated namespace anywhere in the program is a compile error with a precise message showing the call site. You cannot accidentally leave uses of a deprecated API in a codebase. The compiler will not compile until they are removed.


A Data Model Built for Hardware
Rust’s type system thinks in structs, enums (which are really tagged unions), and references. It has u8, u16, u32, u64, and so on. If you need a 13-bit integer, you use a u16 and mask manually, or you reach for a proc macro crate like bitfield. The language does not have a concept of arbitrary-width integers as first-class types.

Zig has packed structs and a more explicit integer width system (u13, i7, etc.) that gets you closer. But the control over alignment, endianness, and the ability to chain type aliases is not there.

Flux has the data keyword, which creates arbitrary-width integer types as first-class language citizens:

signed data{13:16} as strange13;     // 13-bit signed, 16-bit aligned
unsigned data{3} as tiny;            // 3-bit unsigned, values 0-7
unsigned data{7:8} as aligned7;      // 7-bit with 8-bit alignment
unsigned data{5}[10] as 5b_array;    // Array of 5-bit values
Enter fullscreen mode Exit fullscreen mode

The full specification is data{width : alignment : endianness}. Big-endian is the default. Little-endian is specified as the third parameter with 0:

data{16::0} as le16;   // Little-endian 16-bit
data{16}    as be16;   // Big-endian 16-bit (default)
Enter fullscreen mode Exit fullscreen mode

Endian conversion in Flux is handled by assignment between types of different endianness — the compiler emits the byte swap. There is no byteswap intrinsic to remember to call. There is no htons/ntohs family. You assign a be16 to an le16 and the swap happens:

def network_to_host(be16 net_value) -> le16
{
    le16 x = net_value;   // Byte swap emitted automatically
    return x;
};
Enter fullscreen mode Exit fullscreen mode

All math is performed in a single endianness internally. Mixed-endian arithmetic does not silently produce the wrong answer because there is no mixed-endian arithmetic — the conversion happens at assignment, not mid-expression.

You can also type-pun chains of data aliases:

unsigned data{16} as dbyte;
dbyte as xbyte;
xbyte as ybyte;
ybyte as zbyte zbx = 0xFF;
Enter fullscreen mode Exit fullscreen mode

Each alias is a distinct type for the compiler’s purposes. endianof, alignof, sizeof, and typeof are built-in operators that return properties of any type:

sizeof(strange13);    // 13 (bits)
alignof(strange13);   // 16
typeof(strange13);    // "signed data{13:16}"
endianof(strange13);  // 1 (big-endian)
Enter fullscreen mode Exit fullscreen mode

This is the type system that networking code, cryptography implementations, hardware register maps, and binary protocol parsers actually need. You do not reach for a proc macro crate or maintain a packed_struct wrapper. You write the type you mean.


The Bit Manipulation Story
One of the most tedious aspects of systems programming is bit manipulation. Extracting a field from a packed register. Replacing a range of bits in place. Converting between array representations and integer representations. Every language leaves you writing the same masking and shifting patterns over and over.

Flux has the bit-slice operator which extracts a range of bits from any integer-width value and returns it as a value of the appropriate width:

u32 packed = 0x12345678;
u32 nibble0 = packed[0``3];    // Bits 0-3:   0x8
u32 nibble3 = packed[12``15];  // Bits 12-15: 0x5
u32 byte1   = packed[8``15];   // Bits 8-15:  0x56
Enter fullscreen mode Exit fullscreen mode

Assignment into a bit slice replaces just that range in place, leaving every other bit untouched:

u32 packed = 0x12345678;
packed[24``31] = 0xFF;
// packed == 0x123456FF
Enter fullscreen mode Exit fullscreen mode

This eliminates entire categories of (value & mask) >> shift boilerplate. The start and end indices must be compile-time constants, so the compiler can verify correctness and generate the right mask in one step.

Array-to-integer packing takes this further. Casting a fixed-size array to an integer type packs the array element-by-element into the integer, with element 0 occupying the most significant bits. This is defined behavior with a consistent convention, not implementation-defined. The consequence is that multi-byte big-endian operations that take 16 lines in C can take one in Flux:

C:

len_block[0]  = (byte)((aad_bits >> 56) & 0xFF);
len_block[1]  = (byte)((aad_bits >> 48) & 0xFF);
len_block[2]  = (byte)((aad_bits >> 40) & 0xFF);
len_block[3]  = (byte)((aad_bits >> 32) & 0xFF);
len_block[4]  = (byte)((aad_bits >> 24) & 0xFF);
len_block[5]  = (byte)((aad_bits >> 16) & 0xFF);
len_block[6]  = (byte)((aad_bits >>  8) & 0xFF);
len_block[7]  = (byte)( aad_bits        & 0xFF);
Enter fullscreen mode Exit fullscreen mode

Flux:

len_block[0..7] = (byte[8])(u64)aad_bits;
Enter fullscreen mode Exit fullscreen mode

The array comprehension syntax makes bulk initialization similarly expressive:

int[10] squares = [x ^ 2 for (int x in 1..10)];
int[20] evens   = [x     for (int x in 1..20) if (x % 2 == 0)];
Enter fullscreen mode Exit fullscreen mode

Python-style and C-style comprehension both work. You can also comprehend over dynamic collections:

Array[] filtered = [x.name for (Array x in oldArr) if (x.name.len() > 5)];
Enter fullscreen mode Exit fullscreen mode

Structs, Objects, and the Separation Between Data and Behavior
C++ conflated data and behavior in classes, giving you a type that could be both a plain data container and a method-laden object. This was convenient but blurred the distinction between things that are just memory layouts and things that are executable. Rust’s structs and impl blocks separate layout from behavior but do not enforce that one particular kind of type is only data.

Flux makes the separation explicit and enforced at the language level. Structs are data only:

struct Header
{
    data{16} sig;
    data{32} filesize, reserved, dataoffset;
};
Enter fullscreen mode Exit fullscreen mode

Placing any executable statement — a for loop, an if block, a try/catch, a function definition — inside a struct is a compile error. Structs cannot contain functions or objects. They can contain pointers, including function pointers. They are tightly packed with no padding unless alignment is specified in the type. This is not something you configure with #[repr(packed)] or a pragma. It is the default and the only layout.

Struct composition is done directly in the definition without OOP inheritance ceremony:

struct BMP : Header, InfoHeader
{
    // Additional BMP fields
} : ExtraData;
Enter fullscreen mode Exit fullscreen mode

This prepends and appends fields from the named structs. No vtables. No runtime polymorphism. Just “the layout of BMP includes these fields from these structs in this order.”

Objects are the executable counterpart. They can contain methods, use this, and have lifecycle management through required methods:

object SomeObj
{
    int val;

    def __init(int x) -> this
    {
        this.val = x;
        return this;
    };

    def __exit() -> void {};

    def __expr() -> int { return this.val; };
};
Enter fullscreen mode Exit fullscreen mode

__init is the constructor, called on instantiation. __exit is the destructor, called manually or via defer. __expr is the expression context method — when an object instance appears in an expression context, the compiler calls this and uses its return value. So println(sobj) becomes println(sobj.__expr()) automatically.

If __init takes exactly one parameter, you can instantiate with assignment syntax:

SomeObj sobj = 5;    // Equivalent to SomeObj sobj(5);
Enter fullscreen mode Exit fullscreen mode

Object inheritance in Flux is designed to eliminate the diamond problem rather than work around it. A child object inherits all non-private, non-mandatory members from its parents. If two parents have a method with matching signatures but different implementations, the compiler errors. The child does not inherit mandatory methods (__init, __expr, __exit) and must define its own. Multiple inheritance is expressed as object C : A, B. The !+ modifier marks a method as non-overridable, and attempting to override it in a child is a compile error.

The separation between struct and object also matters for templates: struct template parameters cannot be of object type, because objects are not pure data. The compiler enforces this. You cannot accidentally instantiate a struct template with a type that carries behavior.


Error Handling Without a Return Type Tax
Rust’s error handling uses the Result<T, E> type and the ? propagation operator. It is clean, compositional, and enforced: you cannot accidentally ignore an error because the return value is a Result that you must handle or propagate explicitly. The cost is that every function in a call chain that can fail must have its return type annotated with Result, and the error type must propagate consistently or be converted.

Zig uses explicit error unions (!T) and try for propagation. The error set is part of the type and can be inferred. This is cleaner than Rust in some ways — the error types are values not trait objects — but it still means annotating every function in the chain.

Flux uses try/throw/catch with typed catches that dispatch on the thrown type. This is structurally similar to C++ exceptions but without the hidden stack unwinding cost — the design is explicit call-site dispatch rather than a global unwind table:

object ERR
{
    byte* message;
    def __init(byte* m) -> this { this.message = m; return this; };
    def __exit() -> void {};
    def __expr() -> byte* { return this.message; };
};

def myErr(int code) -> void
{
    switch (code)
    {
        case (0)
        {
            ERR e("Custom error\0");
            throw(e);
        }
        default
        {
            throw("Default error\0");
        };
    };
};

def main() -> int
{
    try
    {
        myErr(0);
    }
    catch (ERR e)        // Typed catch: dispatches on ERR object
    {
        byte* msg = e.message;
    }
    catch (byte* s)      // Typed catch: dispatches on raw string
    {
    }
    catch (auto x)       // Catch-all
    {
    };
    return 0;
};
Enter fullscreen mode Exit fullscreen mode

Catches dispatch in order on the thrown type. auto is the catch-all. assert integrates with the try/catch system: inside a try block, a failed assert throws the assertion message as a string rather than writing to stderr, so you can catch assertion failures with the same mechanism as thrown errors.

The practical difference from Rust’s model is that functions in the middle of a call chain do not need to annotate their return types with a result type to propagate errors. A function that might throw simply throws. The catch happens where you want to handle it. For many patterns — particularly in systems code where you want to catch errors at a high level and log or recover — this is less boilerplate than threading Result through every layer.


The Operator System
Rust allows operator overloading through trait implementations. The set of overloadable operators is fixed. You implement std::ops::Add for your type and + dispatches to it. This is clean but limited to the existing operator set, and implementing it requires understanding the trait system and lifetime annotations on the return type.

Zig has no operator overloading.

Flux has a complete operator system that allows both overloading existing operators and defining entirely new ones:

// New operator
operator (int L, int R) [+++] -> int
{
    return ++L + ++R;
};

int result = a +++ b;   // Dispatches to the custom operator

// Identifier-based operator
operator (int L, int R) [NOPOR] -> bool
{
    return !L | !R;
};

bool check = a NOPOR b;
Enter fullscreen mode Exit fullscreen mode

Overloading built-in operators follows two rules: one parameter must not be a built-in type (so you cannot silently override integer addition), and the overload’s precedence and associativity cannot be changed from the built-in symbol’s. These rules prevent the most common overloading abuses while leaving the useful cases open.

Custom operators also accept contracts:

operator (int L, BigInt R) [+] -> BigInt : ValidOperands(L, R)
{
    // Implementation
};
Enter fullscreen mode Exit fullscreen mode

Every dispatch to this overload runs ValidOperands first. The contract is part of the operator's specification, not its implementation.

Templated operators instantiate lazily on first use with concrete types:

operator<T> (T L, T R) [+] -> T
{
    // Generic implementation
};
Enter fullscreen mode Exit fullscreen mode

Each time this appears in code with a new concrete pair of types, a new overload is generated for that pair.

Soon, Flux will allow you to create prefix and postfix operators, not just infix, as well as specify precedence.


Type Functions and the Method Call Syntax Problem
Rust puts methods on types through impl blocks, which are separate from the type definition and can appear anywhere. This means the method set of a type is open — anyone can add methods to any type in any file. Zig has no method syntax; you call TypeName.function(value, args) with the first parameter being the receiver.

Flux has type functions, which extend any type with dot-call syntax without the overhead of object definitions or the open-world problem of impl blocks:

byte*.reverse() -> byte*
{
    // _ is the receiver, equivalent to `this`
    // implementation...
};

println("world".reverse());   // Calls the above
Enter fullscreen mode Exit fullscreen mode

You can do this for built-in types, struct types, and pointer types. The receiver is _ inside the function. You can also use "" as shorthand for the string type:

"".add_world() -> ""
{
    return _ + ", World!";
};

byte* greeting = "Hello".add_world();  // "Hello, World!"
Enter fullscreen mode Exit fullscreen mode

Literal modifier functions let you define behavior that attaches to literal suffixes:

0.my_int_func() -> int   // Attaches to integer literals: 55.my_int_func()
{
    return _;
};
Tied types can have type functions too:

~byte*.consume() -> ~byte*
{
    return _[0] - 11;
};
Enter fullscreen mode Exit fullscreen mode

This means the ownership semantics propagate through type function calls the way you would expect: calling a type function on a tied value moves the tied value into the function’s scope.

To disallow a type from having functions, you must create a primitive with data! like so:

#import <standard.fx>;

using standard::io::console;

data!{32} as u32i;

u32i.foo() -> u32i { return 0u; };

def main() -> int
{
    return 0;
};
Enter fullscreen mode Exit fullscreen mode

Real screenshot of Flux enforcing functionless types with an error.


Control Flow That Says What It Means
Rust’s control flow is mostly conventional C with match as the flagship addition. Zig's control flow is simpler than C's in some ways — no implicit fallthrough in switch, for example — but does not add much beyond the explicit.

Flux makes deliberate decisions about control flow that make code more readable and less error-prone.

elif is a first-class keyword rather than else if, following Python's lead and reducing a common visual indentation problem:

if (x > 0)      { doThis(); }
elif (x < 0)    { doThat(); }
else            { doDefault(); };
Enter fullscreen mode Exit fullscreen mode

Switch statements do not fall through between cases. Each case is its own block. Exiting a switch uses break switch; rather than bare break, so when a switch is inside a loop, a break unambiguously exits the loop rather than the switch:

for (int i = 0; i < n; i++)
{
    switch (values[i])
    {
        case (0)
        {
            // handle zero
            break switch;   // Exits the switch
        }
        case (1)
        {
            if (done) { break; };   // Exits the for loop
        }
        default
        {
            continue;   // next iteration of the for loop
        };
    };
};
Enter fullscreen mode Exit fullscreen mode

There is no ambiguity. break switch means the switch. break means the nearest enclosing loop. They are different things with different syntax.

The in keyword works as both a loop iterator and a membership test in any expression context:

int[5] arr = [1, 2, 3, 4, 5];
if (3 in arr) { found(); };

int[2] pair = [3, 4];
if (pair in arr) { contiguous_match(); };

byte* s = "Hello World";
if ("World" in s) { substring_found(); };
Enter fullscreen mode Exit fullscreen mode

A single operator handles value membership, sub-sequence search, and substring search depending on the types involved. The intent is readable at the call site without needing a method name to explain what is happening.

singinit variables initialize exactly once across all calls to a function, providing static-local-like semantics:

def counter() -> void
{
    singinit int x;   // x initializes to 0 on first call, retains value on subsequent calls
    x += 1;
    print(x);
};
Enter fullscreen mode Exit fullscreen mode

Strict tail recursion with <~ guarantees zero stack growth for recursive functions and is a compile-time enforcement rather than an optimizer hint:

def recurse() <~ void
{
    // Function always returns to itself
    // musttail is emitted; stack frame cannot grow
};
Enter fullscreen mode Exit fullscreen mode

If you write code in a <~ function that would prevent tail-call optimization, the compiler errors. You can break out of strict recursion with escape to jump to a different function, which is the only way to exit a <~ function to a non-self target, used like escape other_func();.


Memory Management Without the Ceremony
Rust’s memory management comes from the borrow checker plus smart pointers like Box, Rc, and Arc. Knowing when to use which, and how to structure your data to avoid reference cycles in Rc, is a substantial part of the Rust learning curve. The stack vs. heap distinction is mostly implicit — let x = 5 is stack, Box::new(5) is heap.

Zig is explicit about everything: you call allocator.alloc(), you call allocator.free(), and you use defer to ensure cleanup. The allocator is a parameter you thread through your code. This is completely honest but adds boilerplate.

Flux makes the distinction explicit with syntax rather than with a separate type hierarchy:

int x = 5;        // Stack allocated
heap int y = 5;   // Heap allocated; y is a pointer
Enter fullscreen mode Exit fullscreen mode

Deallocation uses the void cast, which also works on stack variables (zeroing them and invalidating the reference):

(void)y; // Frees the heap allocation; y is no longer valid

The defer keyword handles cleanup in the same LIFO order as Zig, but in Flux defer can be a block:

heap int* buffer = allocate_thing();
defer
{
    cleanup_side_effects();
    (void)buffer;   // Freed last, after side effects are cleaned up
};
// Work with buffer here; cleanup is guaranteed on any return path
Enter fullscreen mode Exit fullscreen mode

Deferred statements execute after post-contract code and immediately before the function returns, in reverse declaration order. This makes resource cleanup predictable and composable without an RAII wrapper type.


Performing Inline Assembly
All three languages support inline assembly. In Rust it is asm! with a domain-specific argument syntax. In Zig it is @asm with explicit I/O binding. Both work. Neither is especially readable.

Flux uses AT&T-style assembly in an explicit block with architecture guards and standard constraint syntax:

def _exchange64(i64* ptr, i64 value, i64* out) -> void
{
    #ifdef __ARCH_X86_64__
    volatile asm
    {
        movq $0, %rsi
        movq $2, %rdi
        movq $1, %rax
        xchgq %rax, (%rsi)
        movq %rax, (%rdi)
    } : : "r"(ptr), "r"(value), "r"(out) : "rax", "rsi", "rdi", "memory";
    #endif;
    #ifdef __ARCH_ARM64__
    volatile asm
    {
    .retry_xchg64:
        ldaxr x0, [$0]
        stlxr w3, x1, [$0]
        cbnz  w3, .retry_xchg64
        str   x0, [$2]
    } : : "r"(ptr), "r"(value), "r"(out) : "x0", "w3", "memory";
    #endif;
};
Enter fullscreen mode Exit fullscreen mode

The pattern outputs : inputs : clobbers follows GCC's inline assembly convention, which means programmers who already know how to write inline assembly in C can write it in Flux without learning a new constraint language. The volatile tag tells the compiler to not touch the block under any circumstances. The #ifdef __ARCH_*__ guards are preprocessor directives that let you write one function with platform-specific implementations, all in one place.

This matters for systems code in a way that higher-level languages can wave away. Writing a compare-and-swap, a spinlock, or a hardware I/O operation requires inline assembly in most cases. Having a readable, familiar interface to it — rather than a domain-specific macro language bolted on top — reduces the chance of getting the constraints wrong.


The Tooling Picture
Flux compiles to native code via LLVM, which means it has access to the same optimization passes and target backend support as Rust and Zig. The fxc.py compiler frontend handles lexing, parsing, type checking, and LLVM IR generation. A VM interpreter (fvm.py) exists for development and debugging, allowing programs to be run and traced without a full compile-link cycle.

The package manager fpm handles dependencies with local registry storage. Libraries compile to .so on Linux and .dll on Windows with the same source-level export syntax:

export
{
    def !!my_function() -> void {};
};
Enter fullscreen mode Exit fullscreen mode

The !! prefix suppresses name mangling, making the export link-compatible with C. You can call any Flux library from C and vice versa using the same extern declaration mechanism that declares external functions. Alternatively you can declare the calling convention as cdecl:

export
{
    cdecl my_function() -> void {};
};
Enter fullscreen mode Exit fullscreen mode

For interoperating with existing C codebases, a translation tool called cft parses C headers and source files using libclang and emits Flux equivalents. This handles the majority of mechanical translation: function prototypes become extern declarations, typedef pointer chains become Flux type aliases, struct definitions become Flux structs, switch statements emit break switch correctly, and raw pointer arithmetic is preserved for manual review. The tool is not a perfect translator — macro-heavy C code requires manual intervention — but for coreutils-scale C codebases it produces a readable starting point.


What Flux Is Not Claiming
Flux isn’t claiming to be formally verified. It does not generate proofs. It does not use linear types in the sense that Idris or ATS do. The tie operator is a practical ownership model, not a full affine type system.

Flux is not claiming that its error handling is better than Rust’s Result in every context. For a large library where you want callers to be statically forced to handle every failure case, typed returns are a better fit. The try/throw/catch model is better for systems code where you want to catch errors at a high level rather than threading a result type through twenty call frames.

Flux is not claiming to be Rust’s replacement in organizations where the borrow checker’s guarantees are worth the friction. Those organizations exist and their choice is justified.

What Flux is claiming is that there is a large and underserved space between “fight the borrow checker” and “rely entirely on discipline.” A space where a programmer who knows what they are doing wants real safety tools — ownership on specific types, contracts on functions, trait enforcement on interfaces, compile-time constraint verification on templates — without a global memory model they have to opt into for their entire program. A space where the type system is expressive enough to describe a 13-bit signed integer with 16-bit alignment and big-endian byte order as a first-class type, not an abstraction on top of a u16. A space where bit manipulation is readable, control flow says what it means, and the language assumes you are a professional.

That is the space Flux is designed for, and it’s a space both Rust and Zig left open.

Flux is an actively developed systems programming language. Source code, the full language specification, and the community Discord are available at the Flux GitHub repository. Try out the online compiler!

Top comments (0)