Reviewing Motoko
This skill judges whether Motoko code is well designed. It does not teach the language — load writing-motoko for syntax, mo:core APIs, and mechanical rules, and migrating-motoko-actors before changing anything that is stable state.
Four questions decide a review, in this order — the first three because each can invalidate the next, the fourth last because it is the least consequential:
- Is every invariant the code relies on encoded in a type? Where the type system can rule out a bad state, leaving that to a runtime check or a comment is a defect, not a style preference.
- Is state declared with the right persistence? Under enhanced migration, stable and transient are separate worlds. Blurring them produces state that silently stops updating.
- Does the code live in the right files?
types.mo/lib//mixins//main.mois a contract about what may depend on what, not a folder convention. - Does it read like Motoko? Contextual dot notation, inferred implicits, no annotation the call already supplies, no classes.
mops check --fixhandles most of this; only what it cannot see is the reviewer’s job.
Procedure
Run the mechanical pass first — do not spend review attention on what the compiler already reports.
mops check --fix
Its style auto-fixes (M0236 dot notation, M0237 redundant implicits, M0223 redundant instantiation) only fire where [moc] args enables -W for them; writing-motoko’s references/project-setup.md has the setup. Where they are off, the Conventions findings below are all yours to make by hand.
Then read in this order — outside in, because a misplaced file makes every type inside it suspect:
mops.toml— is there a[canisters.*.migrations]chain? If not, finding B1 below outranks everything else.- Structure — the file layout, then each file’s imports.
types.mo— every type declaration. This is where most findings are.- State — the actor fields in
main.mo, and what eachincludepasses. See references/state-and-persistence.md. - The API boundary — every
public shared/public queryfunction: its parameter types, its return type, its authorization. - Bodies — last, and only for the smells under Maintainability below.
Severity
Report findings with one of three levels. Do not inflate: a reviewer who calls everything a blocker gets ignored.
| Level | Meaning |
|---|---|
| Blocker | Can corrupt or silently lose state, or lets a caller reach an unauthorized action. |
| Warning | An invariant the type system could enforce is left to runtime, a comment, or a convention. |
| Note | Readability, duplication, or naming. Fix while you are in the file; do not gate on it. |
State the concrete failure, not the rule. “status : Text accepts "complete", and the three call sites spell it "completed"” beats “prefer variants over strings”.
Type encoding
The rule: if a value has a fixed set of shapes, the type must have that set of shapes. Full recipes, including how to carry each change through a migration, are in references/type-encoding.md.
T1. Text for a closed set of values — Warning, Blocker if it gates authorization
The most common defect in generated Motoko. Every producer and consumer agrees on spelling by convention and the compiler checks nothing — no exhaustiveness, no error on a typo, no error when a new state is added and half the call sites are not updated.
public type Task = { id : Nat; var status : Text }; // WRONG
public type TaskStatus = { #open; #claimed : Principal; #done : { at : Time.Time } };
Variants are shared, so this reaches the frontend as a discriminated union too. Flag Text (or Nat, or Bool) whenever the code, a comment, or a doc lists the permitted values — == "..." comparisons, a switch on a Text with a case _, role names held as Text. Keep Text for genuinely open sets, and for strings an external system owns.
T2. Correlated fields — Warning
Two fields that are only valid together belong in one variant. A Bool plus an optional payload has four representable combinations and two meaningful ones.
// WRONG — { hasDeadline = true; deadline = null } compiles and means nothing
{ hasDeadline : Bool; deadline : ?Time.Time }
// RIGHT
{ deadline : { #none; #due : Time.Time } } // or simply `deadline : ?Time.Time`
If a ?T already says it, drop the Bool — do not introduce a variant that only restates ?T.
T3. Wrong cardinality — Note, Warning when callers loop defensively
Use the type that matches the real count. A [T] that is always empty-or-one makes every caller write a loop or an if size == 1; a ?T tells them there is nothing to iterate.
T4. Errors as Text — Warning
Result<Ok, Text> forces every caller to string-match to tell one failure from another. Make Err a variant with the data each case needs. See the Result section of writing-motoko for the full rule, including when to return ?T and when to trap instead.
T5. var fields leaking into the API — Blocker (it does not compile) or Warning (it compiles but over-shares)
A record with a var field is not shared, so it cannot be returned from a public function. Where a project already has the internal/view split (Task internal, TaskView shared), check that:
- no public signature mentions the internal type;
- the view is narrower than the internal record, not a mechanical field-for-field copy. A view that re-exports an internal
Principallist, an audit trail, or another user’s data is an information leak that compiles.
T6. case _ swallowing variants — Warning
A catch-all arm is what turns “adding a variant tag is a compile error everywhere it matters” into “adding a variant tag silently takes the default branch”. Keep case _ only where the arms are genuinely uniform and the set is expected to grow (e.g. mapping many tags to one label). Anywhere a new tag would need new behaviour, enumerate the tags.
T7. Transparent aliases sold as safety — Note
type UserId = Principal is a documentation alias: any Principal still type-checks where a UserId is expected. That is fine, and usually the right trade. Just do not treat it as an enforced distinction — and if two different id types are genuinely being confused at call sites, a one-field record ({ userId : Principal }) or a variant tag (#user(Principal)) is what actually enforces it, at the cost of changing the Candid shape.
State and persistence
Under enhanced migration (EM) the actor’s stable fields are declared type-only and every initial value comes from the migration chain. That separation is the point: it is what makes stable state impossible to initialize from something transient. Details in references/state-and-persistence.md.
B1. No enhanced migration — Blocker
No [canisters.<name>.migrations] chain in mops.toml means pre-EM persistence, where stable fields carry inline initializers — so a stable field can be initialized from a transient one:
transient let defaultQuota = 100;
let quota = defaultQuota; // stable; the initializer runs ONCE, on first install
Change defaultQuota to 250, deploy, and quota is still 100 — forever, no warning. Point at migrating-motoko-actors for the conversion; everything below assumes EM.
S1. Inline initializer on a stable field — Blocker
Under EM this is M0250 / M0014, so it will not compile — but it appears constantly in code copied from pre-EM examples and in half-finished refactors. The fix is never to add transient: move the initial value into the pending migration’s NewActor.
S2. transient misused — Warning both ways
Ask of every actor and mixin field: if this is lost on upgrade, is the app still correct?
- Missing
transient: caches, derived indexes, rate-limit counters, and anything rebuildable are being persisted. They then have to be migrated forever, and a stale cache survives the upgrade that was supposed to clear it. - Wrongly
transient: durable domain data silently resets to its initializer on every upgrade. Look fortransienton anything a user would expect to still be there after a deploy.
S3. var actor field shared with a mixin — Blocker
var parameters pass by value, so the mixin mutates a copy and the actor’s field never changes. Wrap it in a record and pass the record. Also flag a fresh record literal at the include site (include Api({ var n = state.n })) — each mixin then gets its own copy. See writing-motoko.
S4. Stable state inside a mixin block — Blocker
Every bare let/var at the top of a mixin is implicitly stable and traps at runtime with IC0503. State comes in as a parameter; constants go in a module; only transient is allowed inline.
S5. Migration hygiene — Blocker
- A migration file importing anything other than
mo:core/...— the chain replays forever, so a project import makes it wrong the moment that type changes. (caffeineai-lintscatches this.) - More than one pending migration in a build, or an edit to a migration that predates this build.
- A stable field in
main.mothat no migration in the chain supplies (M0254 / M0267).
An identity migration body is a Warning — the change was stable-compatible, so delete the file. Full table.
Structure
Layers, with dependencies pointing one way only: main.mo → mixins/ → lib/ → types.mo.
| File | Holds | Must not hold |
|---|---|---|
types.mo |
Type declarations; small pure helpers on those types | State, endpoints, business rules |
lib/*.mo |
Domain logic as stateless modules, state as parameters | Public endpoints, actor fields |
mixins/*.mo |
Public endpoints: authorize, delegate, map to a view | Domain logic, stable state |
main.mo |
State declarations and includes |
Any public method, any logic |
migrations/ |
The frozen chain, one module per file | Project imports |
- A1 — Blocker/Warning: a public method in
main.mo. Move it to a mixin. - A2 — Warning: a monolithic file. One
.moholding types, state, logic, and endpoints together. - A3 — Warning: domain logic in a mixin body. If an endpoint’s body is more than authorize → delegate → map, the middle belongs in
lib/. - A4 — Warning: wrong dependency direction —
lib/importing a mixin, ortypes.moimporting either. - A5 — Note: a mixin that is not a feature. Mixins split by feature (
mixins/Bookings.mo), not by verb (mixins/Getters.mo). - A6 — Warning: over-broad state injection. A mixin receiving state it never touches widens what a change can break; pass only the slices it uses.
Maintainability
Most of this is caught in the last, bodies pass. The exception is M1, the authorization blocker — it is found during the API-boundary pass (step 5), not with the body smells below.
- M1 — Blocker: a
public sharedfunction that does not checkcaller. Every state change and every read of another user’s data needs authorization on the backend. - M2 — Warning:
Natsubtraction that is not provably non-negative at the operation. It traps. - M3 — Warning: a trap where the caller could have recovered. Traps roll back the message and reach the client as an opaque reject; caller-fixable failures belong in a
Resulterror variant. - M4 — Note: duplicated internal→view mapping. One
toViewper type, inlib/ortypes.mo. - M5 — Note: magic values — bare literals for limits, quotas, or durations. Name them in a module.
- M6 — Note: a comment asserting an invariant the type could carry. Every “must be non-empty” / “only set when …” comment is a type-encoding finding in disguise. Where Motoko cannot express the invariant, keep the comment but validate the value once at the boundary rather than re-checking it at every use.
Conventions
Least consequential of the four axes, and mops check --fix fixes most of it where the -W flags are on — so raise these only after the passes above, and only where the tooling cannot. writing-motoko has the rules; this is what to look for.
- C1 — Note: a module-function call where the function takes
self—List.add(list, x),Principal.toText(p). M0236, auto-fixed. - C2 — Note: an implicit passed explicitly (
map.add(Text.compare, k, v)), or a type instantiation inference already resolved. M0237 / M0223, auto-fixed. - C3 — Warning: type annotations on an inline
funcpassed as a call argument —xs.filter(func(x : Nat) : Bool { x > 1 }). No diagnostic catches this, so it is the reviewer’s job: the call already fixes both types, and the annotation duplicates them so they can drift. Writexs.filter(func x = x > 1). Instantiate the call (map<In, Out>) when M0098 demands it — never the lambda. One exception:: async ()on an async callback is load-bearing. - C4 — Blocker: a
class, or any object holding functions, used as actor state. Functions are not stable, so it fails with M0131 (declared stable but has non-stable type). Outside state a class still is not the idiom here — a module takingselfplus dot notation gets the same call sites and stays stable.
Refactoring
Design fixes in Motoko are not free: changing a type that is reachable from a stable actor field requires a migration, and the chain is one-way. Before proposing a type change, establish whether that type is stable state.
mops check --fixfirst, so the mechanical noise is gone.- Batch the stable-state type changes — one pending migration per build, so they land together.
- Write the migration (
migrating-motoko-actors). ForText→ variant, decide explicitly what an unrecognized string becomes rather than inventing an#unknowntag. - Move code before changing it — relocating, then editing, keeps each diff reviewable.
mops check --fixuntil clean, thenmops buildonce.
Reporting
Group findings by severity, blockers first. Per finding: file:line, the level, the concrete failure it permits, and the change. Suppress findings the compiler already reports.
Blocker src/backend/mixins/Tasks.mo:34 (M1)
`claimTask` never reads `caller` — any principal can claim any task.
Warning src/backend/types.mo:12 (T1)
`Task.status : Text` is a closed set, so the comparison at Tasks.mo:51
spells it "complete" and is never true. Stable state — needs a migration.
If nothing is wrong, say so plainly. Do not manufacture findings to fill a report.
Additional References
- references/type-encoding.md — recipes per invariant, and the migration each one needs
- references/state-and-persistence.md — why EM matters, the stable/transient question, migration hygiene
writing-motoko— language mechanics,mo:coreAPIs,Result, dot notation- mops tooling: Load
mops-cliformops.tomlconfiguration andmops check/mops builddetails migrating-motoko-actors— writing the migration a type change requirestroubleshooting-motoko-migrations— compatibility diagnostics that do not match the source