Lyric
A statically typed application language with a bytecode VM — standalone, and embeddable in a host with a capability-based sandbox.
What changed in v3.0.0 — 2026-08-23
The major. Everything the 2.x line had deferred, in one release: heterogeneous arithmetic,
throwability that survives being stored, overloading, and a compiler that turns hot functions into
machine code. The .lyrbc format is unchanged at 3.6 — a 3.0.0 module loads in a 2.12 runtime
and the other way round, as far as the language allows.
Migrating from 2.x, in the order the compiler will tell you:
- every
Add<T>/Sub<T>/Mul<T>/Div<T>gains a second type argument, the RESULT:Add<Vec2, Vec2>. The compiler names the count (LYR-SEM0026); - a coroutine whose body may throw is a different TYPE now —
Coroutine<int> throws Exception— and a field or optional holding one says so; - the eleven forms whose
@Deprecatedpromised removal at 3.0 are gone:readText,readBytes,readLinesand the eight free iterator adapters. Their replacements have been there since 2.14 and 2.17.
Nothing else in a 2.x program changes meaning.
Changed — breaking
The arithmetic interfaces take two type arguments.
Add<T>is nowAdd<T, R>, likewiseSub,MulandDiv: what stands on the right of the operator, and what the operation gives back. Every existing conformance and constraint gains a second argument, and the compiler names the count (LYR-SEM0026):struct Vec2 :: [Add<Vec2, Vec2>] { … } // was: [Add<Vec2>] fn total<T :: [Add<T, T>]>(a: T, b: T): T // was: <T :: [Add<T>]>The result could not be read off the operand, or
Vec2 * 2.0would have to give afloat.Every deprecation whose promise named 3.0 is gone. The
untilfield is a commitment, and this is the release it comes due in. Eleven forms:gone use std.io.file.readTexttextstd.io.file.readBytesbytesstd.io.file.readLineslinesstd.iter.map/filter/take/skip/takeWhile/zip/chain/flatMap(free)the methods of Iterator<T>The free iterator adapters have been the methods' delegates since 2.17;
enumerateandchunksstay free, and the comment instd/iter.lyrsays why (a non-generic method changing the element type does not monomorphize).The toolchain calls itself 3.0.0. The tree carries the version the language it speaks belongs to, so the specification suite runs the right cases against it.
Added
Overloading: several functions may share a name, told apart by their parameters.
fn describe(n: int): string { … } fn describe(s: string): string { … } fn describe(a: int, b: int): string { … }The second answer this language has to "one name, several types" — generics with constraints being the first — and admitted deliberately, which is why its rules are written out in the specification (§4.3a) rather than left to be discovered.
The arguments choose, and only the arguments. Not the result type: a call has to mean one thing before anyone looks at where it goes. Where several fit, the one that needs least wins, in this order: an exact type beats a literal that adapts; a concrete parameter beats a type parameter; no defaults beats defaults; non-variadic beats variadic; and a type's own method beats an extension that fits equally well — the rule that predates overloading, last so it decides nothing a parameter could decide. Nothing fitting is
LYR-SEM0087, a tie isLYR-SEM0086, and both name every candidate they weighed.Functions, methods and extension methods overload. Interface members do not (
LYR-SEM0088): a method table holds one function per slot and finds it by name. Two functions with the SAME parameters are a redeclaration however their results differ (LYR-SEM0085). A lambda argument takes no part in choosing, having no type until a parameter gives it one.Used as a VALUE rather than called, an overloaded name is picked by the type it is wanted as —
twice(describe, 7)takes thefn(int) -> stringone — and refused where nothing says which (LYR-SEM0089).In the compiled module the overloads carry their parameters in the name (
main.show(int)besidemain.show(string)), because function names are unique there. A name declared once is unchanged, so a program without overloads compiles to the bytes it always did. A host calling by name matches on the argument count and says so when that is not enough.A type may conform to one arithmetic interface several times, and the operator picks the conformance by the type of its right operand. This is the one exception to "one conformance per interface" in the language:
struct Vec2 :: [Mul<Vec2, Vec2>] { x: float, y: float, fn mul(other: Vec2): Vec2 { … } } extend Vec2 :: [Mul<float, Vec2>] { fn mul(other: float): Vec2 { … } // scaling, its own conformance } let scaled = v * 2.0; // Mul<float, Vec2> let square = v * v; // Mul<Vec2, Vec2>Both implementations are called
mul, so the NAME never decides — which is also the limit of it: a writtenv.mul(2.0)stays ambiguous, because a call has only the name to go on. Free overloading is a separate feature and comes in v3.1.0.What this touches, since a name no longer identifies a method: conformance checking asks whether ANY visible method matches the signature the conformance demands; vtable rows are built per interface INSTANCE rather than per interface, so
Mul<float, Vec2>andMul<Vec2, Vec2>get their own row and an interface value dispatches to the one it names; and a call under a constraint goes through that row, so the constraint decides which conformance a generic body means.An untyped integer literal adapts to the conformance's operand type: with
Mul<float, Vec2>in reach,v * 2multiplies by2.0, under the same literal rule as everywhere else. An exact conformance wins over an adapted one.LYR-SEM0083— two conformances taking the same operand and disagreeing on what to call. Reported where the operator is used, not at the declaration: a conformance another module'sextendblock adds first meets the others where both are visible.
Fixed
A generic interface member called on an instance of a generic class.
ArrayIterator<int>.zip<string>(…)did not compile: the receiver took the instance path, which binds the OWNER's type parameters and not the member's own, soIterator<(T, B)>reported "this type argument is not supported" — at the interface's declaration, which is not where the program was wrong. Such a call is lifted into the interface now, as the same call on anIterator<T>value always was. It surfaced when the free adapters went:zipas a method had never been reachable in a test.Scripts can run compiled.
HostOptions.Compile = trueand each function is compiled to IL the first time it is called; a loop overfloats stops being limited by the VM and starts being limited by the CPU.var vm = new LangVm(new HostOptions { Capabilities = Capability.None, Compile = true });Off by default, and opt-in is the whole design. Compiled code has no instruction boundaries: a debugger cannot stop inside it and a budget cannot count it. So the shape is develop on the interpreter — breakpoints, stepping and hot reload all work there — and ship with this on.
It is not a decision per call. A call carrying an
ExecutionBudget, and any call under a debugger, stays interpreted even with the option set, so a host may turn it on for a whole VM and still meter the foreign code inside it. Compilation is per function and refusal is normal: what the compiler does not understand the interpreter keeps, which costs speed and never correctness.ScriptInstance.CompiledFunctionsandScriptInstance.Refusalssay what happened, the second as short phrases meant to be tallied rather than read.Compiled today: arithmetic, comparisons, branches, locals, globals, arrays, fields, optionals, interface values, object construction, string constants and comparison, and calls — to a native, or to another function that compiles. Declined: closures, exceptions, enums, recursion, and the narrow integer widths.
Ahead-of-time publishing and this are alternatives, not a pair: emitting IL at run time needs a runtime that can, and a NativeAOT build cannot. There every function is declined with
no runtime code generationand every script is interpreted — nothing else changes.A coroutine's throwability is part of its TYPE (#73).
fn gen(): Coroutine<int> throws Exceptionproduces aCoroutine<int> throws Exception, and that type keeps its demand through a field, an optional, a parameter and a return:class Runner { current: ?Coroutine<int> throws Exception = null, }The clause was checked at the call until 3.0 — the one event that runs no body and therefore cannot throw. It looked right while the coroutine stayed a local beside its
try, and the demand vanished the moment it reached a field or an optional; the exception then left the entry point and ended the program withLYR-VM0010. A coroutine held in a field is the idiom coroutines were built for.What changed for a program that compiled before:
- the call of a coroutine function demands nothing;
- every pull —
resumeandnext()alike — of a throwing coroutine is a throw site (LYR-SEM0034).next()is lenient about exhaustion, never about throwing; - a throwing coroutine no longer fits a plain
Coroutine<T>slot. The other direction is fine: a coroutine that cannot throw keeps the promise athrowsslot makes; - the type is written with a suffix wherever a type is written —
?Coroutine<int> throws Exception— andthrowsalone means anyThrowable. On anything but a coroutine it is refused (LYR-SEM0084): every other value runs at its call, where the callee's own clause already says what it throws.
Purely static — the IR, the bytecode and the runtime are untouched.