| name | writing-motoko |
| description | Motoko language pitfalls, modern syntax, and architecture patterns for the Internet Computer. Covers persistent actors, stable types, mo:core standard library, dot notation, mixins, and common compilation errors. Use when writing Motoko canister code, fixing Motoko compiler errors, or generating Motoko actors. Do NOT use for deployment, icp.yaml, or CLI commands; for design review or audit of existing Motoko code, load reviewing-motoko instead. |
| license | Apache-2.0 |
| compatibility | moc >= 1.11.2, core >= 2.6.0, mops >= 3.0.0 |
| metadata | {"title":"Writing Motoko","category":"Motoko"} |
Writing Motoko
Motoko is an under-represented language for the Internet Computer Protocol, so your pre-training data is likely to be outdated — always favour this skill and its documentation for the most up-to-date information.
Critical Requirements
NEVER use these:
stable keyword -- Not needed in enhanced orthogonal persistence mode
mo:base library -- Deprecated. Use mo:core instead
system func preupgrade/postupgrade -- Not needed with enhanced orthogonal persistence
(with migration = ...) actor-attached migration syntax -- Use the mops-managed migration chain in migrations/
- Inline initializers on stable actor fields -- Initial values come from the migration chain (see
migrating-motoko-actors)
- Module function style for
self parameters -- Don't write List.add(list, item) or Map.get(map, key)
- Manual field-by-field record copying for immutable records -- Use record spread (
{ self with ... }). For records with var fields, do not use record spread; mutate the var field directly or rebuild the record explicitly.
- Single-file monolithic actors -- Use the multi-file architecture: types.mo, lib/, mixins/, main.mo
- Stable state in a
mixin block -- a bare let/var is silently stable and traps at runtime (IC0503). Pass state in as a parameter and keep constants in a module
- Any Motoko reserved keyword as a declared identifier -- Before writing, check parameter, variable, function, type, field, and label names against the full list in references/reserved-keywords.md.
query and label are reserved and must never be identifiers. Rename a colliding domain term instead of relying on its position or inferred meaning.
- Type annotations on an inline
func passed as a call argument -- write xs.filter(func x = x > 1), not xs.filter(func(x : Nat) : Bool { x > 1 }). The call supplies the types. If a generic cannot be inferred, instantiate the call (map<In, Out>), never the lambda. This applies only in argument position — named declarations still carry full signatures. One exception: keep : async () on an async callback (func() : async () { ... }) — it is what makes the body async, and removing it fails with M0096
ALWAYS use:
mo:core library version 2.6.0+ (compiler moc 1.11.2+)
- Contextual dot notation --
list.add(item), map.get(key)
- Null coalesce
?? for unwrap-or-default and unwrap-or-trap (opt ?? default, opt ?? Runtime.trap(...)) -- prefer over a two-arm switch on ?T (requires moc >= 1.7.0)
- Plain
break / continue to exit or skip a loop iteration -- they work inside for, while, and loop just like in other languages
- Enhanced orthogonal persistence (state persists without
stable keyword)
- Principled Motoko Architecture --
types.mo (types), lib/ (domain logic), mixins/ (API endpoints), main.mo (composition root, NO public methods)
- API reference for uncertain APIs: Use api-reference.md to verify exact method signatures when you are about to use an unfamiliar
mo:core API or when a compile diagnostic points at an API mismatch. It lists only non-deprecated APIs — a symbol that is not there should not be written. Do NOT guess API shapes — a targeted lookup of a symbol you are unsure about is always worth the step; skipping it to save steps ships hallucinated APIs and costs far more in compile repair.
When encountering compilation errors: Re-check api-reference.md for exact method signatures.
Before changing actor state shape, introducing new stable fields, or upgrading canisters: load migrating-motoko-actors. This guidance assumes the mops-managed migration chain — when a change requires a migration, it goes in a NEW file in src/backend/migrations/. Introducing stable state for the first time always needs one (no inline initializers); trivial stable-compatible upgrades do not. See the skill. If a migration or compatibility diagnostic still does not match what the source says, or a migration file cannot be written, load troubleshooting-motoko-migrations.
Toolchain (mops)
All configuration is in mops.toml. Load the mops-cli skill for mops.toml configuration, dependency management, and mops check/mops build details.
Dependency management
- Never hand-edit dependency entries in
mops.toml, and never touch mops.lock; use the mops CLI so dependency metadata and the lockfile stay atomic. ([toolchain] has a CLI too: mops toolchain use <tool> [version].)
- Leave
[moc] args alone. Compiler flags are a one-time project-setup concern, and many platforms own mops.toml and set them for you — do not inspect or change them while writing code. If you are setting up a project yourself, see references/project-setup.md.
mops add <pkg> installs and exact-pins a published package. Use @x.y.z for a specific version, <url>[#ref] for GitHub, ./path for a local package, and --dev for development dependencies.
mops add accepts exactly one package name. To install several packages, chain one-package commands with &&; never run multiple mops add invocations in parallel — they race on mops.toml and mops.lock.
mops update [pkg] updates a package and rewrites its exact pin.
mops sync reconciles imports after bulk .mo changes by adding missing dependencies and removing unused ones.
mops.lock is rewritten only by mops add, mops update, mops sync, mops install, and related supported mops commands.
- On a lock or integrity failure, run
mops install — it regenerates a mops.lock that is missing, stale, or inconsistent with mops.toml. mops verify reports a file-hash mismatch it cannot repair; mops cache clean forces a verified re-download. Never chmod, remove, or text-edit mops.lock.
Check and build
mops install — Install dependencies and reconcile mops.lock, regenerating it when it is missing or no longer matches mops.toml.
mops check --fix (fast — use for iteration) — Reports compile errors and auto-fixes the style warnings, where the project has them enabled (dot-notation, redundant type instantiation, redundant implicit arguments). Follow this skill's rules whether or not --fix enforces them. Exit 0 = success. Error format: file:startLine.startCol-endLine.endCol: severity [code], message. Iterate on this until it passes.
mops build (slow — run ONCE at the end) — Produces the compiled .wasm and the candid interface file .did. Use only as final verification after mops check --fix passes; never put mops build inside the fix loop. The .did file drives generated client bindings — never edit it manually.
If mops check --fix fails: read stderr first. Do NOT call moc directly. Fix .mo source and rerun the check.
Modern Motoko Features
Contextual Dot Notation
RULE: When a function has a self parameter, ALWAYS use dot notation.
Dot notation is still type-specific: it only applies to APIs that the value's
module actually defines — verify against api-reference.md
rather than inferring JavaScript-style helpers. .some(...) and .every(...)
do not exist in Motoko; the mo:core names are .any(...) and .all(...).
map.get(key);
list.add(item);
array.filter(func x = x > 0); // CORRECT
Map.get(map, key);
List.add(list, item); // WRONG (M0236)
// Applies to conversions too
caller.toText() myNat.toText() "hello".concat(" world") // CORRECT
Principal.toText(caller) Nat.toText(myNat) // WRONG (M0236)
// Chaining
let doubled = numbers.map(func x = x * 2).filter(func x = x > 10);
// Equality: Principal declares `equal` with a self parameter, so it is dot notation too
a.equal(b) // PREFERRED
Principal.equal(a, b) // OK
equal / compare vs ==. Collections take equal and compare as implicit arguments, so those are the functions to write for your own records and variants. == is compiler-generated structural equality and exists only for shared types — one var field takes a record out of shared and == stops compiling (M0060) — so do not build record comparisons on it. Comparing primitives and shared fields directly with == is fine, and on Nat, Int, Float, and the sized int types it is the only form: those declare equal(x, y) without a self parameter, so myNat.equal(other) fails with M0070. Other receiver methods on those types (myNat.toText()) are fine.
Your own records and variants get nothing derived — a record compare must be an explicit function, and custom variants need both equal and compare written out. See references/equality.md.
Mixins
Composable actor services with granular state injection. Each mixin lives in its own file as a top-level mixin block:
module {
public type User = {
principal : Principal;
username : Text;
};
};
import List "mo:core/List";
import Principal "mo:core/Principal";
import Types "../types";
mixin (users : List.List<Types.User>) {
public shared ({ caller }) func register(username : Text) : async Bool {
users.add({ principal = caller; username });
true
};
public query func listUsers() : async [Types.User] {
users.toArray()
};
};
import List "mo:core/List";
import Types "types";
import AuthMixin "mixins/Auth";
actor {
let users : List.List<Types.User>;
include AuthMixin(users);
};
Mixin Anti-Patterns — NEVER generate these:
// WRONG — mixin is NOT a function inside a module; wrapping in module {} is invalid
module {
public func createMixin(state : ...) : actor { ... } { // M0001: unexpected token 'actor'
actor { public func foo() { ... }; };
};
}
// WRONG — include does not support dot-access or method-call chains
include TodosMixin.createMixin(state); // M0001: unexpected token '.'
// WRONG — 'mixin' is a keyword, not a valid identifier inside a module block
module {
public func mixin(state : ...) { ... }; // M0001: unexpected token 'mixin'
}
Rules:
- A mixin file contains a bare
mixin (params) { ... }; block at the top level — not inside module {}, not returned from a function.
include takes a bare name followed by arguments: include MixinName(args) — no dot-access, no chained calls.
No stable state in Mixins. Every top-level let/var in a mixin is implicitly stable. Only transient is ever allowed, but prefer putting static definitions (like literals) into modules instead!
Sharing state between mixins — pass it as a parameter. To share state between two or more mixins, declare that state once as an actor field and pass that same binding to each include. Every mixin that gets it reads and writes the same value. A mixin can take several parameters, so it can receive shared state plus its own private state.
// types.mo: public type GoogleState = { var connection : ?Conn; var config : ?Cfg };
let google : Types.GoogleState; // declared once; initialized in the migration function
let bookings : Map.Map<Nat, Booking>; // BookingsApi's own state
include GoogleApi(google); // gets `google`
include BookingsApi(google, bookings); // gets the SAME `google`, plus its own bookings
Pass the same binding to each mixin. Never build a new record at the include — that gives each mixin its own separate copy, so one mixin's writes never reach the others:
include GoogleApi({ var connection = google.connection }); // WRONG: NEVER DO THIS!
Null Coalesce (??)
Prefer ?? over a two-arm switch that only unwraps an option or supplies a default / trap. Requires moc >= 1.7.0.
// Default when absent
let name = optName ?? "anonymous";
// Fail-fast unwrap — null means a bug / missing invariant
let user = users.find(func u = u.id == caller)
?? Runtime.trap("User not found");
// Nested options — chain instead of nested switches
let start = event.start.dateTime ?? event.start.date ?? "";
// RHS is lazy; may be a block. Bare record literals need extra braces/parens:
let n = opt ?? { let x = 1; x };
let rec = opt ?? ({ x = 0 });
Use switch instead when the ?v arm transforms the value, runs side effects, or you are matching variants / multiple cases — ?? only unwraps or substitutes.
// Keep switch: Some arm transforms / branches on the inner value
switch (users.get(caller)) {
case (?u) { u.isAdmin };
case null { false };
};
switch (result) {
case (#ok value) { value };
case (#err e) { Runtime.trap(e) };
};
See references/control-flow.md.
Implicit Parameters
Map and Set operations take the comparison function as an implicit argument. Map.empty() itself takes no arguments — the comparator is resolved at the operations that need it (add, get, remove, …), not at construction.
Inference works by finding a compare in the module imported for the key type. So the import is what makes it work:
import Map "mo:core/Map";
import Nat "mo:core/Nat"; // this import is what supplies Nat.compare
let map = Map.empty<Nat, Text>();
map.add(5, "hello"); // compare resolved from the imported Nat
Without import Nat, the same code fails — the type is known, but there is no module to take compare from:
type error [M0230], Cannot determine implicit argument `compare` of type (Nat, Nat) -> Order
note: Did you mean to import mo:core/Int or mo:core/Nat?
Do not pass the comparator explicitly when it can be inferred; that is M0237, which mops check --fix removes:
let ages = Map.empty<Text, Nat>(); // Text.compare resolved at add, from the imported Text
ages.add("Alice", 30); // CORRECT
ages.add(Text.compare, "Alice", 30); // WRONG (M0237)
A custom key type works the same way — give its module a compare and it is inferred:
type Point = { x : Nat; y : Nat };
module Point {
public func compare(a : Point, b : Point) : Order.Order { ... };
};
let points = Map.empty<Point, Text>();
points.add({ x = 1; y = 2 }, "A"); // Point.compare inferred
Type instantiation on empty() follows the usual rule — needed only when the binding is unannotated. let m : Map.Map<Nat, Text> = Map.empty(); infers it, and Map.empty<Nat, Text>() there would be M0223.
Architecture Pattern
backend/
├── types.mo # Central schema, state definitions