Overview
ilk is a data modeling language it can be used to design your system and validate this design is sound, especially at the data flow level.
A .ilk file contains both :
- meta declarations : the abstract vocabulary of a domain (which concepts exist, what shape they have, what constraints apply)
- instance bindings : the concrete entities that exist in a specific domain (which named events, commands, tags, etc.).
It does not hold runtime values like actual UUIDs or timestamps.
Think of it as a catalog: types define what an Event is in the abstract; instance bindings say "in my system, the specific events are userRegistered and orderPlaced."
Comments
Single-line comments only, using //:
// this is a comment
userIdTag = Tag {userId String} // inline commentBase types
| Token | Description |
|---|---|
* | Wildcard — matches any type. Usable as a field meta or in struct cardinality notation. |
Bool | Boolean |
Int | Integer |
Float | Floating-point number |
String | UTF-8 string |
Uuid | UUID value |
Date | Calendar date |
Timestamp | Point in time |
Money | Monetary amount |
* can be used as a field meta (any concrete meta or value is accepted) or in struct cardinality notation like {_} (shorthand for {_ *}).
Meta declarations
Meta declarations define named types. The meta keyword introduces a declaration:
meta Name = TypeExprType names start with a capital letter.
Declarations may be annotated : annotations appear on the line immediately before the declaration they annotate.
Instance bindings
A binding assigns a name to a typed instance:
name = TypeName bodyBindings are:
- Top-level only — not nested inside other constructs
- Unordered — order does not matter for validation
- Unique — each name may be declared at most once
Names follow standard identifier rules and may start with lowercase or uppercase.
userIdTag = Parametrized {userId String}
simpleTag = Unique "simple-tag"
userRegistered = Event<userIdTag> {
id String
name String
}Value constraint levels
Three forms express how tightly a field's value is constrained:
| Form | Constraint | Meaning |
|---|---|---|
String, Int, … | Open | Instance must accept any value of that meta |
Concrete<String>, Concrete<Int>, … | Instance-fixed | Instance declares one specific value; the meta does not prescribe which |
"hello", 42, true, … | Type-fixed | Only this exact value is valid |
Types must match exactly. Instances must use the same meta as declared — no subtyping
Future consideration: Variance annotations (
+Tcovariant,-Tcontravariant) could allow controlled narrowing/widening of constraint levels. Currently all levels are invariant.
Struct types
Structs have named fields.
Fields declaration
Fields are separated by newlines or commas inline:
{
id Uuid
name String
}
{ id Uuid, name String }Declaration
The anonymous-field shorthand uses _ as a placeholder for "a field of any name":
Optional, Required and Additional fields
Struct Intersection
A & B produces a meta whose instances must satisfy both A and B. All fields from both sides are merged into a single struct.
NB : Reference types (&T) cannot participate in intersections.
Union types
A | B means a value must satisfy exactly one of the alternatives.
Litteral meta branches
Identifier-only variants
Named types with empty bodies need no body in instances:
Anonymous struct branches
The branch is matched structurally:
Discriminated unions
For named-meta branches, every union is discriminated by name. When two branches have the same shape, the name distinguishes them:
List types
| Syntax | Meaning |
|---|---|
[]T | 0+ elements |
[N]T | exactly N elements |
[N..]T | N+ elements |
[N..M]T | N to M elements (inclusive) |
[..M]T | 0 to M elements |
[]Event // zero or more Event values
[3]Tag // exactly 3 Tag values
[1..]Tag // at least 1 Tag
[2..5]Tag // 2 to 5 Tags
[..10]Tag // up to 10 TagsList values in instances are separated by commas (or newlines):
[userRegistered, other]
[
userRegistered
other
]>>> DRAFT DOCUMENTATION BELOW
Reference types (add to advanced topic, after @source)
&T — a reference to a binding of meta T.
Reference types point to an existing binding without instantiating it or flowing data through it. The validator checks that the referenced binding exists and is of the correct type.
The main purpose of reference meta is to be able to use them in overall validation without them participating in the data flow.
&Event // reference to an Event binding
[]&Event // list of references to Event bindingsValidation rules:
- The instance value must be an unquoted binding name
- The binding must exist in the file
- The binding must be of meta
T(or a subtype) - No data flows through references —
@sourcechecks do not apply
Refinable meta references
-T — a refinable reference to a binding of meta T. The - prefix signals that the instance may refine the binding with concrete values using & { ... } syntax.
meta Scenario = {
name Concrete<String>
given []-Event // list of refinable Event references
}In instance bindings, a refinable reference may be refined:
scenarios [
{
name "happy path"
given [userRegistered & {id "123"}, userRegistered]
}
]Without the - prefix, providing concrete values in a refinement is an error. With -, the validator allows concrete literals for open fields in the refinement struct.
Subtyping
Type compatibility in ilk follows structural subtyping with the following rules:
Struct subtyping
Closed structs require exact field match — no width subtyping:
{x Int} // requires exactly {x Int}, no extra fields
{...} // accepts any struct (zero or more fields)
{...} & {x Int} // accepts any struct with at least {x Int}Width subtyping is only available via the open struct pattern ({...} & {...}).
List subtyping (covariant)
Lists are covariant in their element type:
[]Event // accepts list of Event or Event subtypesReference subtyping (covariant)
References are covariant — &S is a submeta of &T when S is a submeta of T:
&Event // accepts reference to Event or Event subtypeAnnotations
Annotations appear on the line immediately before the declaration they annotate.
| Annotation | Valid target | Meaning |
|---|---|---|
@main | instance binding | Entry point — the file is validated starting from this instance |
@source [S, …] | field / list decl | Values must originate from one of the named source fields |
@constraint <expr> | meta body | Boolean predicate that must hold for every instance |
@doc "..." | declaration / field | Implementation hint preserved in AST; not stripped during parsing |
@main
Exactly one instance binding per .ilk file may be marked @main. Validation starts from this instance.
@main
board = Board {
commands [registerUser]
}@source
@source [S, …] on a declaration means every value in that construct must be traceable to one of the named source fields. Multiple sources may be listed, comma-separated.
Dot-path sources: Source entries may be dot-separated paths to reach nested fields:
@source [db.returns] // fields must trace to db.returns.*
body {...}Source paths are resolved from the enclosing meta root, not relative to the annotation's position.
The validator resolves each field in an instance struct in priority order:
Concrete<T>value or type-fixed literal — exempt (author-chosen, not runtime data)Type*— exempt (generated)Type = path/Type = compute(paths)— explicit origin; path root must be in the source list- No origin form — implicit; matched by structural name against the source fields (one level deep)
On a list declaration — each element's fields are checked against the sources.
On a plain struct field — the field's own struct element is checked directly: every sub-field of that struct must be traceable to the named sources.
Reference types (&T) are exempt — references point to bindings rather than instantiating them, so no data flows and @source validation does not apply.
meta Command = {
fields {...}
@source [fields]
emits []Event // each Event element's fields must trace to Command.fields
@source [fields]
summary {...} // summary struct's own fields must trace to Command.fields
query []QueryItem // no @source — no provenance constraint
}Inline binding refinements
When @source is in effect on a list, a list element may be written as a binding reference followed by & { ... } — mirroring intersection syntax. The struct body supplies origin annotations for specific fields of the referenced binding:
emits [userRegistered & {
timestamp Int* // Generated — exempt from source check
id String // implicit: matched by name to fields.id
}]Rules:
- The struct body contains origin-annotated fields (
Type*,Type = path,Type = compute(...)), or fields with no annotation (implicit name-match). - Fields not mentioned fall back to implicit name-matching against the source.
- The refinement may not name fields that do not exist in the binding's declared type.
- This syntax is only valid within
@source-constrained list declarations.
Subtyping rules for @source
Direct field mapping (implicit or explicit = path) requires the source meta to be a subtype of the target type. Narrowing mappings require compute().
| Mapping | Syntax | Type rule | Example |
|---|---|---|---|
| Author-chosen | field "hello" / Concrete<T> value | n/a | no source check |
| Generated | field Type* | n/a | no source check |
| Direct (implicit) | field Type | source ≤ target | Uuid → String ✓ |
| Direct (explicit) | field Type = path | source ≤ target | Uuid → String ✓ |
| Narrowing | field Type = compute(...) | any (runtime) | String → Uuid ✓ |
// OK: fields.id (Uuid) can map to Event.id (String) — Uuid <: String
meta Command = {
fields {id Uuid}
@source [fields]
emits []Event
}
// ERROR: fields.id (String) cannot narrow to Event.id (Uuid) — String </: Uuid
meta Command = {
fields {id String}
@source [fields]
emits []Event // Event.id is Uuid — fails, needs compute()
}
// OK: narrowing via compute() — runtime validation
meta Command = {
fields {id String}
@source [fields]
emits []Event & {
id Uuid = compute(fields.id) // explicit narrowing
}
}@constraint
An inline boolean predicate that every instance of the enclosing meta must satisfy. Uses the constraint expression language (see Constraint expression language).
@doc
@doc "..." attaches documentation to the following declaration or field. Unlike // comments which are stripped during parsing, @doc annotations are preserved in the AST and emitted by tooling.
@doc "multiply qty * unitPrice"
totalAmount Int = compute(fields.qty, fields.unitPrice)
@doc "generate UUID v4 at runtime"
correlationId Uuid*Use @doc to provide implementation hints — transformation semantics, generation strategy, domain context for AI or human implementers. Multiple @doc annotations on the same element concatenate.
Field origins
When @source is in effect on a declaration, each field in an instance struct must be provably traceable to the listed sources. Three origin annotations override default implicit resolution:
| Form | Meaning | ||| | fieldName Type* | Generated — value is auto-produced at runtime; provenance not checked | | fieldName Type = path | Mapped — value copied from a dot-path in a source field | | fieldName Type = compute(path, ...) | Computed — derived from multiple source fields |
Generated (Type*)
timestamp Int*The field value is auto-produced at runtime. Provenance is not checked even when @source is in effect.
Mapped (Type = path)
customerId Uuid = fields.userId
nestedId Uuid = fields.user.address.idThe value is copied from a source field identified by a dot-path walked from the enclosing type. The root segment must be one of the sources named in @source.
Computed (Type = compute(path, ...))
amount Int = compute(fields.quantity, fields.unitAmount)The value is derived from multiple source fields. Paths are comma-separated dot-paths. At least one path is required. All path roots must satisfy the same @source constraint as mapped fields. Use compute() for narrowing mappings (e.g. String → Uuid) that require runtime validation.
Struct values
A struct value is a { ... } block of named fields separated by newlines:
{hello Int}
{
hello Int
goodbye String
}Each field is a name value pair. The value is a meta name, a literal, a reference to a binding, or another nested struct/list.
Reference values
When a field has meta &T (reference to T), the instance value is an unquoted binding name:
// type: eventTypes []&Event
eventTypes [cartCreated, itemAdded]The binding must exist in the file and be of meta T. References are not strings — "cartCreated" (quoted) would not satisfy &Event. No data flows through references.
Optional fields
? appended to a field name marks it as optional. The semantics differ between type declarations and instance bindings.
Optional in meta declarations
field? Type in a meta declaration means instances are not required to provide this field:
meta User = {
id Uuid
name String
email? String // instances may omit email
}A missing optional meta field does not cause a validation error. When present, it must match the declared type.
Optional in instance bindings
field? value in an instance binding marks the field as conditionally present at runtime. Downstream @source checks treat an optional source field as unreliable:
fields {
id String
email? String // may be absent at runtime
}Validation rule: A required target field cannot map to an optional source field via @source:
// ERROR: required field relies on optional source
emits [userRegistered & {
email String = fields.email // fields.email is optional
}]
// OK: optional target can map to optional source
emits [userRegistered & {
email? String = fields.email // both optional
}]
// OK: use compute() for explicit handling
emits [userRegistered & {
email String = compute(fields.email) // runtime handles absence
}]Anonymous struct instantiation
When a field or list element has an unambiguous expected meta from the schema, the type name may be omitted and an anonymous struct { ... } supplied directly. Structural typing validates that the struct matches the expected type:
// type: query []QueryItem
// QueryItem meta name omitted — struct matches structurally
query [
{
eventTypes [userRegistered, other]
tags [commonTag]
}
]This is only valid when the expected element meta is a single unambiguous named type (not a union). For union-typed lists, write the branch name explicitly.
Imports
A file may import types from another .ilk file:
import "./base-types.ilk"
import "./common-tags.ilk" as tags // namespaced: tags.SomeTypeAll types in a file are automatically exported — no explicit export annotation needed. Files without a @main instance are pure meta libraries.
Constraint expression language
A minimal expression language for @constraint predicates.
Built-in functions
| Expression | Meaning |
|---|---|
all(col, x => body) | True if body holds for every element x in collection col |
exists(col, x => body) | True if body holds for at least one element x in collection col |
unique(col, x => expr) | True if expr yields distinct values for all elements in col |
count(col) | Number of elements in collection col |
templateVars(str) | Extracts {var} placeholders from a string template as a set of names |
keys(struct) | Returns the set of field names in a struct |
isPresent(field) | True if the optional field is present in the current instance |
Operators
| Operator | Meaning |
|---|---|
&& | Logical and |
|| | Logical or |
! | Logical not |
==, != | Equality, inequality |
in | Set membership (x in set) |
<, <=, >, >= | Numeric comparison |
Examples:
@constraint unique(eventTypes, e => e.name)
@constraint count(eventTypes) >= 1
@constraint count(tags) <= 5User-defined predicates are not currently supported.