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. Literals are written with a decimal point (3.14, -0.5, 1.0) |
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, 3.14, 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
]Typed lists in schema-style instances
When an instance fills an open struct ({...}), its fields declare types rather than supplying values — title String declares a String field. The same list syntax used at the meta level is available here to declare a list-typed field:
meta Event = {
type! Concrete<String>
payload {...}
}
articlePublished = Event {
type "ArticlePublished"
payload {
id Uuid
title String
tags []String // list of String, any length
ratings [2..5]Int // bounds also allowed
authors []Author // named element type
links []{ url String } // struct element type
}
}The element may be a base type, a named type, or a nested struct, and the cardinality forms ([], [N], [N..], [N..M], [..M]) match the meta-level list types. This mirrors scalar type declarations (title String); a bare [a, b] remains a list of values, not a type.
Reference types
&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
Texactly — or, whenTis a union, of one of its variant metas - 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.
Type compatibility
ilk has no general subtyping — types must match exactly. The only forms of flexibility are:
Struct width via open structs
Closed structs require exact field match:
{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}Extra fields are only accepted via the open struct pattern ({...} or {...} & {...}).
Union variant membership
A value or binding of a variant meta satisfies the union it belongs to. This also applies to references: &Event where meta Event = A | B accepts a binding of type A or B.
Lists
Lists check cardinality, then validate each element against the declared element type using the same exact-match rules.
Annotations
Annotations appear on the line immediately before the declaration they annotate.
| Annotation | Valid target | Meaning |
|---|---|---|
@main | instance binding | Entry point — root instance for emitted output (ilk emit) |
@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
At most one instance binding per .ilk file may be marked @main (a second one is an error). It marks the root instance that ilk emit outputs. Validation itself covers every instance in the file, @main or not.
@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)
Implicit matching must be unambiguous: if a field name is found under more than one of the listed sources, validation fails (Ambiguous source) and an explicit = path mapping is required. If it is found under none, validation fails with No source found.
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. The exemption can be overridden per field: putting an explicit @source [ ... ] directly on a reference field (&T or []&T) opts it back in, and each referenced binding's fields are then validated against the listed sources (use cases: event tags, read-model handlers).
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.
- The
binding & { ... }syntax is the same one used for structural refinements (see Refinable meta references); inside an@source-constrained list it additionally carries origin annotations.
Type rules for @source
Direct field mapping (implicit or explicit = path) requires the source and target types to match exactly. Any conversion — widening or narrowing — must go through compute(), which defers the transformation to runtime and skips the type check.
| 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 → Uuid ✓, Uuid → String ✗ |
| Direct (explicit) | field Type = path | source == target | Uuid → Uuid ✓, Uuid → String ✗ |
| Converted | field Type = compute(...) | any (runtime) | String → Uuid ✓ |
// OK: fields.id (Uuid) maps to Event.id (Uuid) — exact match
meta Command = {
fields {id Uuid}
@source [fields]
emits []Event // Event.id is Uuid
}
// ERROR: fields.id (String) cannot map to Event.id (Uuid) — types differ
meta Command = {
fields {id String}
@source [fields]
emits []Event // Event.id is Uuid — fails, needs compute()
}
// OK: conversion via compute() — runtime validation
meta Command = {
fields {id String}
@source [fields]
emits []Event & {
id Uuid = compute(fields.id) // explicit conversion
}
}@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. A declaration may carry several @doc annotations (all are preserved); an instance field accepts a single @doc.
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 any type conversion (e.g. String → Uuid) — direct mappings require exact type equality; compute() defers validation to runtime. In JSON Schema output, computed fields are annotated with x-computed-from listing their source paths (like x-generated for generated fields).
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]
}
]When the expected type is a union, the anonymous struct is matched structurally against each branch and must satisfy at least one. Write the branch name explicitly when two branches share the same shape (unions of named metas are discriminated by name).
Imports
A file may import types from another .ilk file:
import "./base-types.ilk"All metas and instances in a file are automatically exported — no explicit export annotation needed. Files without a @main instance are pure meta libraries. Imports are loaded recursively; circular imports are an error.
Imported names share a flat namespace with the importing file: declaring a name that an import already provides is a duplicate-declaration error, and two imports providing different declarations under the same name is a conflicting-import error (the same declaration reachable through several imports — a diamond — is fine). There is no namespacing: import "..." as alias is a parse error.
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 |
isType(expr, TypeName) | True if expr's value has the shape of TypeName — a base type or a named meta, resolved to its kind (string / int / float / bool / struct / list) |
Operators
| Operator | Meaning |
|---|---|
&& | Logical and |
|| | Logical or |
! | Logical not |
==, != | Equality, inequality. Structs and lists compare deeply (lists are order-sensitive) |
in | Set membership (x in set) |
<, <=, >, >= | Numeric comparison (Int and Float operands may be mixed) |
Examples:
@constraint unique(eventTypes, e => e.name)
@constraint count(eventTypes) >= 1
@constraint count(tags) <= 5User-defined predicates are not currently supported.