Motoko doc strings
Purpose
Add triple-slash (///) doc comments to every public declaration in Motoko
source files so that mo-doc renders meaningful documentation pages. This
skill captures the conventions used in mo:core (caffeinelabs/motoko-core)
and the lessons learned applying them across the motoko-bitcoin library.
When to Use
- The user asks to "add doc strings", "document the public API", or "generate docs" for a Motoko package.
- Reviewing or polishing a library before release / publication on MOPS.
- After adding new public symbols and noticing missing entries in the
generated
docs/.
What Counts as a "Public Object"
Document every declaration that ends up in the rendered docs. In a typical
module { ... } file these are:
public type ...public let ...public func ...public class ...- The module itself (
module { ... }ormodule Name { ... }) - For a
public class, everypublic let,public var, andpublic funcmember inside the class body. - For a nested
public module, recurse and apply the same rules.
Private declarations (func, let, class without public) and helper
types should NOT receive /// comments — they don't appear in mo-doc
output and the noise hurts readability.
Comprehensiveness by Location
How thorough a doc string needs to be depends on where the file lives:
- Inside
src/internal/(some packages usesrc/private/for the same role — apply the same rule) — doc strings can be brief. These modules are implementation details that end users are not expected to call directly, so a short one-liner stating what a declaration does is usually enough. Trap/error notes can be omitted unless the behavior is surprising to another maintainer. - Anywhere else under
src/(i.e. outsidesrc/internal/) — doc strings MUST be comprehensive. These are the public API surface that users will call, so they need every detail: argument units and formats, size/range constraints, return-value semantics, full failure behavior (Traps/Errorsparagraphs), and runnable examples where helpful. Apply the full "User-Perspective Read-Through" checklist at the end of this skill to every declaration here.
Doc String Format
mo-doc parses lines that start with /// as Markdown documentation
attached to the next declaration.
/// Brief one-line description ending in a period.
///
/// Optional longer paragraph(s) describing semantics, edge cases,
/// and return values. Refer to parameters by `name` in backticks.
///
/// Example:
/// ```motoko include=import
/// let result = Module.func(arg);
/// ```
public func func(arg : T) : U { ... };
Conventions used in mo:core:
- First line is a short imperative summary ("Returns ...", "Computes ...", "Decodes ...") ending with a period.
- One blank
///line separates paragraphs. - Wrap parameter names, types, and short code in single backticks.
- Code blocks use
```motokofences. Use the```motoko include=importannotation when the snippet relies on the module's import header (defined elsewhere with```motoko name=import). - For module-level docs, define a named import block once at the top so all
example snippets can reference it:
/// ```motoko name=import /// import Hash "mo:bitcoin/Hash"; /// ``` module { ... } - The
name=import/include=importannotations are NOT consumed bymo-docitself — they are directives for an external doctest runner (used bymo:core's CI and by Docusaurus-based doc sites) that prepends the named snippet before compiling each example.mo-docpasses them through verbatim into the rendered code-fence info string. If the project does not run doctests, you can omit them and just use plain```motokofences.
Where to put the module-level doc string
The module-level /// block must sit at the very top of the file,
before the import statements, with one blank line separating it from
the imports and one blank line between the imports and the module { ... }
line. This matches the layout used in dfinity/motoko-core and is the
only placement that mo-doc actually attaches to the module page —
a doc block placed between the imports and module { ... } is silently
ignored and the rendered module page will have no description.
All .mo files MUST have a module-level /// doc string at the very
top of the file (before the import statements). This applies to every
Motoko source file, not just modules with public APIs — having a doc
block at the top ensures mo-doc renders a meaningful module page and
helps maintainers quickly understand the file's purpose.
Special case for Mixins: This rule is especially important for files
using the mixin keyword. Even though mo-doc currently skips files
with the mixin keyword due to syntax differences, the doc string MUST
still be placed at the very top of the file to ensure compatibility with
future tool updates and maintainer readability.
/// One-line module summary.
///
/// Longer description.
///
/// ```motoko name=import
/// import Foo "mo:pkg/Foo";
/// ```
import Bar "mo:core/Bar";
import Baz "mo:core/Baz";
module {
// ...
}
For named modules (module Name { ... }, e.g. src/bitcoin/Script.mo)
the same rule applies: doc block at the top of the file, then a blank
line, then imports, then a blank line, then module Name { ... }.
Where to put per-declaration comments
Place /// lines immediately above the declaration with no blank line
between them. If there is an existing legacy // ... comment, put the
/// block above the legacy comment (the legacy comment can stay as
implementation notes).
/// Public API description goes here.
// Legacy implementation notes can stay below the doc string.
public func encode(input : [Nat8]) : Text { ... };
Common Pitfalls
1. apply_patch and literal \n
When inserting multi-line content, write actual newlines in the patch body
(one + per real line). NEVER use the escape sequence \n inside
inserted text — it will be written as the literal two characters and break
the file.
2. Don't accidentally insert into the middle of a function
apply_patch matches on context. When the surrounding context is too
short or appears multiple times, the inserted block may land inside a
function body. After inserting docs, run a quick syntax check (e.g.
mo-doc or moc --check) to catch this. A telltale sign is a
syntax error [M0001], unexpected token 'import' message — that means a
new module-level block was placed inside a loop/func body.
3. Class member documentation order
mo-doc renders class fields and methods in source order, but the class's
own description block is shown after the member list when the class doc
appears above the constructor. To keep the class summary at the top of
the rendered class page, place the /// block on the line directly
preceding public class Name(...).
4. Re-exported types
Re-exported types like
public type Signature = Types.Signature; still need a one-line ///
description so they are not rendered as "(no description)".
5. Skip private helpers and constants
Don't add /// to let, func, or type declarations that lack
public. They never appear in the output and the comments add visual
noise.
6. module Name { ... } named modules
Files like src/bitcoin/Script.mo use the form module Script { ... }
instead of the bare module { ... }. The same top-of-file placement rule
applies — the module-level /// block goes at the very start of the
file (before the imports), not on the line directly preceding
module Script {.
7. Preserve unfamiliar parameter syntax verbatim
If a function signature uses syntax you don't recognise — e.g.
self : [var T] (Motoko's method-dispatch hook that enables
x.func(...) call sites) or key : (implicit : T -> Nat32) (the
implicit parameter feature) — do NOT rewrite it. These shapes are
load-bearing: they affect how call sites in examples work
(users.bucketSort<User>(...) only compiles because the first
parameter is named self), and a doc pass that "cleans them up" can
silently invalidate every example you just wrote.
8. Module doc must be at the top of the file
mo-doc only treats a /// block as the module description when it
appears at the very beginning of the file, ahead of the import
statements. A doc block placed between the imports and module { ... }
compiles fine but produces an empty module description in the rendered
HTML. If you find an existing project with module docs adjacent to
module { ... }, relocate them to the top of the file (a small Python
script that finds the trailing /// block before module and prepends
it to the file works well for batch migration).
8. Documenting Record Types
For record types with multiple members, documenting each member with a
per-line /// can make the code and the rendered documentation very
verbose. Instead, prefer documenting all members in a single block above
the type declaration. Use backticks for member names.
/// An HTTP request.
/// `method`: HTTP method (e.g., "GET", "POST").
/// `url`: Request URL.
/// `headers`: List of headers.
/// `body`: Request body.
public type Request = {
method : Text;
url : Text;
headers : [(Text, Text)];
body : Blob;
};
Workflow
Inventory public declarations:
grep -RInE "public (type|func|class|let)" srcFor each
.mofile:- Add a module-level
///block (with aname=importexample) at the beginning of the file, even before the block of import statements. - Add
///blocks above every public declaration inside the top-level modules. - Recurse into nested public declarations. For instance public members of public classes need doc strings. Public members of public modules need doc strings. And so on.
- Add a module-level
Re-scan to catch anything missed:
awk ' FNR==1{prev=""} { if ($0 ~ /^[ \t]*public[ \t]+(type|func|class|let)([ \t(]|$)/) { p=prev; gsub(/^[ \t]+|[ \t]+$/, "", p); if (p !~ /^\/{3}/) printf "%s:%d:%s\n", FILENAME, FNR, $0; } if ($0 !~ /^[ \t]*$/) prev=$0; } ' src/*.mo src/**/*.moEmpty output = all public declarations are preceded by a
///line.Portability: use
[ \t]+(and explicit[ \t(]boundaries) instead of[[:space:]]+/\b. BSD/macOS awk silently fails to match POSIX classes here — the script returns empty output and looks like a pass. The cost of this miss is a full iteration to diagnose.Known false positives (don't waste edits "fixing" these — mo-doc itself handles both cases correctly):
- A legacy
// ...comment between the///block and thepublic ...line. The skill's format guidance explicitly allows this layout, but the audit awk only looks at the immediately previous non-blank line. Verify by reading the rendered HTML before assuming a real miss. - A
public func/public letinside a/* ... */block comment. The awk has no notion of block comments, so commented-out code shows up as missing. Cross-check bygrep-ing the file for/*near the flagged line.
- A legacy
Generate docs:
# mo-doc will NOT create nested output subdirectories on its own. # If src/ has subdirectories, mkdir -p the mirrored paths under # docs/ first or the run aborts on the first file it can't write. mkdir -p docs $(find src -mindepth 1 -type d | sed 's|^src|docs|') mo-doc --source src --output docs --format htmlNo output = success. Two failure modes to watch for:
- A
Fatal error: exception Sys_error("docs/.../X.html: No such file or directory")means an output subdirectory is missing. - Any
Skipping ...line indicates a syntax error that must be fixed (often a strayapply_patchcorruption).
- A
Spot-check the rendered output:
docs/index.html— every module should appear in the listing.- Each
docs/<Module>.html— module description, types, functions, and class members should all show their text.
Tips for Writing Useful Descriptions
- Describe what the function does and what it returns, not how it is implemented.
- For low-level helpers (
readBE32,writeLE64, etc.) a one-liner stating the byte order, width, and offset semantics is sufficient. - For domain types (
SighashType,WitnessProgram,OutPoint) name the spec or BIP that defines the format and link to it. - Keep examples short and self-contained; prefer literal byte arrays over reading from external sources.
Files with many similar public lets (constants tables)
Each public let needs its own /// line — there is no syntax for a shared
doc block. For files like FIPS round-constant tables (e.g. SHA256's
K00..K63, SHA512's K00..K79), doing N individual Edit calls is
slow and noisy. Read the file once and rewrite it whole with Write,
emitting one /// <name> round constant K<NN>. line per entry. A
module-level /// at the top can carry the shared context (spec section,
purpose) so the per-entry lines stay terse.
Documenting Error and Trap Behavior (REQUIRED)
Every public function doc string MUST describe its full failure behavior.
Readers cannot tell from a type signature alone whether a function traps,
returns null, returns #err, or simply produces a wrong-but-defined
result on bad input — the doc string is the only place this contract is
recorded.
What to look for in the implementation
Scan the function body (and every helper it calls) for:
Runtime.trap(...)/Debug.trap(...)/Prim.trap(...)calls.assert ...;statements (a failed assert traps).- Pattern matches that are non-exhaustive in practice (e.g. a
switchon?Twhosenullbranch traps, or acase (#err _) Runtime.trap ...). - Implicit traps from the standard library: out-of-bounds array indexing
(
a[i]wheni >= a.size()),Natsubtraction underflow, division by zero,Option.unwraponnull. - Explicit error returns:
?Treturningnull,Result<T, E>returning#err, variant returns like{ #ok; #err }.
What to write
For each failure mode, state in the doc:
- The condition — what input or state triggers it, in user-facing
terms ("when
inputcontains a character outside the Base58 alphabet", not "whenmapBase58[c] == 255"). - The outcome —
traps,returns null,returns #err(...), etc. - For
Result/option returns, list every distinct error case separately when the variants carry meaning.
Use a dedicated Traps and/or Errors paragraph (or both) at the end of
the doc, after the example and before the runtime/space notes.
CRITICAL: Use the wording "Traps" (capitalised, present tense) for
unrecoverable failures (e.g., assert, trap). This makes the failure
modes highly visible in rendered docs and easily greppable.
/// Decodes a Base58-encoded string into the original byte array.
///
/// ```motoko include=import
/// let bytes = Base58.decode("StV1DL6CwTryKyV");
/// ```
///
/// Traps if `encoded` contains any character that is not in the Base58
/// alphabet (i.e. not in
/// `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz`).
public func decode(encoded : Text) : [Nat8] { ... };
For graceful errors:
/// Parses a WIF-encoded private key.
///
/// Returns `#err(msg)` when:
/// - the input is not valid Base58Check (bad checksum or alphabet),
/// - the decoded payload has an unexpected length (must be 33 or 34 bytes),
/// - the version byte does not match `network`.
public func decode(wif : Text, network : Network) : Result<PrivateKey, Text> { ... };
Pure / total functions
If a function genuinely cannot fail (e.g. Array.size, a pure arithmetic
helper that uses fixed-width types and total operators), say so explicitly
with a one-liner like "Never traps." or omit the failure section
entirely — but only after auditing the body to confirm.
Worked examples from motoko-bitcoin
Base58.decode— traps on any character outside the alphabet (not aResult, no graceful fallback).Base58Check.decode— returns?[Nat8];nullon bad alphabet, bad length, or checksum mismatch. Document each.Bech32.decode— returnsResult<_, Text>with distinct messages for invalid characters, mixed case, bad checksum, length out of range, invalid HRP. Mention each error category, not just "returns#err".Bip32.derivePath— traps on hardened derivation from a public key.Fp.inverse(and the/operator) — traps when the value is zero.Affine.add/Jacobi.add— document behaviour at the point at infinity and for equal/opposite inputs.- Transaction serialization (
Transaction.toBytes,TxInput.toBytes, etc.) — note any size limits that would causeNat32/Nat64conversion traps.
Audit workflow for an existing file
- List every
public funcandpublic classmember. - For each, read the body and follow the call graph one level into private helpers.
- Note every
trap,assert,Result/Optionreturn, and any implicit trap source (subtraction, indexing, division). - Update the doc string to enumerate the conditions.
- Re-run
mo-docand visually scan the rendered HTML for sections that still lack a "Traps" / "Errors" paragraph on a non-trivial function.
Final Step: User-Perspective Read-Through (REQUIRED)
After every public declaration has a doc string, do one more pass. Read each doc string from the perspective of a first-time user of the API who has not seen the implementation. For every doc, ask:
- What is the unit / format of each argument and the return value? (bytes vs. bits, big- vs. little-endian, satoshis vs. BTC, raw vs. DER-encoded, compressed vs. uncompressed, 0-based vs. 1-based, …)
- For Motoko array parameters, is
[T](immutable) vs.[var T](mutable) clear? Examples that mixArray.tabulate(returns[T]) withVarArray.repeat(returns[var T]) routinely confuse newcomers — spell out which shape the function expects. - What are the size or range constraints on each input?
- Which BIP / RFC / spec defines the format, and is it linked?
- For mutating methods, what state changes? Is the receiver still usable afterward?
- For functions that take a callback or proxy (e.g. an ECDSA signer), what is the expected input/output shape of the callback?
- For variant returns, what does each variant mean semantically (not just what tag it carries)?
- Are domain-specific terms ("witness program", "tap leaf", "sighash", "scriptPubKey") used without a one-line explanation or link?
- For constants (
EMPTY_WITNESS,dustThreshold), what is the value and why does it have the value it has? - For re-exported types, where is the actual definition (and is the link there)?
- If you removed the function name, would the description still be
unambiguous? If two near-identical functions exist (e.g.
toBytesvs.toBytesIgnoringWitness,encodevs.encodeUncompressed), is the difference between them spelled out?
If any question remains unanswered, extend the doc string to address it. Prefer one extra sentence in the doc over forcing the user to read the source. Do this pass file by file; it usually surfaces 2–5 missing facts per non-trivial module.