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:
{
"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"]
}{vehicle}
vin = !:(17):/^[A-HJ-NPR-Z0-9]{17}$/
year = !##:(1900..2100)
price = #$:(0..)Design Principles
- Self-referential, ODIN syntax describes ODIN structure
- Concise, Symbols over keywords
- Type-aware, Numeric, boolean, temporal types are distinct
- Constraint-driven, Bounds, patterns, enums in-line
- Deterministic, Same schema always produces the same canonical output, enabling hashing, digital signatures, and audit trails
- Diffable, One field per line with flat paths means schema changes produce meaningful git diffs, not structural noise from shifted braces or indentation
- Composable, Reusable type definitions
- Familiar, Borrows from programming language conventions
- 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.
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
| Symbol | Meaning | Context |
|---|---|---|
! | Required | Field must be present |
? | Boolean | Boolean type |
# | Number | Numeric type (any precision) |
## | Integer | Whole number only |
#.N | Decimal | Fixed N decimal places |
#$ | Currency | Money (defaults to 2 decimals) |
#$.N | Currency | Money with N decimal places |
#% | Percent | Percentage as decimal (0-1) |
@ | Reference | Path reference to another field |
^ | Binary | Base64-encoded binary data |
~ | Nullable/Null | Nullable modifier or null type |
* | Redacted | Value should be masked |
- | Deprecated | Field is obsolete |
: | Constraint | Constraint expression follows |
() | Enum/Bounds | Enumeration or numeric range |
[] | Array | Array type definition |
| | Union | Type separator |
& | Intersection | Combine type definitions |
:if | Conditional | Field depends on another field's value |
:unless | Inverse Conditional | Required when condition is FALSE |
:invariant | Invariant | Cross-field validation expression |
:format | Format | String format validation |
:computed | Computed | Derived value, not in input |
:immutable | Immutable | Cannot be changed after creation |
Type Definitions
String (Default)
String is the default type when no prefix is specified.
name = ! ; required string
description = ; optional string
code = !:(3..10) ; required, length 3-10
pattern = :/^[A-Z]{3}$/ ; regex pattern
status = (draft, published) ; enumBoolean
Use ? for boolean fields.
active = ? ; optional boolean
required = !? ; required boolean
enabled = ? true ; optional, default trueNumeric Types
| Type | Syntax | Description |
|---|---|---|
| Any number | # | Integer or decimal |
| Integer | ## | Whole numbers only |
| Decimal | #.N | Fixed N decimal places |
| Currency | #$ | Money (2 decimal places) |
| Currency | #$.N | Money with N decimals |
| Percent | #% | Percentage as decimal (0-1) |
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 Declaration | Data Value |
|---|---|
rate = #.4 | rate = #0.0500 |
btc = #$.8 | btc = #$1.00000000 |
count = ## | count = ##42 |
price = #$ | price = #$99.99 |
tax = #% | tax = #%0.15 |
Temporal Types
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
parent = @ ; reference to any path
manager = !@ ; required reference
billing = @addresses ; reference to addresses array
primary = @contacts[0] ; reference to first contactdata = ^ ; any binary
signature = !^ ; required binary
hash = ^sha256 ; SHA-256 hash
photo = ^:(..1048576) ; max 1MBUnion Types
Use | to chain multiple types. |"" adds string, |~ adds null.
; 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 stringDefault Values
Default values appear after the type and constraints:
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 defaultDefault Value Rules
- Type-prefixed, Default value uses same type prefix as field
- Within constraints, Default must satisfy field constraints
- Optional fields only, Required (
!) fields cannot have defaults
Constraints
The colon : introduces 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 defaultNumeric 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 defaultTemporal 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]
; 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 + redactedArrays
Use [] suffix in header to define array schema.
{items[]}
sku = !
name = !
price = !#$
quantity = !##:(1..) ##1Array Constraints
Array bounds appear as the first line after the header.
{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:
{line_items[] : sku, description, qty, price}
sku = !
description = !:(..200)
qty = !##:(1..)
price = !#$:(0..)Tabular Schema Rules
- Primitives only, All column paths must resolve to primitive types
- Single-level nesting, Column names may use one dot or one array index
- No multi-level nesting, Paths like
a.b.cora[0].bnot allowed - Column order, Column list defines serialization order
Reusable Types
Define reusable types with {@name} syntax.
Type Definition & Usage
{@address}
line1 = !:(1..100)
line2 = :(..100)
city = !:(1..50)
state = !:(2)
zip = !:/^\d{5}$/
country = :(2) US
{@money}
amount = !#$:(0..999999.99)
currency = :(3) USDReference defined types with @typename:
{customer}
name = !
billing = @address ; uses @address definition
shipping = @address
{order}
total = @money
tax = @moneyType Intersection
Combine multiple type definitions with &:
{@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 & @bequals@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.
@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| Aspect | Allowed Override |
|---|---|
| Constraints | More restrictive (narrower range) |
| Required | optional to required (not reverse) |
| Nullable | Remove nullability (not add) |
| Type | Must be same base type |
Namespaced Custom Types
Use reverse-DNS notation for organization-specific 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.
{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{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 propertyConditional Rules
:if field = value, field is relevant when condition is true- Multiple
:ifon same field act as OR - Combine with
!for "required when condition is true" - Omit
!for "optional when condition is true" - The
:ifcondition 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.
{user}
email = ! ; always required
password = !:unless sso_enabled ; required if NOT using SSO
{order}
payment_method = !(card, invoice)
immediate_charge = ?:unless payment_method = invoiceInvariants
Use :invariant under a header to define cross-field validation constraints.
{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 <= quantitySupported Operators
| Category | Operators |
|---|---|
| Arithmetic | + - * / % |
| Comparison | > < >= <= == != |
| Logic | && || ! |
| Grouping | ( ) |
Format Constraints
Use :format to validate string fields against common formats.
| Format | Description | Example |
|---|---|---|
email | Email address | user@example.com |
url | URL | https://example.com/path |
uuid | UUID v4 | 550e8400-e29b-41d4-... |
phone | Phone (international) | +1-555-123-4567 |
ssn | Social Security Number | 123-45-6789 |
vin | Vehicle ID Number | 1HGBH41JXMN109186 |
iban | International Bank Account | DE89370400440532013000 |
credit-card | Credit card (13-19 digits) | 4111111111111111 |
ipv4 | IPv4 address | 192.168.1.1 |
hostname | DNS hostname | api.example.com |
{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 SSNCardinality Constraints
Use :of to require N-of-M fields from a set.
{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 requiredShorthand
| Shorthand | Equivalent | Meaning |
|---|---|---|
: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.
{order}
subtotal = !#$
tax = !#$
total = !#$:computed ; calculated from subtotal + tax
created_at = !timestamp:computed ; system-generated timestamp
order_number = !:computed ; auto-generated order IDImmutable Fields
Mark fields that cannot be changed after initial creation.
{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 changeSchema Metadata
Schema documents use the $ header for 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"