Style guide
Patterns that make MinLang programs pass ml1 validate on the first try and stay easy to review. The language reference says what is legal; this page says what works.
The singleton screen-flow entity
Multi-screen apps are driven by one small UI-state entity whose enum field has one member per screen:
entity Workspace {
id: string req
view: enum(pipeline, companies)
}
Screen when conditions are evaluated against the first row in id order of that entity — the singleton convention. Navigation is just set-actions:
action OpenCompanies(workspace: ref(Workspace), actor_id: string) {
on: Workspace
set(view, 'companies')
}
screen Companies {
when workspace.view == 'companies'
...
button { label "Back to pipeline" action OpenPipeline }
}
Conventions: name the entity after the app’s container (Workspace, Guestbook, Game), call the field view (or phase for games), and seed exactly one row of it in the app shell (seed.ts) — with zero rows, no screen activates.
The uniqueness rule: D3/D4/D7 interplay
Three detectors interact around uniqueness, and together they force one design decision:
- D3 requires create-time uniqueness to use the proactive boundary
count(Entity as e, e.field == input_field) == 0. - D4 forbids
self.<field>inside that check — you must compare the action input. - D7 rejects any input-bound constraint on an entity that also has
setactions, because the update path would bypass it.
Consequence: uniqueness constraints go only on create-only entities. If an entity needs both uniqueness and mutability, split it: keep the unique identity create-only and hang the mutable state off it via a ref.
# Company is create-only → uniqueness is allowed
constraint UniqueCompanyName {
on: Company
validate: count(Company as c, c.name == name) == 0
message: 'a company with this name already exists'
}
# Deal is mutable (MoveDeal, ReassignDeal) → only self.-bound predicates
constraint DealOwnerRequiredWhenQualified {
on: Deal
validate: NOT(self.stage == 'qualified' AND self.owner == '')
message: 'owner is required when a deal is qualified'
}
On mutable entities, the predicates that satisfy all three detectors are:
self.-bound invalid-state forms:NOT(self.title == ''),NOT(self.status == 'doing' AND self.assignee == '')- whole-reference count joins (next section)
Whole-reference joins
Cross-entity checks are always count(...) joins, and the join compares the whole reference, never an id field:
# good
validate: count(Project as p, p == self.project) == 1
# rejected (D8)
validate: count(Project as p, p.id == self.project) == 1
# rejected (D2 — dot-walking)
validate: self.project.name == 'General'
Use == 1 for “the referenced row exists (with these properties)” and == 0 for “no conflicting row exists”.
Test triad discipline
Write the triad as you write the constraint, not after. For each critical constraint:
- Success — a test where the guarded write goes through. One success test can serve several constraints on the same write path (e.g. one
CreateDealSucceedscovers every create-time rule onDeal). - Failure —
error_raised('<message>'), copied byte for byte from the constraint. Don’t retype it; copy-paste it. - Rejection leaves state unchanged — in the same failure test, assert the post-state:
entity_count(Deal) == 0after a rejected create,entity_has(Deal.1, stage == "lead")after a rejectedset.
If a constraint guards both a create and a set path, give the set path its own failure test — that is exactly the lifecycle leak D7 exists to catch.
One more consequence worth knowing: if you can’t express a failure test for a rule (no literal can violate it), the triad can’t be completed — drop the rule rather than ship it untested.
Naming conventions
| Thing | Convention | Examples |
|---|---|---|
| Entities | PascalCase noun | Company, Deal, Visitor |
| Fields, inputs | snake_case | created_at, owner, view |
| Constraints | PascalCase, SubjectRule | UniqueCompanyName, DealTitleRequired |
| Actions | PascalCase, VerbNoun | CreateDeal, MoveDeal, OpenCompanies |
| Queries | PascalCase, view-shaped | PipelineBoard, CompanyList |
| Screens | PascalCase, place-shaped | Pipeline, Companies |
| Tests | PascalCase, SubjectOutcome | MoveDealRejectsQualifiedWithoutOwner |
| Constraint messages | single-quoted, lowercase, specific | 'owner is required when a deal is qualified' |
| Screen copy | double-quoted sentences | "Every deal belongs to a company." |
Runtime-injected inputs must use the canonical names (created_at, actor_id, current_time, …) — classification is by name, so createdAt would become a form field. The full list is in the language reference.
Small-program shape
A well-shaped MinLang file reads top to bottom in this order:
- The flow entity, then domain entities
- Constraints, grouped by entity
- Actions: creates, then sets, then navigation
- Queries
- Screens (in flow order)
- Theme (if any)
- Tests, grouped by constraint/action
Keep programs small. If the file stops fitting in one review sitting, that’s a sign the app wants splitting — one .ml is one application. Avoid speculative fields and rules: every constraint costs a triad, which is the language nudging you toward only the rules you mean.
Remember the deletion rule
delete Entity clears the whole set. There is no per-row delete, so don’t design flows that need one — model removal as a status change (stage: enum(..., lost), archived: bool) and filter or read accordingly. See the cookbook recipe.