What lux Leaves Out

Design notes on a teaching language, for people who already program.

lux is a small language built to be a first one and then to be outgrown. That much is on the front page. This page is the other half of the story, and it is written for somebody who already knows three or four languages and wants to know what the thing actually is underneath the pitch.

The short version: a teaching language is defined almost entirely by its subtractions. Anybody can write a parser. The design work is deciding what a beginner should not be able to express yet, holding that line when it is inconvenient, and saying which absences are principles and which are just gaps. What follows is the list, sorted by which kind each one is, with what it costs and what it buys.

This is not a tutorial. If you want to learn lux, the terminal has lux learn and the web has An Introduction to Programming. If you want to see a real program in it, Building Your Own Keep takes apart the text adventure that ships with the compiler.

The shape of the thing

lux is a statically typed procedural language with structs, enums carrying associated values, exhaustive pattern matching, Option and Result, and no null. It has four scalar types, arrays, and functions with recursion. It reads like a stripped-down Swift and it is implemented in about fifteen thousand lines of Rust with zero dependencies.

enum Shape {
    circle(radius: float)
    rect(w: float, h: float)
}

func area(s: Shape) -> float {
    return match s {
        circle(let r)    => 3.14159 * r * r
        rect(let w, let h) => w * h
    }
}

print(area(Shape.circle(radius: 2.0)))

The proportions of the implementation say more about the project's intent than the feature list does. The language proper — lexer, parser, AST, interpreter, and the naming and scope checker — is around fifty-eight hundred lines. The translation half is around seventy-seven hundred: the three backends, the machinery they share, and the static type check that exists to keep all four legs agreeing on what counts as a valid program. The translators are bigger than the language they translate. That is the thesis expressed as a line count: lux is not trying to be a language you stay in, so most of the engineering goes into the exit. The gap has held steady as the project has gone on, which is the proportion you would want it to keep.

The standard library is short enough to list without a reference card: print, eprint, input, readLine, readFile, writeFile, run, args, length, contains, replace, split, string, int, float, parseInt, and parseFloat. That is the entire surface between a lux program and the world outside it. There is no collections module, no math beyond the operators, and the string handling stops at those three — no lowercasing, no indexing, no formatting. The two parsers hand back an Option, and file and subprocess calls hand back a Result, so a failure is a value you match rather than an exception you did not know to catch.

The tutorial ships inside the binary. Eleven hundred lines of the tree are the cards and the spells as prose, with another eight hundred of Rust to render them, and error messages carry pointers into it — a diagnostic ends with help: lux learn match and that card is a keystroke away. The reference and the implementation are the same artifact, which means they cannot drift.

Same source, same behaviour

The claim that makes the rest of this page worth reading is this one: a lux program produces identical output whether you interpret it or compile it through any of the three backends. Not "compiles cleanly" — identical bytes.

That claim is cheap to make and easy to check, so here is the check. The keep is the text adventure lux crawl writes out, about three hundred and thirty lines, and it exercises structs, enums, exhaustive matching, Option, arrays, string building, file writing, and reading from standard input. Feeding it a fixed fourteen-command walkthrough produces a hundred and eighteen lines of output.

$ lux convert rust  world.lux > w.rs    && rustc  -O -o w.rust      w.rs
$ lux convert go    world.lux > w.go    && go build -o w.go.bin     w.go
$ lux convert swift world.lux > w.swift && swiftc -O -o w.swift.bin w.swift

$ for r in "lux run world.lux" ./w.rust ./w.go.bin ./w.swift.bin; do
>     rm -f the-secret.txt
>     $r < walk.txt | md5sum
> done

eca1931e9fd0fe561a54e301d4c48202  -
eca1931e9fd0fe561a54e301d4c48202  -
eca1931e9fd0fe561a54e301d4c48202  -
eca1931e9fd0fe561a54e301d4c48202  -

Four runtimes, one hash. That is on Debian with rustc 1.95, Go 1.24, and Swift 6.0, and all three compiled without a warning.

One hash is a measurement, not a guarantee, and the difference is worth stating plainly. lux run is the reference: the three backends target the interpreter, and anywhere a compiled translation behaves differently the translation is wrong and gets fixed. Parity is a goal the project holds itself to release by release rather than a property settled once — the corpus exists to catch divergences, and every one it has caught has become a patch.

Getting there is harder than it sounds, because the interesting bugs are not syntax. They are places where a target language's defaults quietly disagree with lux's semantics, and each one takes a specific fix rather than a general one.

Go took the most work of the three, and its backend is still the largest — 2,064 lines against the Rust backend's 1,891 — almost entirely because of a single mismatch. A Go slice is a reference. A lux array is a value. So binding an array to a new name, passing one to a function, or putting one in a struct field all shared backing storage in the generated Go, and mutating the copy reached back into the original: a sort would rewrite the row it was handed. The fix is a deep copy at every point a slice-bearing value flows into a new place, recursing through struct fields so a board of grids stays independent — the same points the Rust backend already had to clone at.

Go had a second, more interesting failure. lux lowers an enum to a Go interface with one struct per case, and it lowers Option<T> to *T with nil for none. Compose those and Option<Room> becomes *Room — a pointer to an interface, which in Go is a distinct type that almost nothing satisfies. Two encodings, each fine alone, colliding. The fix is to notice that an interface value is already nilable and drop the pointer layer when the payload is an enum. It is the kind of bug that only surfaces when you write a real program: Option of an enum is the natural shape of any lookup that can fail, so learner code hits it constantly and a feature-by-feature test suite never does.

Swift contributed a smaller one with the same flavour. A range whose end falls below its start is empty in the interpreter, in Rust, and in Go, and it traps in Swift — so a bubble sort with a shrinking inner bound took the Swift build down on an empty row. Range loops now emit stride(from:to:by:) — empty rather than fatal.

Agreeing about failure, too

Producing the same answer is the easy half. The harder half is failing the same way, and that is where a transpiled language usually gives itself away: the interpreter says something helpful, the compiled binary hands you a Rust panic trace, a Go goroutine dump, or a Swift register dump for a file the learner never wrote. Beginners spend a lot of time in that territory — running off the end of an array is the most common runtime mistake there is.

Reading past the end of a three-element array, on all four:

before the fault
error: index 7 is out of bounds for an array of length 3
note: valid indices are 0 to 2
help: `lux learn arrays` — the first element is 0, so the last is
      length minus 1

Identical from lux run and from each of the three compiled binaries, with the output printed before the fault preserved. The interpreter adds the source line and a caret, which the binaries have no file to quote; everything else matches, down to the help: trail that names the rule and the card explaining it.

That last line is worth pointing at, because it was the interesting near-miss. When the runtime errors first moved onto the compiled targets they kept the diagnosis and dropped the help: — the binaries told you what went wrong and left out the lesson, which for a teaching language is the half that matters. It was restored in 0.17.1. Dividing by zero behaves the same way, and so do runaway recursion and integer overflow. The four runtimes agree about what a program does and about what it says when it stops.

That last one shows the cost side, and it is worth seeing because it is where parity stops being free. Swift traps on integer overflow; the interpreter, Go, and release Rust wrap. Those cannot all be right, so the language had to pick, and it picked wrapping — not because wrapping is better, but because trapping would mean a guard call around every +, -, and * in the generated code, and that code is meant to be read by the person who wrote the lux. A silent wraparound is the less honest semantics and it won on legibility. Insisting the four agree does not just verify the design; sometimes it decides it.

Agreeing about what counts as a program

There was a third kind of agreement missing, and it took until 0.19 to notice it was a parity question at all. The interpreter checked types as it ran, which meant a type error sitting in a branch that never executed was simply never seen. This program printed you have 3 and exited zero:

let torches = 3

if torches > 10 {
    print("plenty: " + torches)
}

print("you have", torches)

It also converted to Rust and compiled clean, because that backend routes string building through format!, which is happy to take an integer. And it did this on Go:

./dead.go:8:15: invalid operation: "plenty: " + torches
    (mismatched types untyped string and int)

Three legs accepted it and one refused, and the refusal arrived in Go's words, about a line of generated code the learner never wrote, at the one moment they were furthest from help. Nothing was wrong with any individual backend. What was wrong is that legal lux had no single definition — it was whatever the leg you happened to be standing on would tolerate.

0.19 closes that with a static pass that applies the interpreter's own concrete-type rules to every path, ahead of run, convert, and build alike. The program above is now refused before anything happens, by all three commands, in the words the interpreter always used and with the same card to go read:

error: cannot add a string and an int
  --> dead.lux:4:11
 4 |     print("plenty: " + torches)
               ^^^^^^^^^^^^^^^^^^^^
help: `lux learn strings` — lux never turns a number into text for
      you — you ask

The discipline that makes this safe rather than annoying is a rule about silence: the pass never rejects a program the interpreter would have accepted. Where it cannot pin something to a concrete type — an empty array, a bare none — it says nothing and leaves the question to run time and the target compiler, exactly as before. It is not a new type system and it adds no inference the language did not already have. It is the old rules, applied everywhere instead of only where the program happened to walk.

What that buys is worth naming, because it is a stronger claim than the hash further up. Identical output means the four legs agree about what a program does. This means they agree about which texts are programs at all. A teaching language that transpiles has to earn both, and the second one is the half that is easy to skip.

What it buys: the claim that lux is a stepping stone stops being a promise about syntax and becomes a property you can check. A learner's first real program is also a real Rust program, a real Swift program, and a real Go program, and it behaves the same in all four.

Three kinds of absence

When somebody says a teaching language "leaves out the hard parts," that usually means one undifferentiated thing. It should not. There are three quite different reasons a feature is missing, and conflating them is how a small language turns into an evasive one.

The first kind is a principle: the feature is absent because having it would break something the language is built on, and it is not coming. The second is withheld: the feature is understood, wanted, and deliberately not shipped yet, because the language wants learners to hit the wall and ask for it. The third is plainly a gap: nobody has done it, there is no argument behind it, and pretending otherwise would be dishonest.

What follows is sorted that way, and I have tried to put each one in the right bucket rather than the flattering one.

No references, anywhere

Principle

This is the load-bearing subtraction and everything else leans on it. lux has no references, no pointers, no inout, no way to say "and I mean that one." Assignment copies. Two names never denote the same value.

Two rules enforce it. A parameter arrives as a let, so a function cannot assign to what it was handed:

error: cannot change `w.doorOpen` — `w` is a parameter,
       and a parameter never changes
note: a parameter can't be a var; copy it into a local var
      first — `var copy = w` — and change that

And the copy that error suggests is a genuine copy, all the way down through arrays and nested structs. So the escape hatch does not leak: you can mutate freely inside a function and still not touch the caller's value.

The consequence is that the only way to make a change outlive a function is to return it. That pushes every non-trivial lux program into the same shape — a state value threaded through a transition function — without the language ever mentioning functional programming, and it makes undo a spare variable and replay a fold over a command list. The crawl guide works through that at length for a beginner.

What is interesting for an experienced reader is where this sits relative to the targets. All three of them allow field assignment; what differs is how much each makes you say first. Go lets you, with no ceremony. Swift lets you if the binding is a var. Rust lets you if you wrote mut, and then spends enormous effort proving nobody else is looking at the value while you change it.

Read that as a progression, because it is one. Go trusts you, Swift asks you to mark intent, Rust asks you to prove nobody gets hurt. lux sits where Swift does and adds the copying rule, which removes the entire category of problem the borrow checker exists to police. If two names can never share a value, nobody can be looking at the one you are changing.

The cost is real and I will not pretend otherwise. Deep copies everywhere are wasteful, and a language that took this seriously at scale would need persistent data structures with structural sharing. lux does not have them and does not need them, because a program that would notice is a program that has outgrown lux. That is a defensible place to stop, but it is stopping, not solving.

What it buys: aliasing bugs are not rare in lux, they are unrepresentable. What it costs: copying, and a ceiling on program size that the language is content to have.

No null

Principle

Nothing surprising here for anyone who has used Swift, Rust, or a modern functional language — a value that might be missing is Option<T>, one that might fail is Result<T, E>, and the only way to get at the payload is to match, which means writing both arms.

Two details are worth the space. First, the whole outside world is modelled in these two shapes rather than in exceptions: readFile, writeFile, and run all return a Result, and readLine returns an Option so that end-of-input is a value rather than a condition. There is no try, no panic to catch, and no way to write a program that ignores a failed read by accident.

Second, Option and Result are the only generics in the language. There is no way to write your own:

func first<T>(xs: [T]) -> T { return xs[0] }

error: expected '(' to start the parameter list

That is deliberate. Generics are one of the genuinely hard ideas, and a first language that has them acquires variance, bounds, and inference errors that nobody can read. Having exactly two generic types, both built in and both teaching the same lesson about absence, gets the pedagogy without the machinery.

What it buys: the two situations that sink beginners become things you handle rather than traps you fall into. What it costs: no user-defined containers — a real ceiling, and an intentional one.

A string has one level

Principle

Ask how long "é" is and there are three defensible answers, depending on what you think a string is made of: bytes, Unicode scalars, or what a person sees on screen. Every serious language lets you ask for more than one of them. lux picks one and gives you no way to see the others.

Write the letter both ways — e followed by a combining accent, and the single precomposed character — then ask each language how long they are and whether they are the same string:

e + accentéequal?
lux length21no
Rust .len()32no
Go len()32no
Swift .count11yes

lux sits in the middle — above bytes, below what a person sees. And the two halves of that table have completely different explanations, and that difference is the interesting part.

The counting is lux's own decision, and every backend pays for it. Not one of the three counts scalars natively, so all three get an explicit conversion:

(s).chars().count() as i64      // Rust — .len() would be bytes
len([]rune(s))                  // Go   — len() would be bytes
s.unicodeScalars.count          // Swift — .count would be graphemes

The comparison is Swift being the odd one out. Rust and Go compare strings by their underlying bytes, and since UTF-8 encodes scalar sequences one-to-one, byte equality answers the scalar question for free — both emit a bare ==. Swift compares by canonical equivalence, so it is the only target where the generated code has to spell the comparison out:

a == b                                            // Rust and Go
a.unicodeScalars.elementsEqual(b.unicodeScalars)  // Swift

The second fact follows from the first. Because lux chose the middle level, the byte-level targets get equality free and the grapheme-level one does not. Choose graphemes instead and it inverts: Swift comes free and the other two need real work, because grapheme segmentation is in neither standard library — Rust would need a crate, and lux has no dependencies. Scalars is plausibly the only level lux could have picked that is both well defined and reachable everywhere on its own.

The cost is that scalars are not what a person means by a character. A family emoji comes out as 5. Swift's answer of 1 is the humanly correct one and lux cannot give it. That is the price of picking a level a learner can be told about in one sentence and every backend can actually implement.

To its credit the language says so out loud rather than hoping nobody notices. lux learn strings states the seam directly — that a family emoji measures more than one, that two spellings of the same-looking letter are two different strings, and that every language meets this once its text stops being plain ASCII. The Swift-shaped consequence is left for the reader to find: write == in your own Swift after lux and two strings lux called different will start comparing equal.

The decision has since had to hold up under load. 0.18 added the only three string functions lux has — contains, replace, and split — and each of them has to answer the same question length and == already answered, because a search that matches at a different level than the comparison would be its own quiet trap. They match at scalars too, and the bill lands where the table says it should. Rust and Go get theirs nearly free, since a byte-level search over UTF-8 finds scalar sequences correctly. Swift's String.contains is grapheme-aware and would have given a different answer, so the Swift backend does not call it. It emits a hand-rolled scan over Array(s.unicodeScalars) instead — the same shape of tax the comparison already pays, for the same reason.

The rest of the string story is subtraction rather than choice. You cannot index into a string, and there is no lowercasing, so two spellings of a word stay two words until you write the comparison that says otherwise.

What it buys: one answer to "how long is this" that holds on four runtimes, and a comparison that means the same thing everywhere. What it costs: the answer is sometimes not the one a human would give, and there is no second view to fall back on.

No functions as values

Gap, with a defensible reason

lux has no closures, no function literals, and no way to pass a function to another function. A name that refers to a function is simply not a value:

func g(x: int) -> int { return x }
let h = g

error: `g` is not defined

Which means no higher-order functions at all, and so no map, no filter, no reduce. Every traversal in a lux program is a written-out loop, and a fold is a var and a for:

var total = 0
for x in xs {
    total = total + x
}

An experienced developer will find this the most annoying thing on the page, and it is the one I am least sure about. The argument for it is that a beginner who writes that loop understands what reduce does when they meet it, whereas a beginner handed reduce first has a spell rather than a concept. The argument against it is that a callback is not actually hard, and every language they graduate to has them within the first week.

I am calling it a gap rather than a principle because nothing in the language design documents defends it, and because the real reason is closer to "the interpreter does not have first-class functions yet" than to a considered position on pedagogy. If it were a principle, the error message would say so — the way lux's better diagnostics do — instead of reporting an undefined name.

What it buys: one fewer concept, and loops that a learner can trace by hand. What it costs: a whole style of programming, and a slightly embarrassing error message.

No break, no continue

Gap

Neither keyword exists. A loop runs to its natural end, or you return out of the enclosing function.

while true {
    i = i + 1
    if i > 2 { break }
}

error: `break` is not defined
note: declare it with let or var before using it
help: `lux learn scope` — a name lives only inside the { } where it's made

There is a real argument available for this — that early exits are where beginners write their first genuinely confusing control flow, and that a search loop is clearer as a function with a return in it. The has helper in the crawl does exactly that, and it reads well.

But I am not going to dress this one up. The learning material does not mention break anywhere, in any of the four teaching surfaces, and that is what an omission looks like rather than a decision. And the error message treats it as an undefined variable — it suggests declaring break with let — which means the parser has no idea it is a keyword in every other language the learner will ever touch. A deliberate refusal would say lux has no break; return out of a function instead, and point at a card. That is the shape of every other considered refusal in the language, and it is the tell that this one is not.

The contrast that settles it is main, and its history is the better half of the story. lux has no entry point — a program runs from its first line — so the first thing somebody arriving from C, Java, Go, or Rust reaches for was also missing. Version 0.16.0 refused it, and refused it properly: a rule named, a reason given, a redirect offered. Set that beside being told to declare break with let and you can see the difference between a decision and a gap without knowing anything else about the language.

Then 0.17.0 reversed it. func main is now accepted and taught — it is the entry point, lux calls it for you, and Rust and Go map it straight onto their own fn main and func main with no wrapper. Swift, whose top level is already the entry point exactly as lux's is, gets a bare main() call to start it, because it is the one target that never needed the ceremony either. The refusal became four rules instead: main takes no values, returns nothing, shares the top level with nothing but definitions, and is not called by hand.

error: `main` takes no values — it is only where your program starts
error: `main` returns nothing — it is only where your program starts
error: nothing runs beside `main` at the top level — it is where
       your program starts

Four errors that are one idea, each stating it. That is worth watching for what it says about the sorting on this page. main spent one release looking like a principle, and on reflection turned out to be a feature that had simply been left until last — deliberately, so that it lands as a bridge to the languages that require it rather than as ceremony a beginner has to copy on faith. The three buckets are not a taxonomy somebody wrote down once; things move between them. Which is the argument for stating plainly which bucket each absence is in, so that a move is visible when it happens.

What it buys: nothing much. What it costs: a stumble, at the exact moment a learner is copying an idea in from somewhere else.

No randomness

Principle

There is no random number generator, and this one is settled: it was requested, considered, and closed as not planned.

The reason is that lux's teaching rests on programs being reproducible. A recorded list of commands replayed against a starting state produces the same result every time. That is what makes a walkthrough file a regression test, what makes lux trace readable, and what makes the parity corpora above possible at all — you cannot diff four runtimes byte for byte if any of them can roll a die. Randomness would cost the property that everything else is built on, in exchange for making games marginally easier to write.

Worth noting for anyone who assumes this is a portability problem: it is not. Go and Swift both ship a generator in their standard libraries. Rust does not — rand is a crate — so a lux with randomness would either take its first dependency or emit a hand-rolled PRNG on one backend and the platform one on the others, at which point the four runtimes stop agreeing. The principle and the implementation point the same direction here, and that is usually a sign the principle is right.

What it buys: every lux program is a deterministic function of its input, which is what makes the whole verification story work. What it costs: dice.

The ones held back on purpose

Withheld

This is the part of the design I find most unusual, and it is the reason the middle bucket exists at all.

lux has no map or dictionary type, so a lookup is a match with an arm per case. It has no module system — no import, no way to spread a program across files, no way to use somebody else's code. And its strings stop short of the two things you reach for next: there is no lowercasing and no way to index into one.

if answer == lower("Paris") { … }

error: unknown function `lower`
note: define it with `func`, or use a built-in: print, eprint,
      string, int, float, length, contains, replace, split, input,
      readLine, readFile, writeFile, args, run, parseInt, parseFloat

All of them are understood, all of them are wanted, and all of them are being held back deliberately, in a known order, to be pulled out of the language by learners hitting walls. The map grows an arm per room so that at about thirty rooms somebody wants a lookup table. The one-file rule bites hardest at the exact moment two siblings each build a wing and want to join them, which is the most valuable thing the whole exercise has to offer. And the missing lower is the first wall almost anybody meets, because the first program a beginner writes that asks a question is a quiz, and the first thing that happens to a quiz is somebody types paris.

The stance is that a feature you asked for after hitting a wall is worth more than the same feature handed to you before you knew you wanted it, and that the walls are therefore the curriculum. The game is the language's product manager.

I think this is genuinely good design and I also think it is the riskiest thing here. It depends on learners hitting the walls in roughly the intended order and having somewhere to put the wish. Get the order wrong, or make the wall too high, and it stops reading as a well-designed constraint and starts reading as a language that cannot do things. The difference between those two is entirely in whether the error message tells you what to want next, which is why lux's diagnostics carry help: trails rather than terse pointers.

The first one to come out

Until recently that was all theory, because nothing had ever been released. It has now happened once, and the shape of it is worth recording while the details are still fresh.

The wall was string splitting, and it was the one this section used to lead with. The keep matches commands whole — take key works, get key does not — specifically so that a kid building a world runs face-first into wanting to break a line into words. That is exactly what happened, and 0.18 added split, along with contains and replace for company.

Two things about how it came out matter more than the fact that it did. The first is that the reason for releasing it was the same as the reason for withholding it: split turns a typed line into an array of unpredictable length — the first time a for loop has anything to do that a person could not have done by hand, and the first time an Option is protecting you from something real rather than from an exercise. Handed out on day one it is a utility. Handed out after the wall, it is what makes three earlier lessons pay.

The second is where it landed. None of the three went onto the guided path. They sit on the deeper pages behind lux learn strings more and lux learn arrays more. That is the same principle applied one level down — the release answers the person who went looking, and stays out of the way of the person who has not hit the wall yet. A language that hands you the answer to a question you have not asked has spent the wall for nothing.

What it buys: a roadmap written by the people learning, and constraints that teach. What it costs: it only works if the walls are the right height, and there is no way to know that except by watching somebody hit them.

Where it is rough

Three things I would want to know before recommending it to anyone — one of which is here because of how it stopped being a problem.

The one that used to head this list is fixed, and how it went is the useful part. Until 0.19, checking happened as the program ran. Add a case to an enum and every incomplete match became a latent error that said nothing until it executed — and one inside a function nobody called was never checked at all. A learner adding a room found one missing arm, fixed it, ran again, found the next somewhere else, and reasonably concluded the language was dribbling out the work on purpose.

What this page said at the time was that half the machinery already existed and was wired to the wrong door, and that pointing it at lux run and teaching it exhaustiveness would close the gap without inventing anything. That is what 0.19 did. The same program now stops before it prints anything, whether or not the function holding the match is ever called, and the note names every case that match is missing rather than the first one to bite. Matches still surface one at a time, so the fix-and-rerun loop remains; what is gone is the waiting:

$ lux run ex.lux
error: this match on `Room` doesn't handle every case
  --> ex.lux:8:12
 8 |     return match r {
                ^^^^^^^^^
note: add an arm for: tower (or a `_` catch-all)
help: `lux learn match` — covering every case is what makes match safe

The reason to leave this here rather than quietly delete it is that it is the measure of a young project: how long a known, named, well-understood gap stays open. This one was open for three minor versions and closed the way it was expected to. That is a better signal than a page with nothing rough on it.

The error messages are uneven. At their best they are the nicest thing about the language — a rule named, the cause pointed at, a fix suggested, and a trail into the built-in reference:

error: cannot change `pi` — `pi` was declared with let
 2 | pi = 3.0
     ^^^^^^^^
note: use `var` instead of `let` if it needs to change
help: `lux learn variables` — a let holds still on purpose —
      that's what keeps it safe

At their worst they are raw parser output. error: expected a value with a caret under the offending token is what you get for anything the parser has no grammar for — a map literal, a function literal, a class — and it names no rule, gives no reason, and offers no fix. The good ones and the bad ones sit in the same language, and which one you meet depends on whether anybody has walked that path with a learner yet.

Writing this page turned up a small instance of the same thing, and what happened to it says more about the project than the bug did. The lower error a few sections up offers a list of the built-ins, and that list had once drifted three names behind the real set — input, parseInt, and parseFloat all work, all are taught in the tutorial, and none appeared. A learner reaching for a way to read a number out of text saw a list with no parser on it. The note was a hand-typed string literal a few hundred lines from the match arms that are the truth, so only one of the two could go stale.

It was fixed inside a day, in 0.15.1, and the fix went past the report: the note now renders from a single list so it cannot drift again, and a near miss — parseint, readline, a misspelled function of your own — is redirected to the name you meant, with the full list kept for a name that is genuinely absent. That is the pattern to watch for if you are sizing the project up. The rough edges are real, and they are in the paths nobody has walked yet rather than in the design.

It is early. The teaching surface is fully built and the transpilers are live, but this is a young project with one author, and the version number says so. The parity story is the strongest thing about it, and it is still gaining pieces release by release rather than sitting finished.

Who it is for

lux is for a person who has never programmed, ideally one who has somebody nearby who has. It is not a scripting language, it is not going to grow into one, and it would be a bad choice for anything you intend to keep.

It also teaches from a point of view, and the page above is mostly that view stated as absences. Said as a commitment instead: no null, sum types with exhaustive match, and immutability by default — not as the only way to write programs, but as the habits the strongest current languages are converging on, and ones easier to learn first than to bolt on later. That stand is the reason lux exists. A language with no view on how programs should be written would have nothing to teach.

What it does that I have not seen elsewhere is treat the exit as the proof. Most teaching languages are trying to keep you; the good ones admit you will leave. lux puts more code into translating your program out than into running it, and holds each translation to matching the interpreter — so a learner can check, rather than take on faith, that what they learned belongs to programming and not to lux.

If you are evaluating it for a kid: the interesting question is not the feature list, it is whether the withheld features in the middle section are held at the right heights. That is the design bet the whole thing rests on, and the only way to find out is to watch somebody hit one.

The source is at github.com/anderix/lux — about fifteen thousand lines of Rust with no dependencies, and the tutorial, the test corpus, and the reference all live in the same tree. Everything on this page was checked against lux 0.19.10 by running it, the parity walk included, on all four runtimes.