Language
Forma is a typed Lisp for executable programs and domain languages. Types, schemas, and form contracts use the same syntax. The reader keeps source identity and spans for diagnostics, formatting, and structural editing.
Values and expressions
The primitive types are Unit, Bool, Int, Float, String, Keyword, and Symbol. Integers can be used where a Float is expected. Strings, keywords, and quoted symbols are distinct values: "ready", :ready, and 'ready do not compare equal. nil has type Unit.
1 is Int; 1.0 and 1e0 are Float even when their value is integral. Number and Num are rejected. Use Int or Float explicitly. +, -, *, min, max, and abs return Int when all operands are Int, otherwise Float. / always returns Float; mod requires Int operands and a nonzero divisor. floor, ceil, and round return Int (round sends ties toward positive infinity). Assignment from Int to Float is permitted without retagging the value. Equality compares numeric values, so (= 2 2.0) is true; numeric literal patterns distinguish their kinds.
Int uses JavaScript's safe range, −9007199254740991 through 9007199254740991. Out-of-range literals and arithmetic results fail instead of wrapping. Float uses IEEE 754, including NaN, infinities, and signed zero; division by zero follows IEEE rules. NaN does not equal itself. Rounding nonfinite or out-of-range values fails. Values print as 2 and 2.0; str spells an integral Float as 2.. Tagged host values retain their kinds; plain JSON erases them and encodes nonfinite values as null. See the numeric design decision for engine and generated TypeScript boundaries.
Parentheses call a function or introduce a special form. Braces always contain records or dictionaries. Square brackets contain lists; their elements share a common type. Semicolons begin comments.
(define greeting "Hello")
(define numbers [1 2 3])
(define person {:name "Ada" :age 36})
(+ person.age 1)Dot access follows lexical bindings: person.age reads the :age field of person. Service members such as Clock.now resolve through their service. get accepts a computed key. Keyword keys and string keys retain their identity.
Definitions and signatures
Use define for both values and functions. Parameters are patterns; signatures are written separately with : and may precede or follow a definition in the same module.
(: increment (-> Int Int))
(define increment [value] (+ value 1))
(define distance [point]
(+ point.x point.y))
(: distance (-> {:x Float :y Float} Float))fn makes an anonymous function. let binds values in lexical scope. if requires a Bool condition, and its branches must have a common type. do evaluates a sequence and returns its final expression. Quote returns syntax without evaluating it; quasiquote supports ~ and ~@ for substitution and splicing.
A keyword literal keeps its exact type: :open has type :open. Branches and collections of different keywords infer their union. Keyword accepts any keyword, including ones constructed at runtime.
Types are schemas
type names a shape. A record type has the same appearance as its values. Fields are required by default. (Option T) means an absent value, represented by None, or a present value, represented by (Some value).
(type OrderId (Brand String))
(type Status (Union :pending :paid :shipped))
(type Order
{:id OrderId :status Status :total-cents Int :coupon (Option String)})
(: example Order)
(define example
{:id (OrderId "order:1") :status :pending :total-cents 1200})In a record checked against an expected type, an omitted optional field becomes None, and a plain supplied value becomes Some. An existing Option stays an Option. Schema and artifact encodings omit absent optional fields.
(Brand T) takes its nominal name from the declaration. Two brands of the same underlying type remain distinct. (List T) describes a homogeneous list. (Map K V) describes a dictionary: keys may be strings, string brands, or a finite union of keyword or string literals. Dictionary lookup returns (Option V).
(: scores (Map String Int))
(define scores {"Ada" 10 "Grace" 12})
(match (get scores "Ada")
(Some score) score
None 0)An open record includes a row variable after &, as in {:name String & row}. Generic aliases name their parameters: (type (Box a) {:value a}).
Ordinary type aliases and signatures can compute closed record shapes:
(type Person {:name String :age Int})
(type Name (Pick Person [:name]))
(type PublicPerson (Omit Person [:age]))
(type Identified (Merge {:id String} PublicPerson))Pick keeps the listed fields; Omit removes them. Their second argument must be a literal vector of distinct keywords, and every listed field must exist. Empty vectors are allowed. Merge combines records with disjoint fields; overlapping names are errors even when their types agree. Aliases and nested operations are supported, and retained field types may be polymorphic. The checker requires known, closed outer record shapes: operations on whole-record variables or open rows report an error. These are type operations; they do not project or merge values or generate Effect schemas. Open-row disjointness constraints are not implemented. For computed record annotations, supply optional fields explicitly as Some or None; implicit wrapping and filling currently require explicit record type syntax.
Unions and tagged values
Union accepts types or literal values and has no discriminator. Tagged declares constructors. Capitalized constructor names distinguish them from lowercase binding patterns.
(type Shape (Tagged (Circle {:radius Float}) (Square {:side Float}) Point))
(: area (-> Shape Float))
(define area [shape]
(match shape
(Circle {:radius radius}) (* 3.14159 radius radius)
(Square {:side side}) (* side side)
Point 0))Constructors belong to their type: Shape.Circle is always explicit. Bare constructors resolve from the expected type when needed. The discriminator is :_tag by default; (Tagged :tag kind ...) selects another field. Record payloads merge into the tagged record; scalar payloads occupy :value. Inline record payloads must be closed and must not declare the discriminator field. A conflicting field or an open payload tail reports a located error.
class and error declare named record types with the same field syntax. Errors have a discriminator derived from their name and may appear in Effect error sets.
(class Customer {:name String})
(error OrderNotFound {:id OrderId})
(error Timeout)Patterns
fn, define, let, match, and catch share pattern conventions. Lowercase names bind, _ ignores a value, literals compare values, and capitalized names match constructors. Records and vectors destructure their members. & captures remaining members or elements, and :as retains the whole matched value.
(define first-name [{:name name}] name)
(let [[first & rest] [1 2 3]] first)
(match (Some 7) (Some value) value None 0)Effects
An Effect type is (Effect Success [Errors] [Requirements]); omitted sets are empty. Definitions with Effect signatures use ordinary define. A zero-argument Effect is a value.
(service Clock (: now (Effect Int)))
(: next (Effect Int [] [Clock.now]))
(define next
(do! [now Clock.now :let [later (+ now 1)]] later))do! runs effects; :let binds pure values within the sequence. A plain tail value is lifted into the enclosing Effect. Services use : members and layers implement members with define. See the Effect reference for resources, concurrency, recovery, and generated TypeScript.
Typed forms
A form is a function from syntax to a declared IR type. Its pattern specifies positional arguments, trailing keyword options, and repeated child forms. Its hole types specify how those arguments parse and check.
(type GreetingIR {:kind "Greeting" :name Symbol :message String :doc (Option String)})
(form (greeting name message {:keys [doc]})
"Declare a greeting."
:types {:name (Declares Greeting) :message String :doc (Option String)}
:ir GreetingIR
{:kind "Greeting" :name name :message message :doc doc})
(greeting welcome "Hello" :doc "Shown on arrival")| Hole type | Meaning |
|---|---|
(Declares T) | Introduces a declaration name of type T |
(Refers T) | Resolves a name to a declaration of type T |
String, Int, Float, Symbol, Keyword | Parses a literal |
Type | Parses a type expression |
(Expr T) | Checks an expression at T |
(Record T) | Supplies record members with type T |
(List child) | Parses child forms and supplies their IR values |
(Option H) | Allows an absent hole |
A pattern ending in child ... accepts zero or more children. A :scope function provides bindings while checking a chosen hole. :check returns diagnostics; :type supplies a result type or computes one from the holes. Projection bodies are ordinary Forma expressions. The declared IR type checks their output and provides the schema used at the artifact boundary.
Domain declarations
Entities use short field names and derive attribute namespaces. References use (Id Entity); an optional entity attribute uses (Option T).
(entity Department {:name String})
(entity Employee
{:name String :department (Id Department) :active (Option Bool)})
(seed Employee "employee:ada"
{:name "Ada" :department "department:engineering"})
(query people :from Employee :where (= name "Ada") :select [name])Inside a query, fields from the :from entity enter the predicate scope. Predicates must have type Bool. Seed and link records check their field names, values, and required fields against the referenced entity or relation.
Structural tools
Forma's formatter and editor share the reader's lossless syntax tree. Diagnostics refer to author spans, including expressions produced through macros.