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
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, 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…

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

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 (or a subtype)
  • 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.

Subtyping

Type compatibility in ilk follows structural subtyping with the following rules:

Struct subtyping

Closed structs require exact field match — no width subtyping:

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}

Width subtyping is only available via the open struct pattern ({...} & {...}).

List subtyping (covariant)

Lists are covariant in their element type:

ilk
[]Event           // accepts list of Event or Event subtypes

Reference subtyping (covariant)

References are covariant — &S is a submeta of &T when S is a submeta of T:

ilk
&Event            // accepts reference to Event or Event subtype

Annotations

Annotations appear on the line immediately before the declaration they annotate.

AnnotationValid targetMeaning
@maininstance bindingEntry point — the file is validated starting from this instance
@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

Exactly one instance binding per .ilk file may be marked @main. Validation starts from this instance.

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)

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.

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.
  • 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().

MappingSyntaxType ruleExample
Author-chosenfield "hello" / Concrete<T> valuen/ano source check
Generatedfield Type*n/ano source check
Direct (implicit)field Typesource ≤ targetUuidString
Direct (explicit)field Type = pathsource ≤ targetUuidString
Narrowingfield Type = compute(...)any (runtime)StringUuid
ilk
// 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
    }
}
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. 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*)

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 narrowing mappings (e.g. StringUuid) that require runtime validation.

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]
    }
]

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:

ilk
import "./base-types.ilk"
import "./common-tags.ilk" as tags   // namespaced: tags.SomeType

All 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

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

Operators

OperatorMeaning
&&Logical and
||Logical or
!Logical not
==, !=Equality, inequality
inSet membership (x in set)
<, <=, >, >=Numeric comparison

Examples:

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

User-defined predicates are not currently supported.