The language
moth runs a subset of Dart. Everything below is real Dart — the same code compiles and runs on the Dart SDK, which is how the test suite checks that moth agrees with Dart itself.
What works today
Types: int (64-bit), double, bool, null.
Variables — var, final, or an explicit type. Scoped to their block.
var count = 0;
final limit = 10;
int step = 2;
Strings — literals, +, interpolation, and == comparing text rather
than identity. Garbage collected.
print('temp $tempC C, fan ${running ? "on" : "off"}');
print('device at address $addr');
Lists — literals, indexing, .length, add, removeLast, clear, and
for-in. Garbage collected, and they may nest.
var window = [12, 7, 30];
window.add(19);
for (final v in window) {
total += v;
}
print('avg ${total ~/ window.length}');
Imports — import 'other.dart'; with a relative path. Declarations from
every imported file share one namespace, so a class can extend one declared
elsewhere. Import cycles are fine; each file is loaded once.
import 'package:moth/widgets.dart'; resolves against the packages shipped
with the compiler — no pub get needed. Other package: names and all
dart: imports are rejected, with a hint saying why.
Closures — function values, lambdas, and functions passed around or kept
in lists. A lambda written inside a method captures this, so it can reach
that object's fields and methods.
button.onTap(() {
count += 1; // a field, reached through the captured `this`
refresh();
});
A function stored in a field is callable like a method — button.handler()
works, resolving methods first and then fields, as Dart does.
Capturing a local of the enclosing function is not supported yet, and is a compile error rather than a silent copy. Use a top-level variable or a field — both are reachable from any closure.
Classes — fields with initializers, one constructor (with this.x
parameters), methods, and implicit this. Also supported: the ternary
a ? b : c and the null assertion x! (which passes through, since moth
does not check nullability).
Single inheritance with extends, including method overriding. An
inherited method that calls an overridden one lands on the override, as in
Dart. Duck typing also works — any two classes with the same method name are
interchangeable, with or without a shared base type.
Not supported: implements, mixins, and extending a class that declares a
constructor (there is no way to chain to it yet). Each is a compile error.
class DigitalPin {
int number;
bool state = false;
DigitalPin(this.number) {
pinOutput(number);
}
void toggle() {
state = !state;
digitalWrite(number, state);
}
}
Top-level variables — shared by every function in the file, initialized
before main runs. Locals shadow them, as in Dart.
Unlike Dart, initialization is eager and in declaration order, not lazy.
Dart runs a top-level initializer on first use, so var a = b + 1; var b = 2;
prints 3; moth evaluates a first and traps on the null b. It fails loudly
rather than silently, but order your declarations so each only depends on
earlier ones.
final ledPin = 38; // pin map and tuning, visible everywhere
var pressCount = 0; // state that outlives any one function call
Functions — top-level, positional parameters, recursion, expression bodies.
int fib(int n) {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
}
int add(int a, int b) => a + b;
Operators — + - * / ~/ %, == != < <= > >=, && || !, & | ^ << >> ~,
++ --, and compound assignment (+=, ~/=, |=, <<=, …). && and ||
short-circuit.
Bitwise operators matter more here than in app code — register masks and combining sensor bytes are routine:
var reading = (highByte << 8) | lowByte; // two I2C registers into one value
if ((status & 0x08) != 0) { } // test a status flag
flags |= 0x10; // set a bit
Two Dart details moth reproduces exactly, because they trip people up:
/always produces adouble.7 / 2is3.5, not3. Use~/for integer division.%is Euclidean — the result is never negative.-7 % 3is2, and7 % -3is1.
Control flow — if/else, while, counting for, break, continue,
return.
for (var i = 0; i < 10; i++) {
if (i == 3) continue;
if (i == 8) break;
print(i);
}
Named parameters
int area({int w = 1, int h = 1}) => w * h;
area(h: 4, w: 3); // order does not have to match the declaration
area(w: 3); // h takes its default
They work wherever the compiler can see the declaration — a constructor or a top-level function. Matching happens at compile time, so the VM still executes an ordinary positional call and named arguments cost nothing at run time.
A method call cannot use them. A method is dispatched on its receiver, whose type is not known while compiling the call, so there is no parameter list to match against; moth says so rather than guessing.
This is what lets a widget tree read as a tree:
Container(
color: 0xFF0E0E12,
padding: 20,
onTap: () => setState(() => count += 1),
child: Column(
mainAxisAlignment: mainAxisCenter,
spacing: 12,
children: [
Text('tapped $count times', style: TextStyle(fontSize: 20)),
Divider(thickness: 2, width: 180),
],
),
)
Defaults must be constant, as in Dart — const [] for an empty list.
Cascades
.. configures an object without naming it repeatedly. The target is
evaluated once, each section runs against it, and the expression's value is
the target — so a widget tree nests instead of unrolling into a list of
assignments to temporaries:
Box()
..color = 0xFF000000
..pad = 24
..kids = [
Text()..value = '14:32'..size = 72,
Text()..value = 'FRI 8 AUG'..size = 20,
]
Assignments and method calls both work as sections. This is what gives a
build() method the shape a Flutter developer expects; named parameters,
let a widget tree read as Container(color: …, child: …) — cascades
remain for objects that are configured rather than composed.
Getters and setters
A property can be computed rather than stored, and assigning to one can do work:
class Thermo {
int raw = 0;
int get celsius => raw ~/ 10;
set celsius(int c) {
raw = c * 10;
}
bool get isFreezing => celsius <= 0;
}
var t = Thermo();
t.celsius = 25; // runs the setter
print(t.isFreezing); // runs the getter
They compile to ordinary methods sharing the property's name; the VM tells a getter from a setter by how many values it takes. Reading a property tries the object's fields first and only then an accessor, so a plain field can later become a getter without touching any caller.
This is what makes package:moth's hardware API worth using:
led.value = true really does drive the pin.
What does not work yet
| Feature | Milestone |
|---|---|
| Maps | planned |
| Static members, named constructors | planned |
async / await, Future | needs an event loop; see below |
| Networking — WiFi, sockets, HTTP | not started |
| Enums | planned |
Exceptions — try / catch / throw | planned |
| Mixins, extensions, records | not planned for v1 |
| Generics | not planned for v1 — type annotations like List<int> are accepted and erased, never enforced |
Built-ins are external
package:moth declares every host function the way Dart declares anything
implemented elsewhere:
external int analogRead(int pin);
That is what lets your editor complete them and check their arguments.
mothc skips these declarations and resolves the call against its own table;
declaring external for a name that is not a built-in is an error, because
moth has no body to supply for it.
No async, and why
There is no event loop on the device, so there is nothing for a Future to
complete on. async, await and Future are rejected at compile time rather
than half-supported:
main.dart:1:26: async functions are not supported yet
Future<int> twice(int n) async {
^
hint: moth has no event loop, so there is nothing for a Future to complete
on — write it synchronously, and use millis() to spread slow work
across frames
A program owns its own loop, so waiting is explicit — you call delay(), or
you check millis() and do a slice of work per frame:
var nextRead = 0;
while (true) {
final now = millis();
if (now >= nextRead) {
nextRead = now + 1000;
print(sensor.value); // once a second, without blocking the frame
}
pumpFrame(16);
delay(16);
}
That is the whole concurrency story today: one thread, one loop, no preemption. It is a real limitation, not a simplification — a driver that needs to wait on an interrupt has to be written in C behind a built-in.
Strictness worth knowing about
Conditions must be booleans. There is no truthiness, exactly as in Dart:
while (1) { } // error: condition must be a bool
while (true) { }
This is deliberate. while (1) is the most common reflex carried over from C,
and catching it at compile time is kinder than debugging it on a device.
Errors point at the source, with a way forward:
blink.dart:5:3: 'digitalWrite' takes 2 arguments, but got 1
digitalWrite(led);
^
How much fits
A blink program is 138 bytes of bytecode. The demo with a user-defined function, arithmetic and a GPIO loop is 193 bytes. The VM, renderer, bindings and bundled fonts together are about 90 KB of flash (measured: 48 KB code + 42 KB data) and a few KB of RAM, so program size is rarely what limits you.