Syntax
Code-first language reference. Semantics and deeper design live in linked topic docs; this file spells what to write.
Decisions
OCaml-inspired semantics; clearer delimiters and separators.
Prefer parse/format/recoverability; push mistakes to parse or compile time.
Explicit braces for blocks, branches, and bodies that need clear endings.
Matching syntactic shapes for related concepts.
Plain field access
x.yis stored data / namespaced members only — no hidden getters, proxies, or user-defined dispatch.Preserve strong inference; avoid annotation noise.
Reserve keywords when the language reads better with them; contextual words are keywords only in their introducing position (see Keywords).
Taste-level consistency rules are hard errors when they keep Ship cohesive.
A block's result is its last expression before
}(no;).return/breakare early exits, not the ordinary way to produce a value.
Keywords
Reserved everywhere: let, fn, open, type, module, struct, effect, provide, match, function, if, else, while, defer, return, break, error, try, must, todo, spawn, await, where
Contextual (ordinary identifiers elsewhere):
| Word | Keyword only... |
|---|---|
pure, exn, eff | function signature positions |
latent, ignored | function-typed parameter/slot positions |
exception | structural exception declaration position |
test, suite, program, unit, database, resource, derive, include, mutation, pattern | item position with their follow shape |
expect | statement position inside test bodies |
providing | leading setup statements inside suite and test bodies |
Reserved words cannot be bindings, parameters, fields, patterns, labels, bare map keys, or member segments after .. In particular, lowercase todo is only the typed-hole expression; domain names use Todo, todos, or a string key such as ["todo"]. | table, relation, domain, retention | database block members | | query | callable query(...) { ... } expression position | | live | before query(...) on a module-level let right-hand side (19-sync.md) | | css | also portable css { ... } class expressions and static module-level css(Global) { ... } rules (20-css.md) | | embed, subscription | program directives; embed field: Type; product fields | | bind | server block members | | service | after effect, or let service Name : Interface | | behavior, rec, sparse, fresh | after module / let / type ... = / provide | | delete, from, insert, into, join, on, returning, row, select, set, through, update, use | SQL-shaped query bodies only |
So let table = ..., a field named select, and Html.select all work; table Users { ... } is still a declaration inside database.
Delimiters
| Separator | Use | |||
|---|---|---|---|---|
, | List-like ( ), [ ], `[ | ]` — calls, params, tuples, vectors, arrays, multi-arg type apps | ||
; | Members inside { } and %{ } — statements, product fields, map entries, set elements, table bodies, metadata | |||
| `\ | ` | Alternatives — closed variants, match / function / try arms, boolean `if { \ | a \ | b }, open tags in[ ... ]` |
No ASI. Write the separator for the form you are in.
Comments
// is an ordinary line comment. /// is an item doc comment, reserved for attaching documentation to the following item. //! is a chapter-doc comment: consecutive //! lines are markdown prose in literate Ship chapters under docs/book/ and docs/ref/. apps/ship-site weaves them with Syntax.parse / Syntax.format_item / Syntax.walk_* and renders that markdown alongside the lexicon and root Markdown docs to static HTML. The lexer matches //! before //.
//! # Pattern matching
//!
//! Closed variants are exhaustive at compile time.
/// Where a scanned string body ends.
type StringScan = {
end: Int;
has_escape: Bool;
}
// Local note, not documentation.
let found_at = fn(bytes: Bytes, offset: Int) -> String {
"end"
};
The formatter keeps leading comments attached to the next item: a blank line goes before the comment+item group, never between the comment and the item. The same blank-line rule attaches a //! region to the following item group.
ship fmt honors a file-level // fmt: ignore when that is the first non-blank line (a trailing note after fmt: ignore is allowed). The tool reads that comment before parsing, so negative parse fixtures can opt out. Unparseable files without it fail ship fmt. This is a tool skip, not a style option; there is still one format.
A chapter may start with YAML frontmatter, using the same --- fence as Markdown.parse. fails: true means the file is expected to fail typecheck or expect: ship test still prints the error, but CI stays green. An unexpected pass (fails: true and everything succeeds) is a failure. Unknown keys are errors.
//! ---
//! fails: true
//! ---
//!
//! # Failed expects
//!
//! This chapter shows a failing `expect`.
test "false is not true" {
expect False;
}
Chapter markdown may annotate the next top-level Ship item with ::hide. The item remains part of the parsed, checked, and tested program; only the woven site fence is omitted.
//! ::hide
test "proof used by the chapter" {
expect True;
}
PascalCase block tags are structured Markdown components. Components use matching open and close tags, may nest, and accept double-quoted string or bare boolean attributes. The Markdown parser preserves their name, attributes, and nested blocks; an output owns the registry that gives each component meaning. Unknown registrations and malformed, mismatched, or unclosed components are rendering errors.
//! <Fold summary="Additional examples">
test "another checked example" {
expect True;
}
//! </Fold>
The language site registers Fold; it renders nested markdown and woven Ship fences as collapsible content. Inline and self-closing components are not part of this syntax.
Book chapters link to each other by frontmatter slug, not filename. The slug is the public identity, so [inference](inference) stays valid if the chapter file is renamed or reordered. The language site fails generation when a slug does not name a chapter. Topic docs and other repo files still use relative paths (../01-syntax.md).
//! See [inference](inference) and [pattern matching](pattern-matching).
Terminator law
Brace-shaped declarations (
type … {…},module … {…}, anonymous filemodule: #{…},pattern … {…},program/database/ bodiedembed/subscription, statementif/while/defer {…}) end at}. Nothing trails that}.Bindings (
let/let mut/let rec) always end with;. When the RHS ends in}, write};— the;belongs to the binding.Body-less declarations (
open,include, body-lessembed/subscription,#infer,abstract type,derive, bare aliases,module X = Y, named filemodule: Signature) require;.Inside
{ }member lists,;separates members (prefer trailing;). Metadata on a field closes with};— that;is the member terminator.Arm lists use
|only — no,or;after an arm.List-like forms use
,only; trailing commas are legal.Block tails: the last expression immediately before
}has no;and is the block or function value.expr;is a statement (UnitorNever).must expr;may also stand alone: it escalates an expected error and discards the successful value. There is no ASI: an unterminated expression is legal only when the next token is that block's}.{ name }is record punning, not a block yieldingname. Match /functionarms follow the same rule:| X -> { field = value }is a record literal. Assignment uses:=, so| X -> scanning := Falseand| X -> { scanning := False; }are the expression and statement forms.{ ident := ... }is a block, not a record.
type t = {
name: String;
age: Int;
}
embed Counter :counter {
many = model.rows;
}
let init = fn() {
{ window_focused = True }
};
let msg = match status {
| Open -> "open"
| Done -> "done"
};
let classify = function {
| Open -> "open"
| Done -> "done"
};
embed Counter :counter;
open Accounts.User;
type UserId = UUID;
Products: types use :; values use =. Semicolons separate fields in both.
type t = {
name: String;
age: Int;
}
let user = { name = "teej"; age = 37 };
let older = { user with age = 38 };
Braces after => are value expressions, never statement blocks. Record-like bodies begin on the next line so the constructor and its braces read as one value:
let deny = () =>
Gate { deny = True; };
Collections and maps
[ ... ]— default persistent ordered collection (Vector)[| ... |]— mutable growable contiguous array (Array)%{ ... }— persistent set (Set); empty set%{}Maps: bare id = string key;
[expr]= computed key;=>is for lambdasEmpty maps:
Map.empty()— not{}Lists: lowercase prelude constructor
list(...)Ops: PascalCase modules, collection first —
Vector.push(items, value),Array.push(items, value),Map.set(builder, key, value)
let names = ["teej", "sam", "drew"]; // Vector(String)
let scratch = [| "teej", "sam", "drew" |]; // Array(String)
let stack = list("parse", "tokens");
let roles = %{ :admin; :editor };
let labels = {
id: "todo-1";
[field_name]: "dynamic";
};
Executable examples: Collections.
Operators and labels
Labeled calls are in book/003-Functions.shp.
// Equality is == ; single = is never a test
if user.age == 37 {
return "same";
}
if user.email != None {
return "has email";
}
// Coercion :> — checked views (e.g. open-variant widening)
type Readiness = [ :ready | :waiting ]
type Timed(open 'r) = [ 'r | :timeout(Duration) ]
let as_timed = fn('r)(status: 'r) -> Timed('r) {
return status :> Timed('r);
};
// Fallback ?> — OrElse on the left type; left once; right only on fallback
let session =
Cookie.read(request, "quotes_session") ?> error :unauthorized;
let theme = configured_theme ?> "system";
let value = primary ?> secondary ?> error :missing; // right-assoc
// ?> quotes both sides as Code; not Rust-style ? propagation
(request |> find_session) ?> error :unauthorized
(configured ?> default_config) |> Config.load
// Unparenthesized ?> with |> is rejected
Variants, pipes, and tuples
type Readiness = [ :ready | :waiting ]
match status {
| :ready -> "ready"
| :waiting -> "waiting"
}
type JobStatus =
| Open
| Done(Instant)
type BillingEvent =
| TrialStarted
| PaymentFailed { invoice_id: InvoiceId; reason: FailureReason }
let pair = (a: Int, b: Int) => (a, b);
Statements, tests, strings
Statements end with ;. Locals are let x = e; (not let … in). A final expression before } has no semicolon — that is the block value. Immutable by default; mutable locals use let mut. var is rejected. Blocks use { } — not in, begin/end, or done. Exits are return, break, and 'name:-labeled break 'name — book/100-LanguageReference/005-Blocks.shp.
test "slugify lowercases and dashes words" {
let actual = slugify("Hello Ship");
#type actual: String;
expect actual == "hello-ship";
expect actual != "Hello Ship", "slugify should change the input";
}
suite "users" {
providing AppDb = Test.Service.per_test(
(_test: Test.Case) => AppDb.test_database()
);
test "lists users" {
// ...
}
suite "as admin" {
test "deletes a user" {
// ...
}
}
}
let name = "teej";
let count = 3;
let msg = "hello ${name}, count ${count}";
suite "name" { ... } is a declarative, arbitrarily nestable test section. Leading providing statements establish source-ordered providers for descendant suites and tests; remaining suite members are only suite and test declarations. Individual executable leaves always use test. Test service factories are ordinary functions: Test.Service.per_test receives a Test.Case, per_suite receives a Test.Suite, and each factory returns a named or anonymous provider module whose init / drop own the resource.
Inside a test body, expect <bool-expr>; is a statement. An optional comma and string is a failure message: expect cond, "why";. The comma belongs to expect, not a tuple. Outside tests, expect is an ordinary identifier. Failed expects record and continue; ship test reports all.
Inside a test body, #type <expr>: <Type>; is an exact compile-time type assertion. Ship infers the expression independently, then compares normalized types without using the written type to guide inference or accepting coercions. The expression is checked but never evaluated, and the assertion creates no runtime test event. #type is a parser error at top level, in ordinary function/block bodies, in suite bodies, and in functions nested inside a test. It is not runtime type reflection or a type-as-value operation. Executable examples: book/002-Inference.shp.
String holes are full expressions. Their compact Display boundary is specified by the executable Format, Display, and Inspect chapter. Escapes: \\, \", \n, \r, \t, \$, $$, \${. Raw strings #"…"# are verbatim: no escapes, newlines kept, and the closing " needs the opening count of #. Their holes carry the same fence, so $#{id} interpolates inside #"…"# while ${id} stays text. The formatter preserves the authored form. Executable examples: String Literals. No interpolation in patterns or test names. SQL/routes/regex/shell/wire use separate compiler-known syntaxes.
Programs And Targets
Nameless program: terminal client/<App>/init.shp uses program(Web) or program(Android); portable modules under client/src/ use plain program { ... } as Client.*. Optional default = True; when several unbound runnables exist.
// client/BrowserCounter/init.shp
program(Web) {
embed Client.Counter :counter;
subscription Browser.Window.Focus :window {}
type t = {
window_focused: Bool;
}
type message = []
let init = fn() {
{ window_focused = True }
};
let update = (model, message) => match message {
| :window.focused -> { model with window_focused = True }
| :window.blurred -> { model with window_focused = False }
};
let view = (model) => Html.div([
Html.map(Client.Counter.view(model.counter), f = (message) => :counter(message)),
]);
}
Generated: init / update / view contracts. Commands, outcomes, embeds, subscriptions → 14-browser-programs.md. Also 18-http.md and 20-css.md.
server(Native) {
port = 8080;
bind Client.Counter { route = "/counter/"; } // serving ref, not import
impl TodoApi { return TodoHandlers.handlers; }
route health = #get(/health) -> text("ok");
}
The accepted server profiles are Native and Static; both execute native Rust-owned server code. database lives under src/ only — Databases. Dedicated migration Database(from = snapshot(...), to = snapshot(...)) declarations live under migrations/<database>/shp/; their full syntax and implicit Start / End namespaces are executable-artifact documented in Database migrations. A bound database { url_env = "..." } names the environment variable the server reads at startup; values never appear in source or manifests. Broader secret storage remains unresolved in question 300.
A server(Static) declares generator = "bin/main.shp", static_root = "site", and content_root = "../../docs" for generated static sites. ship dev models generation, serving, content watching, regeneration, and browser reload in one workflow graph; see 17-dev.md.
Modules And Types
File-as-module
An ordinary .shp under a module root is the module — do not wrap the file in module Name { ... }. Executable/target entries are terminal, not importable.
Reusable signature — optional
module: Signature;checks and seals the file module to onemodule type. It must be the first non-comment source line. Comments, chapter frontmatter, blank lines, and// fmt: ignoremay precede it; no source item may. A companion.shcand a file signature are mutually exclusive. Executable and target entry files cannot use it. The usual shape isStore.shpdeclaringmodule type T, thenStores/Flat.shpbeginning withmodule: Store;. One file may compose several requirements without naming a one-off signature:module: #{ include Store; include Snapshot; }. The formatter expands eachincludeonto its own line.Filename — PascalCase (
User.shp). Rejectuser.shp/todo_id.shp. Entry namesmain.shp/init.shpdo not createMain/Initsegments.Path — every directory segment is PascalCase:
src/Accounts/User.shp→Accounts.User.Folder module — folder under
src/is a package;init.shp/init.shcis the external surface when present.Folder-local children — bare PascalCase for direct child modules only (shallow; parent siblings are not bare).
No mangling — collisions are errors, not generated names.
Nested
modulefor in-file sub-units when needed.
// User.shp -> module User
type t = {
name: String;
age: Int;
}
type Settings = {
theme: String;
}
let display_name = fn(user: t) -> String {
return user.name;
};
// elsewhere
let user: User = { name = "hi"; age = 37 };
let settings: User.Settings = { theme = "dark" };
let label = User.display_name(user);
Without a file signature or companion .shc, every top-level .shp declaration is public. A file signature or .shc contract defines the public surface; members omitted from it remain private.
src/Accounts/init.shp # package Accounts
src/Accounts/User.shp # internal unless re-exposed
// src/Accounts/init.shp
let create_user = fn(email: Email) {
return User.create(email);
};
// outside: Accounts.create_user(email) ok;
// Accounts.User.create(email) rejected if User is not exposed
Opens, includes, resolution
Qualified paths work without open. Stdlib is auto-opened (Vector.length); Stdlib.* remains valid. Current module's own path is a hard error inside itself (write bare members).
let user = Accounts.User.create(email);
let count = Vector.length(items);
open Accounts.User;
let user = create(email);
open Accounts { User; };
open Accounts.User { create; validate; };
let main = fn() {
open Accounts.User;
print(name());
};
open is lookup-only. include re-exports into the current surface. No app:/core: prefixes, open aliases, or bang-open.
Resolution layers (innermost first): lexical bindings → statement opens → item opens → current-module members → folder-local children → stdlib auto-open → qualified paths.
Notable rules: ambiguous opens error at the use site; user modules shadow stdlib silently; Stdlib as a project name is rejected; invalid opens are hard errors (unused opens are fine); target-tagged stdlib modules diagnose outside their target. See 15-modules.md.
Project roots
src/Accounts/User.shp # library Accounts.User
bin/main.shp # default executable
bin/api/main.shp # ship run api
client/src/Counter.shp # Client.Counter portable program
client/Storefront/init.shp # terminal client app
server/Hello/init.shp # server target
client/<App>/ and server/<Name>/ need init.shp. Android requires application_id; optional HTTPS origin — both rejected in plain / program(Web).
Module forms
File with
type t→ struct module; without → namespace module.File beginning with
module: Signature;→ the inferred file form checked and sealed against that reusable signature.module— namespace; may not declaretype t.module struct— exactly onetype t; module name is the primary type outside.module behavior/module type— 11-behavior-modules.md, 15-modules.md. A file'smodule type Tis its primary module interface (Store, equivalentlyStore.T).
module Http {
let parse = fn(bytes: Bytes) -> Request { ... };
type Method =
| Get
| Post
}
module struct User {
type t = {
name: String;
email: Email;
}
create = fn(email: Email) -> t {
return { name = ""; email = email };
};
}
module NameOrder = UserByName;
module UserCache = MakeCache(UserStore);
Functors keep the form keyword in the header: module struct MakeCache(S: Store): Cache { ... }.
Executable file-ascription examples: Module Signatures.
Shapes and primary t
All shapes use type — no record keyword.
type t = { name: String; email: Email }
type Status =
| Open
| Done(Instant)
type Readiness = [ :ready | :waiting ]
type UserId = UUID
| Context | Spelling | Meaning |
|---|---|---|
| Inside struct module | t | primary type |
| Outside, type position | User | primary of module struct User |
| Outside | User.Settings | nested PascalCase type |
| Outside, value | User.create(...) | module member |
| Rejected | User.t | use User |
| Struct-witness position | User | static witness |
let default = fn(struct 'a)(S = struct 'a) -> 'a {
return Default(S).default();
};
let user = default(User);
PascalCase binders are for modules/witnesses; runtime binders are snake_case.
Records
Products are nominal; construct with
User { ... }, update with{ user with ... }, and read stored fields withuser.name.type Patch = sparse { ... }makes every constructor field omittable and stores presence asOption; sparse fields cannot declaredefault, because omission is preserved data.type Public = Project(User, Fields(.id, .name))materializes a fresh nominal product in selector order.Fields(...)is ordered compile-time shape data, not a runtime collection. Prefix withsparseto make the selected fields omittable:type Patch = sparse Project(User, Fields(.name)).Projection requires a statically visible, fully applied named product. Generic source arguments substitute through selected field types; abstract representations remain opaque. Plain
Projectpreserves source sparseness, whilesparse Projectforces it and removes inherited field defaults.Associated operations remain explicit module calls:
User.rename(user, name).Fields are
snake_case;embedpromotes stored fields; metadata uses#{ ... }.Executable guides: records, updates, sparse records, and projection.
Database and resources
database AppDb(Postgres) {
migrations = "migrations/app";
domain Email = String {
check = sql("position('@' in value) > 1");
}
table Users {
id: UUID #{
primary = True;
default = sql("gen_random_uuid()");
};
email: Email #{
unique = True;
};
}
table Posts {
id: UUID #{ primary = True; };
author_id: UUID #{
references = Users.id;
};
}
relation UserPosts {
from = Users.id;
to = Posts.author_id;
cardinality = :one_to_many;
}
}
let active_users = query() {
from user in AppDb.Users,
where user.active,
select user;
};
let rows = with tx = AppDb.transaction() {
active_users(tx)
};
resource PlanetScale.Database AppDatabase {
schema = AppDb;
organization = "teej";
name = "ship-app";
kind = :postgres;
region = "us-east";
cluster_size = "PS_10";
adopt = True;
}
Resources are declarative graph nodes for ship resources — not runtime expressions. PlanetScale layers over ordinary database AppDb(Postgres). Details: Databases.
Effects and services
effect service Email {
send_reset: pure fn(email: EmailAddress, token: ResetToken) -> Unit,
exn [ :smtp(SmtpError) ];
}
let service RecordingEmail: Email = {
type t = {
sent: Vector(EmailAddress);
}
init = fn() -> t {
return { sent = [] };
};
drop = fn(self: t) -> Unit {
};
send_reset = fn(self: t, email: EmailAddress, token: ResetToken) -> Unit {
RecordingEmail.record(self, email, token);
};
};
let forgot_password = fn(email: EmailAddress) -> Unit {
Email.send_reset(user.email, token); // adds Email to inferred eff
};
Kinds: effect service / control / command. One provide { ... } { body } discharges interpreters. Full model: 10-services.md.
Open variants
Closed variants are the default. Open tags: :ready, :error(String).
| Form | Meaning | |
|---|---|---|
| `[ :a \ | :b ]` | Exact finite tag set |
| `[> :tag \ | ... ]` | Lower bound |
| `[< :tag \ | ... ]` | Upper bound |
| `[ Alias \ | :new(T) ]` | Extension |
[ Alias - :tag ] | Subtraction (row-subtraction) |
type Timed(open 'r) = [ 'r | :timeout(Duration) ]
let as_timed = fn('r)(status: 'r) -> Timed('r) {
return status;
};
Exception channels: fn(...) -> T, exn [ DecodeErrors | :timeout(Duration) ]. Inference: 02-type-inference.md. Open rows: lexicon/open-variant.md.
Structural exceptions
exception ParseFailure = [
| :invalid_token(Token)
| :unexpected_eof
]
exception TimedFailure(open 'errors) = [
| 'errors
| :timeout(Duration)
]
exception names a structural exception row, not a nominal identity. Rows are exact unless an explicit open binder extends them. Equal tag and payload shapes are equal across declarations; union deduplicates equal tags and rejects conflicting payloads.
Generics
Binders on the type or function name. Lowercase ticks ('a, 'row) are ordinary type params; uppercase ('A) are behavior-pack variables.
type Option('a) =
| Some('a)
| None
module Pair {
type t('a, 'b) = {
first: 'a;
second: 'b;
}
}
let map = fn('a, 'b)(value: Option('a), ~f: fn('a) -> 'b) -> Option('b) ...
let sort = fn('a: Compare)(items: Vector('a)) -> Vector('a) ...
let log_and_sort = fn('a: Compare + Inspect)(
items: Vector('a),
) -> Vector('a) ...
let convert = fn(
'target,
'source: Convert('target),
'output = Convert.Output('source, 'target),
)(value: 'source) -> 'output ...
Absence is
Option— no ordinarynullin Ship source; host nullables are explicit interop types only (decisions).Declaration:
'abinders. Application: concrete types only —Option(User).Contextual scopes:
scope 'scope(not lifetimes) —fn(scope 'scope)(code: Code(Int, 'scope)) -> Code(Int, 'scope).:requires canonical behavior evidence.+joins requirements on one binder.=defines a directional compile-time name; definitions are acyclic and may reference only earlier binders.Function constraints live only in this initial binder list. Non-function module/type declarations retain their independently defined
whereforms.Open-variant params may use
open 'ron the binder.Behavior module declarations, implementations, parameters, adapters, and witness-bearing types are executable in Behavior Modules and Witnesses.
Measured Numerics
Units are compile-time metadata on numeric primitives (erase at runtime). Measured forms use angle brackets, not ordinary generics:
unit m;
unit s;
unit km {
dimension = m;
scale = 1000.0;
symbol = "km";
}
let distance = 3000.0<m>;
let elapsed = 120.0<s>;
let speed = distance / elapsed; // Float<m / s>
module behavior Unit(km, m) {
convert = fn(value: Float<km>) -> Float<m> {
return value / 1.0<km> * 1000.0<m>;
};
}
let meters = Unit(km, m).convert(3.0<km>);
Rules: no whitespace between number and <; unit atoms are a dedicated lowercase namespace; +/-/== need matching units; *// compose units. Rejected: wrapper newtypes for every measure; method constructors like 5.seconds(). Prefer Duration at public time boundaries (11-time.md).
Modes
Single @ marker on the value slot to its left (no OxCaml @@):
// Type / contract spellings (not standalone expressions):
// fn(file: File @ local) -> Bytes
// fn() -> Buffer @ local
// fn(f: (fn(item: Item @ local) -> Unit) @ once) -> Unit
// fn(w: Worker, buf: Builder @ unique sendable) -> Unit
type ParseFrame = {
source: SourceFile @ global;
scratch: ScratchBuffer;
}
type Cache('a @ global) = { value: 'a; }
type Job = | Now((fn() -> Unit) @ spawnable)
abstract type t @ +unique +sendable;
let push: pure fn!(b: Builder @ unique, s: String) -> Builder @ unique;
@ binds tighter than ->. One @ per slot. Multiple modes are space-separated in canonical order: locality, uniqueness, multiplicity, sendability. Aliases: spawnable = once sendable, transferable = unique sendable. Calls never write modes. Vocabulary and checking: 16-modes.md.
Function channels and contracts
Function definitions, labels, defaults, recursion, pipes, and partial application live in the executable Functions chapter. This section records the contract and channel syntax.
Channels follow the output as comma clauses. A singleton type expression is naked (exn ParseFailure, eff Database). Structural tags, unions, bounds, and row operations use brackets (exn [ :bad(String) ], eff [ Database | Auth ]). Repeated clauses are valid additive contributions. Canonical formatting combines exact additive contributions into one exn row and one eff row, while preserving nontrivial bounds or subtraction separately. fn! seals exn = []; pure fn seals eff = []; pure fn! seals both. Omitted axes infer independently in .shp.
fn(...) -> Output // infer exn and eff
fn!(...) -> Output // empty exn; infer eff
pure fn(...) -> Output // infer exn; empty eff
pure fn!(...) -> Output // empty exn and eff
fn(...) -> Response, exn [ DecodeFailure | HttpFailure ], eff [ HttpClient | Logger ]
Function := Pure? Fn Bang? GenericBinders? Parameters Arrow Output ChannelClause*
ChannelClause := Comma Exn (ChannelTypeExpr | LBracket ExceptionRow RBracket)
| Comma Eff (ChannelTypeExpr | LBracket EffectRow RBracket)
ChannelTypeExpr := TypeName TypeArguments? | TypeVariable Projection?
CallbackType := (Latent | Ignored)? Function FunctionMode*
ExceptionDecl := Exception TypeName GenericBinders? Eq OpenVariantRow
Every function type is one Function descriptor with binders, parameters, modality, and modes, embedding Call { ret, exn, eff }. Embedding promotes function.ret, function.exn, function.eff, and function.call.
Function-typed parameters also declare channel flow:
A bare callback is relative
Currentflow. Its channels join the current call expression through the ambientChannelMode; it cannot escape.ignoredselects empty flow. The implementation may not invoke, retain, return, spawn, or forward it.An escaping mode such as
@ globalwithout another marker permits both current invocation and latent retention (CurrentAndLatent).latentplus an escaping mode permits latent flow only, forbids current invocation, and requires explicit absoluteexn/effchannels.@ onceand@ requiredrefine invocation count independently of flow.latentandignoredare valid only on function-typed parameters/slots; contradictory escape or multiplicity modifiers are errors.
Contracts (.shc)
// TodoHandler.shc — contracts spell channels completely
let handle_create: fn(req: Request) -> Response,
exn [ :decode(Json.DecodeError) | :db(DbError) ],
eff Db;
let health: pure fn!() -> Unit;
// stdlib provider (blessed contracts only)
let get: pure fn!('a)(items: Array('a), index: Int) -> 'a = #builtin(
id = "array_get",
runtime = %{ Native; Browser },
comptime = Pure,
);
#infer type t;
// Designed: `#infer let name;` takes a value contract from the companion .shp
// (parser is type-only for now — see parse-contract-infer golden).
Contract headers choose both axes: fn! or one-or-more exn clauses, and pure fn or one-or-more eff clauses. Bare fn(...) -> T in a contract is an error. #infer type Name; takes the representation from the companion .shp. #infer let name; takes the complete value contract from the implementation and gives up body-free shape checking. Providers: 06-runtime-and-implementation.md.
In .shp, prefer inference when .shc already states the public type, including parameter types. Construct products directly — do not wrap trivial constructors.
Representation Cast Intrinsic
let to_string = (uuid: UUID) -> String => #builtin.magic(uuid);
#builtin.magic(value) is the explicit unchecked cast from the value's inferred type to the surrounding expected type. It emits no runtime operation: the value and representation are unchanged. With no expected type, its result remains an unconstrained type variable, like OCaml's Obj.magic.
Use it only where the implementation owns and has proved representation identity (typically a narrow stdlib abstraction boundary). It is not conversion, validation, boxing, or a substitute for a behavior/provider implementation. Misuse breaks type safety by design.
Contextual Code Directives
let fallback = #code(error :unauthorized);
let expanded = #insert(transform(fallback));
let user_schema = Json.schema(Type.of(User));
#code(expr)quotes without executing (bindings, control targets, channels).#insert(code)evaluates at check time, inserts, and rechecks.With an expected
Meta.Expr,#code(...)is a deriver template instead:#insert(...)splicesMeta.Exprvalues, and the template is checked with the generated module.Type.of(T)isT's compile-timeMeta.Type, baked in where used;Tmust be a statically named type.Ordinary
fn(Code(A, 'scope)) -> Code(B, 'scope)— no#expand fn.Codeerases before runtime IR; initially expression-only and opaque.Scope extrusion across new function/async/handler delimiters is a compile error.
Typed holes (todo)
let charge = fn(invoice: Invoice) -> Receipt,
exn [ :declined(DeclineReason) ],
eff Stripe
{
todo "wire up billing";
};
let cancel = fn!(invoice: Invoice) -> Unit, eff Stripe {
todo;
};
Typechecks at any type; reaching one is a fatal runtime defect. Builds remain available for incomplete programs. ship check --todos <path> lists every checked hole and its optional message. todo-only bodies contribute no inferred channels — shape must come from .shc or an inline annotation.
Never is the uninhabited bottom type of todo and control exits. It may coerce outward to any expected type because execution cannot produce a value; ordinary values never coerce to or unify with Never.
Bytes, Chars, And Strings
Full model: 23-string-and-bytes.md.
Bytes is the immutable wire buffer. Bytes.get returns Byte (0–255), not Int. Char is one Unicode scalar (c'é'). String is valid Unicode scalars. b"…" is a Bytes literal (no interpolation). Expression ++ concatenates String only. Prefix patterns invert concat:
let kind = match Bytes.get(bytes, offset) {
| '{' -> "object"
| '[' -> "array"
| '"' -> "string"
| '0'..'9' -> "digit"
| '\x00' -> "nul"
| _ -> "other"
};
match input {
| b"GET " ++ rest -> Get(rest)
| b"POST " ++ rest -> Post(rest)
| _ -> Unknown
}
match text {
| "Hello, " ++ name -> String.of_slice(name)
| c'a'..c'z' ++ _ -> :letter
| _ -> :other
}
Escapes: \n, \r, \t, \\, \', \", \0, \xNN. Char literals also accept \u{…}. Closing quote distinguishes 'a' (byte) from 'a (type var). c'…' must contain exactly one scalar.
Metadata Blocks
Schema-bearing type declarations carry field facts in #{ ... }:
type t = {
email: Email;
password: String #{
visibility = Secret;
ensure = fn(value) {
if String.char_count(value) < 10 {
error :password_too_short;
}
};
};
invite_code: Option(InviteCode) #{
default = None;
Wire = {
name = "invite_code";
};
};
}
Rules:
Entries are
name = expr;— no bare punning (skip;).default = expr;typechecks against the field; comptime-evaluable defaults runensureat check time when present.visibility = Secret;/Hidden;— redaction policy (not sync topics; sync usestopic = ...— 19-sync.md).Wire = { name = "..."; };— default encoded name;namestring literal;skipisTrue/False.ensure = fn(value) { ... };— field or whole-product validation; failure viaerror; runs on construction and{ with }updates.ensure! = fn(value) { ... };— the same validation, required to be compile-time compatible through every helper it reaches; a constant input that cannot be validated while checking is a compile error. At most oneensureorensure!per declaration.Field with metadata closes with
};.
Protocol metadata
Display, Inspect, and Wire attach after a type head, variant case, or field:
type UserId #{
Display.with = (value) => "user:" ++ value.value;
} = {
value: String;
}
type State =
| Waiting #{ Display.name = "Waiting for input"; }
| Failed {
code: Int;
message: String #{
Inspect.with = redact_message;
};
}
type Row = {
created_at: Instant #{
Display.name = "created"; // dotted = one field of Display = { ... }
Display.with = Instant.to_rfc3339;
};
external_id: UserId #{
Wire = {
name = "externalId";
via = UserIdWire; // Wire.Codec(T) module
};
};
}
type SignupForm = {
password: String;
confirm_password: String #{
Wire = { name = "confirm_password"; };
};
#{
ensure = fn(form) {
if form.password != form.confirm_password {
error :passwords_do_not_match;
}
};
};
}
Dotted paths are exactly two segments. Type-level protocol metadata needs a concrete product or closed variant. Unknown metadata entries are hard errors.
Syntax library
Syntax is the read side of the parse tree: compiler-versioned, unstable, generated from the Rust AST. Meta.Expr stays the write side for derivers. Do not conflate them. The parser and formatter stay Rust; Ship programs call them through Native builtins and generated walkers.
let parsed = Syntax.parse("let answer = 42;\n");
let item = Vector.get(parsed.root.items, 0);
print(Syntax.format_item(item));
Syntax.walk_module(parsed.root, fn(node) {
match node {
| Syntax.Node.Item(item) -> match item {
| Syntax.Item.Let(_) -> ()
| _ -> ()
}
| _ -> ()
}
});
| Path | Role |
|---|---|
Syntax.parse | Native #builtin syntax_parse. Returns { root; trivia; diagnostics }. root is the module tree (module is a reserved name). |
Syntax.format_item | Native #builtin syntax_format_item. Pretty-print one item from the same parse. |
Syntax.walk_module / walk_item / walk_stmt / walk_expr | Generated exhaustive pre-order walk (not a builtin). Visit the node, then children. New AST nodes fail ship check until walk is regenerated. |
Walk is public stdlib, the same class as parse/format. apps/ship-site uses it to weave //! chapters; later tools should too. There is no JS/WASM parse in this library: Native is the host for bin/ programs.
Contracts live under ship/stdlib/Syntax/ (init.shc / init.shp for parse, format, and walk; nested *.shc for tree types). Building the compiler automatically assembles the matching external Core artifact.
Related Docs
02-type-inference.md — HM inference,
.shc, public shapesbook/003-Functions.shp — define, call, labels, pipes, partial application
book/004-PatternMatching.shp — matching, exhaustiveness, guards, extractors
Databases — databases, tables, queries
06-runtime-and-implementation.md —
#builtin, stdlib08-errors.md — error channel,
error, noResultADT10-services.md — effects, services,
provide11-behavior-modules.md — behavior interfaces / impls
11-time.md —
Instant/DurationCollections — vectors, arrays, maps, sets, editors
14-browser-programs.md — programs, commands, subscriptions
14-resource-cleanup.md —
defer,with,Scope15-modules.md — signatures, functors, sealing
16-modes.md —
@modes18-http.md — portable HTTP / clients
19-sync.md — sync / live queries
20-css.md — CSS expressions
22-source-style.md — project taste (
function, inferred params, wire, naming)decisions.md — settled rejections
Lexicon: open-variant, row-extension, row-subtraction