Chapter 5: Contract System
The previous paragraph was about ‘value reuse’. **A contract is a “shape constraint” - it defines “what fields a block should have, what types they should have, whether they are required, how many default values they should have, and the range of values”, and verifies them during parsing, rather than waiting for you to run the program to discover the problem.
Applicable scenario: When using SML for application configuration, the contract is your schema. Correction of incorrect field names, omission of required fields, and filling in port numbers beyond the specified range - an error message will be generated during parsing, and you will be informed of the accuracy to the line or column.
Availability: contracts run on Rust (the reference implementation), JS, C, C++ and Lua (Lua support was added recently), and the same contract violation raises the same error code on all of them — see the error code reference. Whatever is not implemented yet fails loudly instead of pretending to pass: Lua, for example, rejects external types (
@type) and pattern types outright, and records why in the implementation.
5.1 Definition of Contract: @contract
@contract ResenderConfig loose {
api_key: str # Required string
port: int default 8080 min 1 max 65535
debug: bool default false
mode: enum(active, disabled) default active
tags: array[str] ? # Optional string array
}
List of field modifiers:
| Modifier | Meaning |
|---|---|
str/int/num/bool |
Field Type |
enum(a, b, c) |
enumeration, the value must be in one of them |
array[T] |
Array, element type T (such as array[int], array[str]) |
? or optional |
Optional fields |
required |
Explicitly required (default is required, optional) |
Default<value> |
Fill in default value when missing (also automatically considered optional) |
Min<number>/Max<number> |
Numerical value range (including endpoints) |
5.1.1 Field descriptions and i18n
SML has no separate i18n layer (there is not a single i18n / locale in the implementations) —
it is a “text is data” format: keys, values, contract names and fragment names are all written in
plain Unicode, CJK needs no escaping and no \uXXXX. So the two concerns each have a home:
| What you want | How SML does it |
|---|---|
| A description of what a field means | Put it in the trailing comment of the contract field (port: int default 5432 # filled when missing). Comments are for humans and tools: the editor hover shows it (see chapter 12 §12.9), so the explanation does not have to live only in the source |
| Localized copy (one dataset, Chinese and English versions) | Model the language as data, not as syntax: title: { zh: 登录, en: Sign in }, or split zh.sml / en.sml with @fragment / include and pull in the one you need. Switching language is then a data change, not a parser change |
Why this is not baked into the grammar: a format-level i18n feature (
@label(zh: …)) couples data with translation, while SML is positioned as “a pure data format plus an optional contract layer”. Descriptions are documentation (comments); localization is data (nested blocks or files). Each in its place — the parser never needs to know the “current language”.
5.2 Application Contract: @is
Two ways of writing:
# Style 1: @is at the top level of an anonymous block
@contract Cfg loose { api_key: str port: int default 8080 }
@is Cfg
api_key: re_abc
port: 8080
# Style 2: block-level @is
server prod {
@is Cfg
api_key: re_prod
port: 9090
}
Verification occurs during parsing period: a precise error with location is returned directly in violation of the contract, such as contract: Service — field main.port is greater than the max 65535.
5.2.1 Type-annotation form: @is type(ContractName)
After @is you can also write a “type annotation” form. It is exactly equivalent to @is ContractName — only the contract name is wrapped in type parentheses to emphasize “this is a type constraint”:
@contract Clerk strict {
name: str
phone: str
}
window-one {
@is type(Clerk) # equivalent to @is Clerk
name: Zhang San
phone: "13800138000"
}
Both forms verify identically (missing fields still report “contract field missing”, defaults still fill in). If a contract happens to be named type, then @is type is still resolved as “the contract named type” and is never misinterpreted as an annotation unwrap.
5.2.2 Block-level type annotation: <ContractName> <blockName> { }
If writing a line of @is inside a block still feels unnatural, you can put the contract name in front of the block name, like typing a variable:
@feature enable typed-block # this form must be turned on explicitly
@contract Clerk strict {
name: str
phone: phone-num
}
@type name: phone-num { # type = "shape of a value", contract = "shape of a block"
序列: [ { 字面: "1" } { 名: 后续, 类: 数字, 次: 10 } ]
}
Clerk window-one { # contract name + block name = this block is constrained
name: Zhang San
phone: "13800138000"
}
This is exactly the same shape as the existing bare-block form type [name...] { }. The only difference: when the first word is an already-defined contract name, it is automatically applied as a type constraint, equivalent to writing @is ContractName on the block’s first line. Non-contract words (e.g. server web { }) behave exactly as before, so existing documents are unaffected. This capability is opt-in and requires @feature enable typed-block.
Implementation status, advantages and potential (audited 2026-09-19)
⚠️ Implementation status (audited 2026-09-19; corrected once):
| Implementation | Supports typed-block |
On @feature enable … |
|---|---|---|
| Rust (reference implementation) | ✅ implemented (Feature::TypedBlock in rust/sml-feature/src/lib.rs, gated in rust/sml-parse/src/parser.rs) |
known names accepted; unknown names report E-FEATURE-003 (asserted in rust/tests/error_codes.rs) |
| JS | ✅ implemented (js/sml.mjs: feats.has("typed-block") && feats.has("contract")) |
accepts everything (even unknown names, silently — measured ok = true; js/probe-error-codes.mjs itself records this as “a cross-implementation difference, pending W16”) |
| C / C++ / Lua | ❌ not implemented (no such feature name in their sources) | E-PARSE-005 ("@feature is not a legal directive … legal directives: contract / is / version") — these three do not have the @feature directive at all (measured on C; C++ / Lua share the same code path and wording) |
⚠️ On C / C++ / Lua the form degrades to a plain bare block — measured with C’s dumper:
Metrics metrics { latency: 12.5 customCounter: 7 } dumps with extra __type: Metrics and
__name: metrics keys ⇒ the contract is neither validated nor default-filled, and no error is raised
(a silent divergence, the most dangerous kind).
So: in documents shared across implementations prefer @is ContractName (all five parse it and agree
on semantics); if you use the annotation form, be aware it is currently a Rust + JS capability.
⚠️ A larger finding that came with this audit: the
@feature enable/disable …gating mechanism itself currently exists only on Rust + JS — C / C++ / Lua treat@feature …as an illegal directive (E-PARSE-005). In other words, chapter 10’s “trim capabilities with@feature” narrative does not apply to those three (their feature set is fixed). This is registered as an item to verify underTODO.md→ “cross-implementation consistency suite”.
📌 This section was wrong once: the first version said “JavaScript only”. The mistake was grepping only
rust/src(that is the C-ABI crate) while the parser lives inrust/sml-parse/. Lesson: to check “does implementation X support this”, grep X’s whole source tree — never infer from a directory name (rust/src≠ all of Rust).
Advantages (why this design is worth keeping):
- Zero new syntax: identical in shape to the existing bare block
type [name] { }— no new tokens, no breakage of existing documents; the only switch is the semantic condition “is the first word an already-defined contract name?”. Zero syntax growth is rare and precious in a format language. - Type-first, readable in place:
Metrics metrics { }tells you the shape at a glance, without hunting for the@isline inside the block — the value grows with block size. - Naturally exploitable by tooling: the contract name becomes a structured position rather than
a string, which is what lets an editor do something with it — the VS Code extension in this repo
already delivers: semantic highlighting of the contract name (
findAnnotatedBlocks), hover showing path + applied contract + the contract-filled structure, and navigation fromMetricsto@contract Metrics. - Convenient for generators: when emitting SML programmatically, “type then name” is harder to get wrong than “insert a directive inside the block”.
Potential (not built yet, but the road is paved):
- Schema export: scan
@contractplus block-level annotations and emit JSON Schema / TypeScript types (the contract is already available at parse time; only an exporter is missing). - Cross-file contract libraries: together with
include/ namespaces, shapes could be shared across repositories instead of living in one file. - Migration help: infer draft
@contractblocks from existing data when porting JSON/YAML (smltools --from jsonis a start). - Deeper editor semantics: block-level annotation plus contract composition is enough to answer “which field is this block missing” and “which contract does this field come from” incrementally (the extension currently provides hover and navigation, not field-level diagnostics).
Prerequisite for that potential: Rust and JS already have it (and Rust is the reference
implementation, so the semantic baseline exists); what actually blocks the potential is that
C / C++ / Lua do not — so as long as your documents must flow across all five, this form cannot yet
be treated as general syntax (the cross-implementation consistency list lives in TODO.md).
Parentheses are ordinary characters
In SML, ( ) are ordinary characters, not syntax symbols. So a bare-word value can contain parentheses directly, without quotes:
note: (important) # the value is the string "(important)", not lost
priority: (P0) urgent
Contract enums are also written with parentheses: enum(public, internal, confidential), enum(active, disabled).
Note: an older JS engine version treated parentheses as separators and silently truncated values (e.g.
(important)becamenull); this is now fixed. The Rust engine was always correct. Cross-checking with both engines is the safest.
5.3 Strict vs Loose
-Default Strict (Nothing written after the contract name): Prohibit any undeclared fields, spelling errors prot will be immediately detected.
-loose: Allow fields that are not declared in the contract, suitable for evolving configurations.
@contract Metrics loose { latency: num min 0 }
loose only relaxes the requirement for “undeclared fields”, while fields that have been declared will still be checked for type/interval/required fields.
5.4 Combination Contract (Recursive Reference)
Contracts do not share fields, but rather ’the type of field is another contract’ - simply fill in the contract name without introducing new syntax:
@contract Endpoint { host: str port: int }
@contract Service {
name: str
main: Endpoint # Quoting another contract
peers: array[Endpoint] # Contract array
}
@is Service
name: gateway
main: { host: localhost port: 8080 }
peers: [ { host: a port: 1 } { host: b port: 2 } ]
Nested blocks will recursively verify and backfill with default values. The referenced contract is allowed to be defined after @is (the reference is only resolved when @is).
5.4.1 Mixin: applying multiple contracts to one block
The previous section’s combination is “a field’s type is another contract” — it changes the shape (adds a nested block). A mixin merges the fields of several contracts onto the same block — it does not change the shape; it only makes the block satisfy several sets of constraints at once. The three forms are exactly equivalent:
@contract HasHost { host: str }
@contract HasPort { port: int default 80 }
# Form 1: comma-separated after @is
api {
@is HasHost, HasPort
host: example.com
}
# Form 2: space-separated after @is (comma optional)
api {
@is HasHost HasPort
host: example.com
}
# Form 3: contract names before the block name (needs @feature enable typed-block)
@feature enable typed-block
HasHost HasPort api {
host: example.com
}
Merge rules (strictly matching the implementation):
- strict wins: if any participating contract is strict (no
loose) the whole block is validated strictly — undeclared fields are rejected. - Fields are unioned by name: the result is independent of writing order (
HasHost HasPortequalsHasPort HasHost). - Conflict is an error: if the same field name has a different type / default / range in two contracts, parsing fails with
E-CONTRACT-016 contract mixin conflict(the message names the field and the two conflicting contracts). A later contract may not override an earlier one — that is exactly why the result is order-independent: if overriding were allowed, the result would depend on order. - Writing several
@islines in one block is equivalent to one multi-contract@is— all take effect (no longer “the later overrides the earlier”).
⚠️ Implementation status: mixin is currently implemented only in Rust (the reference implementation). JS / C / C++ / Lua do not implement it yet — writing the multi-contract form on those ends explicitly errors with
E-CONTRACT-017(“this implementation does not support applying multiple contracts to one block”), and never silently returns a tree. For documents that must flow across all five ends, keep using the single-contract@is ContractName.
Boundary vs. combination (§5.4): combination expresses “this block embeds a constrained sub-structure” via a field that references another contract (one more shape layer); a mixin expresses “this block obeys several rule sets at once” by merging contracts onto one block (shape unchanged). They solve different problems and cannot replace each other.
5.5 Real Example: Resender Email Tool
resenderUsing SML contract for AppConfig persistence:
@contract ResenderConfig loose {
api_key: str
from: str
to: array[str]
subject: str default "Hello"
port: int default 465 min 1 max 65535
tls: bool default true
}
@is ResenderConfig
api_key: re_xxxxxx
from: me@example.com
to: [ alice@example.com bob@example.com ]
subject: Weekly Report
port: 465
tls: true
The Rust version maintains the CONFIG_CONTRACT constant, serializes the configuration back to SML and automatically attaches @is ResenderConfig when saving, and verifies it when reading - a typical usage of “contract is schema”.
5.6 Give it a try with your hands
Add a contract to your game server cluster (Chapter 3):
@contract Server strict {
name: str
port: int min 1024 max 65535
region: str
}
@common {
region: ap-east-1
max_players: 64
}
lobby {
@is Server
&common
port: 25565
name: 大厅
}
Try writing port: 80 (less than 1024) into it and see if the parser reports an error.
→ Chapter 6: Environment Variables and Escaping
Hands on practice
After reading this chapter, directly modify SML in the editor below and click “Run” to immediately see the parsing results or validation errors - having output is necessary for efficient learning.