Schema Reference

ODIN Schema 1.0, Type Definitions, Constraints & Validation

Overview

ODIN Schema extends the ODIN notation to describe document structure, types, and constraints. Rather than inventing a separate schema language, ODIN Schema uses ODIN syntax to validate ODIN documents, the notation describes itself.

ODIN Schema inherits ODIN's core principles: self-describing, line-based, flat, diffable, and deterministic. Schema definitions are themselves valid ODIN documents.

Compare ODIN Schema to JSON Schema for the same constraints:

JSON Schema
{
  "type": "object",
  "properties": {
    "vin": { "type": "string", "minLength": 17, "maxLength": 17,
             "pattern": "^[A-HJ-NPR-Z0-9]{17}$" },
    "year": { "type": "integer", "minimum": 1900, "maximum": 2100 },
    "price": { "type": "number", "minimum": 0 }
  },
  "required": ["vin", "year"]
}
ODIN Schema
{vehicle}
vin = !:(17):/^[A-HJ-NPR-Z0-9]{17}$/
year = !##:(1900..2100)
price = #$:(0..)

Design Principles

  1. Self-referential, ODIN syntax describes ODIN structure
  2. Concise, Symbols over keywords
  3. Type-aware, Numeric, boolean, temporal types are distinct
  4. Constraint-driven, Bounds, patterns, enums in-line
  5. Deterministic, Same schema always produces the same canonical output, enabling hashing, digital signatures, and audit trails
  6. Diffable, One field per line with flat paths means schema changes produce meaningful git diffs, not structural noise from shifted braces or indentation
  7. Composable, Reusable type definitions
  8. Familiar, Borrows from programming language conventions
  9. Traceable, Schema derivation documented for provenance

Why Schema in ODIN Syntax?

Most schema languages are separate languages from their data formats. JSON Schema is JSON describing JSON. XML Schema (XSD) is XML describing XML. But they're verbose, complex, and require learning two syntaxes.

ODIN Schema uses ODIN to describe ODIN. A schema file is itself a valid ODIN document. The same parser, the same syntax, the same mental model. This means:

  • One parser, two purposes, Your ODIN parser can read schemas with minimal extension
  • Schemas are diffable, Same line-based, flat structure means meaningful git diffs
  • Schemas compose, Import, chain, and overlay schemas just like data documents
  • Self-validation, The schema schema can validate itself

Why Symbols for Constraints?

Schema definitions are read repeatedly, every time you validate data, every time you review a PR, every time you onboard a new developer. Verbosity compounds. The symbols become second nature: ! means required, ## means integer, :(min..max) means bounds, :/regex/ means pattern.

Inline constraints
email = !:/^[^@]+@[^@]+\.[^@]+$/
age = ##:(0..150)
status = (pending, active, closed)

The field declaration tells you everything: name, type, requirement, bounds, pattern, enum values. When you ask "what are the rules for age?", you want one place to look.

Symbol Reference

SymbolMeaningContext
!RequiredField must be present
?BooleanBoolean type
#NumberNumeric type (any precision)
##IntegerWhole number only
#.NDecimalFixed N decimal places
#$CurrencyMoney (defaults to 2 decimals)
#$.NCurrencyMoney with N decimal places
#%PercentPercentage as decimal (0-1)
@ReferencePath reference to another field
^BinaryBase64-encoded binary data
~Nullable/NullNullable modifier or null type
*RedactedValue should be masked
-DeprecatedField is obsolete
:ConstraintConstraint expression follows
()Enum/BoundsEnumeration or numeric range
[]ArrayArray type definition
|UnionType separator
&IntersectionCombine type definitions
:ifConditionalField depends on another field's value
:unlessInverse ConditionalRequired when condition is FALSE
:invariantInvariantCross-field validation expression
:formatFormatString format validation
:computedComputedDerived value, not in input
:immutableImmutableCannot be changed after creation

Type Definitions

String (Default)

String is the default type when no prefix is specified.

String fields
name = !                           ; required string
description =                      ; optional string
code = !:(3..10)                   ; required, length 3-10
pattern = :/^[A-Z]{3}$/            ; regex pattern
status = (draft, published)        ; enum

Boolean

Use ? for boolean fields.

Boolean fields
active = ?                         ; optional boolean
required = !?                      ; required boolean
enabled = ? true                   ; optional, default true

Numeric Types

TypeSyntaxDescription
Any number#Integer or decimal
Integer##Whole numbers only
Decimal#.NFixed N decimal places
Currency#$Money (2 decimal places)
Currency#$.NMoney with N decimals
Percent#%Percentage as decimal (0-1)
Numeric fields
count = ##                         ; optional integer
quantity = !##                     ; required integer
price = #$                         ; optional currency
total = !#$                        ; required currency
rate = #.4                         ; 4 decimal places
amount = #                         ; any numeric precision
btc_amount = #$.8                  ; bitcoin (8 decimals)
tax_rate = #%                      ; percentage (0.15 = 15%)

Schema vs Data Syntax

Schema DeclarationData Value
rate = #.4rate = #0.0500
btc = #$.8btc = #$1.00000000
count = ##count = ##42
price = #$price = #$99.99
tax = #%tax = #%0.15

Temporal Types

Temporal fields
effective = date                   ; date only (YYYY-MM-DD)
expires = !date                    ; required date
created = timestamp                ; full timestamp (ISO 8601)
start_time = time                  ; time only (THH:MM:SS)
term = duration                    ; ISO 8601 duration (P6M)

Reference & Binary

Reference fields
parent = @                         ; reference to any path
manager = !@                       ; required reference
billing = @addresses               ; reference to addresses array
primary = @contacts[0]             ; reference to first contact
Binary fields
data = ^                           ; any binary
signature = !^                     ; required binary
hash = ^sha256                     ; SHA-256 hash
photo = ^:(..1048576)              ; max 1MB

Union Types

Use | to chain multiple types. |"" adds string, |~ adds null.

Union types
; Basic unions
value = #|""                       ; number or string
id = ##|""                         ; integer or string

; Multi-type unions
flexible = #|?                     ; number or boolean
choice = #|?|""                    ; number or boolean or string
temporal = date|timestamp          ; date or timestamp

; Nullable unions
nullable_num = ~#                  ; nullable number (modifier)
num_or_null = #|~                  ; number or null (union)
tri_state = ?|~                    ; boolean or null
everything = #|?|~|""             ; number or boolean or null or string

Default Values

Default values appear after the type and constraints:

Default values
status = (draft, published) draft        ; enum with default
priority = ##:(1..5) ##3                 ; integer with default
rate = #:(0..1) #0.05                    ; number with default
enabled = ? ?true                        ; boolean with default
country = :(2) US                        ; string with default

Default Value Rules

  1. Type-prefixed, Default value uses same type prefix as field
  2. Within constraints, Default must satisfy field constraints
  3. Optional fields only, Required (!) fields cannot have defaults

Constraints

The colon : introduces constraints.

String Constraints

String constraints
; Length bounds
code = :(3)                        ; exactly 3 characters
code = :(3..)                      ; minimum 3
code = :(..10)                     ; maximum 10
code = :(3..10)                    ; 3 to 10

; Pattern (first char after : is delimiter)
email = :/^[^@]+@[^@]+\.[^@]+$/
url = :|^https?://[^\s]+|         ; pipe delimiter

; Enumeration
status = (pending, active, closed)
tier = (bronze, silver, gold) silver  ; with default

Numeric Constraints

Numeric constraints
age = ##:(0..150)                  ; integer 0-150
quantity = ##:(1..)                ; integer >= 1
discount = #:(0..1)                ; decimal 0-1
price = #$:(0..999999.99)
priority = ##:(1..5) ##3           ; with default

Temporal Constraints

Temporal constraints
birth_date = date:(1900-01-01..2025-12-31)
effective = date:(2024-01-01..)    ; on or after
created = timestamp:(2024-01-01T00:00:00Z..)

Modifiers

Modifier order: field = [!][~][*][-][type][:[constraint]] [default]

Modifiers
; Required
name = !                           ; required string
id = !##                           ; required integer

; Nullable
notes = ~                          ; nullable string
middle = ~:(..50)                  ; nullable, max 50 chars

; Redacted (sensitive data)
ssn = *:/^\d{3}-\d{2}-\d{4}$/      ; redacted, pattern
password = !*                      ; required, redacted

; Deprecated
legacy_id = -                      ; deprecated field

; Combinations
ssn = !*:/^\d{3}-\d{2}-\d{4}$/     ; required + redacted + pattern
tax_id = ~*                        ; nullable + redacted

Arrays

Use [] suffix in header to define array schema.

Array definition
{items[]}
sku = !
name = !
price = !#$
quantity = !##:(1..) ##1

Array Constraints

Array bounds appear as the first line after the header.

Array constraints
{items[]}
:(1..100)                          ; 1 to 100 items
sku = !
name = !

{tags[]}
:(..10)                            ; max 10 tags
value = !

{emails[]}
:unique                            ; no duplicate emails
value = !:/^[^@]+@[^@]+$/

{codes[]}
:(1..10):unique                    ; 1-10 unique codes
value = !

Tabular Schema

For arrays containing flat objects, use tabular column syntax:

Tabular schema
{line_items[] : sku, description, qty, price}
sku = !
description = !:(..200)
qty = !##:(1..)
price = !#$:(0..)

Tabular Schema Rules

  1. Primitives only, All column paths must resolve to primitive types
  2. Single-level nesting, Column names may use one dot or one array index
  3. No multi-level nesting, Paths like a.b.c or a[0].b not allowed
  4. Column order, Column list defines serialization order

Reusable Types

Define reusable types with {@name} syntax.

Type Definition & Usage

Defining reusable types
{@address}
line1 = !:(1..100)
line2 = :(..100)
city = !:(1..50)
state = !:(2)
zip = !:/^\d{5}$/
country = :(2) US

{@money}
amount = !#$:(0..999999.99)
currency = :(3) USD

Reference defined types with @typename:

Using reusable types
{customer}
name = !
billing = @address                 ; uses @address definition
shipping = @address

{order}
total = @money
tax = @money

Type Intersection

Combine multiple type definitions with &:

Type intersection
{@timestamps}
created = !timestamp
updated = timestamp

{@auditable}
created_by = !
updated_by =

; Compose types with &
{user}
= @base_entity & @timestamps & @auditable
email = !:/^[^@]+@[^@]+$/
role = !(admin, user, guest)

Intersection Rules

  • & combines all fields from referenced types
  • Field conflicts (same name, different definition) are errors
  • Order doesn't matter: @a & @b equals @b & @a
  • Can mix with local field definitions

Type Inheritance with Override

Use :override to inherit from a base type while overriding specific field constraints.

Type override
@import "./coverage.schema.odin" as base

{@tx_liability}
= @base.personal_auto_liability :override

{.bi}
per_person = !#$:(30000..)           ; TX minimum $30K
per_accident = !#$:(60000..)         ; TX minimum $60K
AspectAllowed Override
ConstraintsMore restrictive (narrower range)
Requiredoptional to required (not reverse)
NullableRemove nullability (not add)
TypeMust be same base type

Namespaced Custom Types

Use reverse-DNS notation for organization-specific types:

Namespaced types
; Define namespaced types
{@&com.acme.customer_id}
value = !:/^ACME-\d{8}$/

; Use namespaced types
{order}
customer = @&com.acme.customer_id

; Extension fields using namespace
{claim}
id = !
&com.carrier.adjuster_code = :(6)
&com.carrier.priority_score = ##:(1..10)

Conditionals

Conditional Fields

Use :if to define fields that are required or present based on another field's value.

Conditional fields
{payment}
method = !(card, bank, crypto)
card_number = !:if method = card
card_expiry = !:if method = card
routing_number = !:if method = bank
account_number = !:if method = bank
wallet_address = !:if method = crypto
Conditional with modifiers
{claim}
type = !(injury, property, liability)
injury_description = !:if type = injury           ; required if injury
medical_records = *:if type = injury              ; redacted if injury
property_value = #$:if type = property            ; currency if property

Conditional Rules

  • :if field = value, field is relevant when condition is true
  • Multiple :if on same field act as OR
  • Combine with ! for "required when condition is true"
  • Omit ! for "optional when condition is true"
  • The :if condition can only reference fields within the same header scope

Inverse Conditionals

Use :unless for conditions where a field is required when a condition is FALSE.

Inverse conditionals
{user}
email = !                              ; always required
password = !:unless sso_enabled        ; required if NOT using SSO

{order}
payment_method = !(card, invoice)
immediate_charge = ?:unless payment_method = invoice

Invariants

Use :invariant under a header to define cross-field validation constraints.

Invariants
{order}
subtotal = !#$
tax = !#$
shipping = !#$
total = !#$
:invariant total = subtotal + tax + shipping

{date_range}
start = !date
end = !date
:invariant end >= start

{inventory}
quantity = !##:(0..)
reserved = !##:(0..)
available = !##:(0..)
:invariant available = quantity - reserved
:invariant reserved <= quantity

Supported Operators

CategoryOperators
Arithmetic+ - * / %
Comparison> < >= <= == !=
Logic&& || !
Grouping( )

Format Constraints

Use :format to validate string fields against common formats.

FormatDescriptionExample
emailEmail addressuser@example.com
urlURLhttps://example.com/path
uuidUUID v4550e8400-e29b-41d4-...
phonePhone (international)+1-555-123-4567
ssnSocial Security Number123-45-6789
vinVehicle ID Number1HGBH41JXMN109186
ibanInternational Bank AccountDE89370400440532013000
credit-cardCredit card (13-19 digits)4111111111111111
ipv4IPv4 address192.168.1.1
hostnameDNS hostnameapi.example.com
Format validation
{user}
email = !:format email              ; required, must be valid email
website = :format url               ; optional URL
id = !:format uuid                  ; required UUID

{payment}
card_number = *:format credit-card  ; confidential credit card

{employee}
ssn = !*:format ssn                 ; required, confidential SSN

Cardinality Constraints

Use :of to require N-of-M fields from a set.

Cardinality constraints
{contact}
email =
phone =
address = @address
:of (1..) email, phone, address              ; at least one required

{identification}
ssn = *
passport = *
drivers_license = *
:of (1..1) ssn, passport, drivers_license    ; exactly one required

Shorthand

ShorthandEquivalentMeaning
:one_of:of (1..)At least one
:exactly_one:of (1..1)Exactly one
:at_most_one:of (..1)Zero or one

Metadata Directives

Computed Fields

Mark fields as derived values that are not provided in input data.

Computed fields
{order}
subtotal = !#$
tax = !#$
total = !#$:computed                 ; calculated from subtotal + tax
created_at = !timestamp:computed     ; system-generated timestamp
order_number = !:computed            ; auto-generated order ID

Immutable Fields

Mark fields that cannot be changed after initial creation.

Immutable fields
{user}
id = !:format uuid :immutable        ; cannot change after creation
email = !:format email               ; can be updated
created_at = !timestamp:immutable    ; creation timestamp, never changes

{transaction}
transaction_id = !:immutable
timestamp = !timestamp:immutable
amount = !#$:immutable               ; transaction amount is permanent
status = !(pending, completed, refunded)  ; status CAN change

Schema Metadata

Schema documents use the $ header for metadata.

Schema metadata
{$}
odin = "1.0.0"
schema = "1.0.0"
id = "com.example.order"
version = "2.1.0"
title = "Order Schema"
description = "Schema for e-commerce orders"