Ship source style
Agent conventions. Language behavior lives in executable tests.
Infer parameter types in .shp
Named helpers follow the same rule as inline lambdas: omit a parameter type when the body, a companion .shc, or an expected function type already determines it. Restating value: String because the body calls Bytes.of_string(value) is a second copy of a type inference already has.
let has_prefix = (value, prefix) => String.starts_with(value, prefix);
type User = {
name: String;
age: Int;
}
let name = fn(user) {
return user.name;
};
Write a parameter type when inference cannot finish: recursive bindings without a complete head, service operations, higher-rank / sig, or a real ambiguity. A stable public signature belongs in .shc, not copied onto the companion .shp body.
See 02-type-inference.md.
Unary match helpers use function
When a single-parameter helper's whole body is a unary match, write:
let name = function { | ... };
Braces are required. Never hand-write the desugared form ((__function_subject) => match __function_subject { ... } or (x) => match x { ... }). Use an explicit parameter only when the match is not the whole body, a named binding is needed for clarity, or a typed/channel head cannot ride on function yet.
See also 01-syntax.md (function forms).
Match the domain; do not simulate it
Use variants and match when control flow is selecting among meaningful domain shapes. Use pattern guards for refinements of one shape. Do not translate a domain choice into mutable flags, nested if ladders, or offset arithmetic spread through the caller.
type Layout =
| Compact
| Hyphenated { start: Int }
let classify = fn(input) {
match Bytes.length(input) {
| 32 -> Compact
| 36 -> Hyphenated { start = 0 }
| 38 when is_braced(input) -> Hyphenated { start = 1 }
| _ -> error :parse("invalid layout")
}
};
Prefer byte/range patterns, constructor patterns, tuple patterns, and or-patterns over repeated equality checks. Keep if for genuinely sequential decisions, not as a weaker spelling of a matchable domain.
A single exhaustive unpack is let, not a one-arm match:
let (value, after) = parse_value_at(bytes, cursor);
let { name, email } = user;
Keep match when the scrutinee has more than one meaningful shape.
Keep one short named variant field inline:
| Hyphenated { start: Int; }
Expand named payloads with multiple fields or field metadata. Case metadata may follow an inline payload.
Guard clauses protect the happy path
Reject invalid, empty, exhausted, or out-of-range states at the top of a function or loop. After those exits, keep the main path flat and state only the invariants that remain true.
let byte_offset = fn(bytes, offset) {
if offset < 0 {
return None;
}
if offset >= Bytes.length(bytes) {
return None;
}
Some(offset)
};
Do not force linear cursor algorithms into recursion or elaborate variants only to avoid while / let mut; local mutation is appropriate when it makes the remaining state obvious.
Prefer natural tail values
A final expression without ; is the block or function result. Write that directly. Keep return and break for guard clauses and other early exits.
Let ship fmt own line layout. Compound value expressions stay compact through 60 characters inside the source-line target, then expand recursively. The target is 120 characters unless the nearest ship.project sets format_width; the book uses 100 to fit its code panels. Record-like arrow bodies always start below =>; any other multiline arrow body moves below it as a unit. A pipeline with two or more |> operators always expands one step per line. A chain of one operator that does not fit breaks before each operator when every operand then fits on its line.
let class_for = fn(active) {
if active {
| "selected"
| "link"
}
};
Boolean if cond { | a | b } is the spelling for a two-way value pick (HTML class =, labels, tiny CSS/text choices). The formatter always rewrites if cond { a } else { b } to that form when both arms are a single expression, and always puts each arm on its own line. There is no compact one-line form. Do not overuse it. else if chains and statement bodies stay ordinary if/else.
When a last match uses return in its arms, put ; after the match so it stays a statement. Prefer tail arms when the match is the result.
Mutation uses :=
Write place := expr for mut locals and fields. It is an expression (type and value are the right-hand side) and is legal anywhere an expression is: match arms, if scanning := False { }, print(count := n), right-associative a := b := 0. place := expr; is the Unit statement form. Do not nest := when a statement is clearer. = stays for let, product fields, labeled arguments, and { x with field = v }.
let mut scanning = True;
scanning := False;
| None -> scanning := False
| ' ' -> {
pending_spaces := pending_spaces + 1;
offset := offset + 1;
}
Doc comments use ///
/// documents the following item. // is an ordinary note. Do not bulk-convert existing // comments; new item docs should use ///. The formatter keeps leading comments attached to the next item.
Group #type assertions and ideas in tests
Place #type assertions immediately beside the function or value being exercised—not collected at the top of a test—and use blank lines to separate distinct ideas inside a test body:
test "Task Module" {
#type Task.new: fn(String, ~title: String) -> Task, exn [ :empty_title ];
let tracker = must Task.new("task-tracker", title = "Build the task tracker");
let crash = must Task.new("bug-crash", title = "Fix the crash");
let renamed = Task.rename(tracker, to = "Ship the task tracker");
expect Task.label(renamed) == "task-tracker: Ship the task tracker";
#type Task.kind: pure fn!(Task) -> Task.Kind;
expect Task.kind(tracker) == :feature;
expect Task.kind(crash) == :bug;
}
Lean on generated wire and display behaviors
Do not hand-roll to_string / parse for ordinary concrete variants or products.
Wire identity:
Json.encode/Json.decode(generatedEncode/Decode)Debug text:
Inspect/DisplayUI copy (select labels, option lists): belong in the frontend; match on the variant there — do not park presentation helpers on shared domain modules
Do not shadow stdlib type names
Do not redefine stdlib names (Option, Result, List, Array, String, …) as project types. User definitions win silently over the auto-open layer, so a local type Option = … hides the real Option and makes ordinary optional values painful. Pick a domain name (Choice, SelectItem, …) instead.