Skip to content

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 //:

ilk
// this is a comment
userIdTag = Tag {userId String} // inline comment

Base types

TokenDescription
*Wildcard — matches any type. Usable as a field meta or in struct cardinality notation.
BoolBoolean
IntInteger
FloatFloating-point number. Literals are written with a decimal point (3.14, -0.5, 1.0)
StringUTF-8 string
UuidUUID value
DateCalendar date
TimestampPoint in time
MoneyMonetary 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:

ilk
meta Name = TypeExpr

Type 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:

ilk
name = TypeName body

Bindings 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.

ilk
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:

FormConstraintMeaning
String, Int, …OpenInstance must accept any value of that meta
Concrete<String>, Concrete<Int>, …Instance-fixedInstance declares one specific value; the meta does not prescribe which
"hello", 42, 3.14, true, …Type-fixedOnly this exact value is valid
Type
live compiler >
Loading compiler…

Types must match exactly. Instances must use the same meta as declared — no subtyping

Future consideration: Variance annotations (+T covariant, -T contravariant) 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:

ilk
{
    id   Uuid
    name String
}

{ id Uuid, name String }

Declaration

The anonymous-field shorthand uses _ as a placeholder for "a field of any name":

Type
live compiler >
Loading compiler…

Optional, Required and Additional fields

Type
live compiler >
Loading compiler…

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.

Type
live compiler >
Loading compiler…

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

Type
live compiler >
Loading compiler…

Identifier-only variants

Named types with empty bodies need no body in instances:

Type
live compiler >
Loading compiler…

Anonymous struct branches

The branch is matched structurally:

Type
live compiler >
Loading compiler…

Discriminated unions

For named-meta branches, every union is discriminated by name. When two branches have the same shape, the name distinguishes them:

Type
live compiler >
Loading compiler…

List types

SyntaxMeaning
[]T0+ elements
[N]Texactly N elements
[N..]TN+ elements
[N..M]TN to M elements (inclusive)
[..M]T0 to M elements
ilk
[]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 Tags

List values in instances are separated by commas (or newlines):

ilk
[userRegistered, other]

[
    userRegistered
    other
]
Type
live compiler >
Loading compiler…

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:

ilk
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.

ilk
&Event      // reference to an Event binding
[]&Event    // list of references to Event bindings

Validation rules:

  • The instance value must be an unquoted binding name
  • The binding must exist in the file
  • The binding must be of meta T exactly — or, when T is a union, of one of its variant metas
  • No data flows through references — @source checks do not apply
Type
live compiler >
Loading compiler…

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.

ilk
meta Scenario = {
    name  Concrete<String>
    given []-Event    // list of refinable Event references
}

In instance bindings, a refinable reference may be refined:

ilk
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:

ilk
{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.

AnnotationValid targetMeaning
@maininstance bindingEntry point — root instance for emitted output (ilk emit)
@source [S, …]field / list declValues must originate from one of the named source fields
@constraint <expr>meta bodyBoolean predicate that must hold for every instance
@doc "..."declaration / fieldImplementation 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.

ilk
@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:

ilk
@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:

  1. Concrete<T> value or type-fixed literal — exempt (author-chosen, not runtime data)
  2. Type* — exempt (generated)
  3. Type = path / Type = compute(paths) — explicit origin; path root must be in the source list
  4. 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).

ilk
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:

ilk
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.

MappingSyntaxType ruleExample
Author-chosenfield "hello" / Concrete<T> valuen/ano source check
Generatedfield Type*n/ano source check
Direct (implicit)field Typesource == targetUuidUuid ✓, UuidString
Direct (explicit)field Type = pathsource == targetUuidUuid ✓, UuidString
Convertedfield Type = compute(...)any (runtime)StringUuid
ilk
// 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
    }
}
Type
live compiler >
Loading compiler…

@constraint

An inline boolean predicate that every instance of the enclosing meta must satisfy. Uses the constraint expression language (see Constraint expression language).

Type
live compiler >
Loading compiler…

@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.

ilk
@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:

FormMeaning
fieldName Type*Generated — value is auto-produced at runtime; provenance not checked
fieldName Type = pathMapped — value copied from a dot-path in a source field
fieldName Type = compute(path, ...)Computed — derived from multiple source fields

Generated (Type*)

ilk
timestamp Int*

The field value is auto-produced at runtime. Provenance is not checked even when @source is in effect.

Mapped (Type = path)

ilk
customerId Uuid = fields.userId
nestedId   Uuid = fields.user.address.id

The 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, ...))

ilk
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. StringUuid) — 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).

Type
live compiler >
Loading compiler…

Struct values

A struct value is a { ... } block of named fields separated by newlines:

ilk
{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:

ilk
// 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:

ilk
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:

ilk
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:

ilk
// 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
}]
Type
live compiler >
Loading compiler…

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:

ilk
// 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:

ilk
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

ExpressionMeaning
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

OperatorMeaning
&&Logical and
||Logical or
!Logical not
==, !=Equality, inequality. Structs and lists compare deeply (lists are order-sensitive)
inSet membership (x in set)
<, <=, >, >=Numeric comparison (Int and Float operands may be mixed)

Examples:

ilk
@constraint unique(eventTypes, e => e.name)
@constraint count(eventTypes) >= 1
@constraint count(tags) <= 5

User-defined predicates are not currently supported.