Version: 1.8.0 Date: September 2026 Status: Draft
License: This annex is part of the Open Okyline Language Specification and is subject to the same license terms (CC BY-SA 4.0). See the Core Specification for full license details.
This annex defines the Okyline Expression Language, a pure and deterministic language for expressing computed validations. It enables business invariants such as cross-field calculations (e.g., total == subtotal * (1 + taxRate)) and conditional logic based on field values. Expressions are declared in a $compute block and referenced in field constraints using the (%ExpressionName) syntax.
This annex defines the Okyline Expression Language, used inside $compute blocks and computed field constraints.
It specifies:
$compute block)(%Name))Implementations claiming conformance to this annex MUST implement the rules it describes (Core, Conformance).
The Expression Language is an integral component of Okyline, not a separate language. It extends the core validation model with computation and conditional logic while remaining pure, deterministic, and side-effect-free.
Although this annex is part of the Okyline 1.8.0 Draft specification, the expression language defined here is considered stable for practical use.
The Okyline Expression Language defines the grammar and semantics used in $compute blocks and computed constraints.
This annex is normative, meaning its contents define the expected behavior of conforming implementations of Okyline 1.8.0.
Expressions are pure, deterministic, and evaluated within the context of the object where they appear. All functions are null-safe and side-effect free.
$computeOkyline reserves the special block $compute as a container for named expressions. It is declared at the root level, alongside $oky.
{
"$compute": {
"ExpressionName": "expression body"
},
"$oky": {
...
}
}Examples:
A computed validation: total is checked against the expression ValidTotal.
{
"$oky": {
"invoice": {
"subtotal": 100.0,
"taxRate": 0.2,
"total|(%ValidTotal)": 120.0
}
},
"$compute": {
"ValidTotal": "total == subtotal * (1 + taxRate)"
}
}The same check with an intermediate compute: ValidTotal reuses %TaxAmount, which is never a constraint by itself.
{
"$oky": {
"invoice": {
"subtotal": 100.0,
"taxRate": 0.2,
"total|(%ValidTotal)": 120.0
}
},
"$compute": {
"TaxAmount": "subtotal * taxRate",
"ValidTotal": "total == subtotal + %TaxAmount"
}
}$compute is declared at the root level, not inside $oky.$compute is optional; when present, it MUST be a JSON object.|: "ItemTotal | Line amount incl. VAT": "quantity * unitPrice". The name is the identifier; the label is read by documentation tools only, and everything after the first | is the label. The parameter list of a parameterized compute (§C.2.3) belongs to the name, before the |.A compute may declare formal parameters by suffixing its name with a parenthesized, comma-separated list of parameter names, each a single segment (§C.5.1). Parameters are bound to call-site values and are visible only inside the body of that compute.
"$compute": {
"CategoryCheck(cat)": "category != cat || rate > 0",
"InRange(lo, hi)": "it >= lo && it <= hi"
}Call syntax: %Name(arg1, arg2, ...). Argument count MUST equal the declared arity; a mismatch is a schema load error. When called from a field constraint (e.g. "field|(%Name(arg))"), arguments are limited to literals or dotted identifier paths; the full expression form is available when calling from within another compute body.
"age|(%InRange(18, 120))": 30
"rate|(%CategoryCheck('S'))": 20Scope rules:
it, this, root, parent, index, size, prev, next, first, last, origin, isOrigin, isFirst, isLast).this.<field> (see §C.4.3).%Name reference is reserved for arity 0.Parameter value shape. An argument is evaluated at the call site and bound to its parameter as-is - scalar, object, or list. Inside the body:
p).p.field (and cascade for nested paths: p.address.city).firstOf(p), at(p, 0), findFirst(p, pred), etc.) or iterate with aggregations (sum(p, ...), filter(p, ...), …)."$compute": {
"GetAddress": "customer.address",
"CheckCity(a)": "a.city == 'Paris'",
"CheckDeep(a)": "a.region.country == 'FR'",
"Total(lines)": "sum(lines, LineExtensionAmount)"
}Canonical name: the identifier before the ( is the canonical name used in collision checks, references, and error reporting. Declaring both "F" and "F(a)" in the same $compute block is a schema load error.
(%Name)To validate a field using a computed expression, reference it with the (%ExpressionName) syntax in the field’s constraint declaration. The same reference serves as the operand of a condition, fieldName(%ExpressionName) (Core §6.3.22).
"fieldName|(%ExpressionName)": exampleValue
Example:
{
"$oky": {
"order": {
"quantity|(>0)": 5,
"unitPrice|(>0)": 20.0,
"total|(%CheckTotal)": 100.0
}
},
"$compute": {
"CheckTotal": "total == quantity * unitPrice"
}
}When a field references a computed expression:
| Result | Validation |
|---|---|
true |
Passes |
false |
Fails - Compute error |
Non-boolean (including null) |
Fails - Compute error (“expression has to be boolean”) |
| Evaluation exception | Fails - Compute error (exception message) |
Error reporting SHOULD include:
order.total)CheckTotal)(%Name) can be combined with other constraint types:
"email|@ (%ValidEmail) ~^[^@]+@[^@]+$~|Contact email": "[email protected]"The field email must satisfy all constraints:
@)ValidEmail expression ((%ValidEmail))~...~)Constraint type exclusivity:
Since each constraint type can appear only once per field, (%Name) cannot be combined with other parenthetical constraints like (>0) or (<100). Both use the (...) syntax and occupy the same constraint slot.
// INVALID - two parenthetical constraints
"total|@ (>0) (%ValidTotal)": 120.0
// VALID - numeric check must be inside the expression
"total|@ (%ValidTotal)": 120.0If you need both a range check and a computed validation, include the range check within the computed expression itself:
"$compute": {
"ValidTotal": "total > 0 && total == subtotal * (1 + taxRate)"
}Constraint evaluation order:
@, {...}, ~...~)(%Name))(%Name) can be applied to collection fields (scalar lists, object lists, and maps). When combined with the -> separator (Core §5.2.2), it distinguishes container-level and element-level evaluation:
"field|@ [size] (%ContainerCheck) -> (%ElementCheck)": [10, 20]
->: evaluated once on the collection as a whole. it references the collection.->: evaluated once per element. it references the current element value.The separation is strict: container constraints are never applied to individual elements, and element constraints are never applied to the collection. Either side is optional.
Container errors are reported on the collection path (e.g., 'scores'); element errors on the element path (e.g., 'scores[0]').
Example:
"scores|@ [*] (%CheckTotal) -> (%CheckElement)": [10, 20, 35]"CheckTotal": "100 == sum(scores)",
"CheckElement": "it >= 10"Expressions are evaluated in the context of a JSON object. All fields of the current object are accessible by name.
For a field constraint (%ExpressionName), the expression is evaluated in the context of the parent object containing that field.
{
"$oky": {
"invoice": {
"subtotal": 100.0,
"taxRate": 0.2,
"total|(%ValidTotal)": 120.0
}
},
"$compute": {
"ValidTotal": "total == subtotal * (1 + taxRate)"
}
}In this example, ValidTotal is evaluated with access to:
subtotal → 100.0taxRate → 0.2total → 120.0Field resolution rules:
subtotal, taxRateaddress.citynullnull (null-safe navigation)When using aggregation functions (sum, average, min, max, etc.), the second parameter expression is evaluated for each element in the collection.
{
"$oky": {
"order": {
"items|[1,100]": [
{"qty": 2, "price": 10.0},
{"qty": 3, "price": 15.0}
],
"total|(%CheckTotal)": 65.0
}
},
"$compute": {
"LineTotal": "qty * price",
"CheckTotal": "total == sum(items, %LineTotal)"
}
}In this example:
LineTotal is evaluated in the context of each item (has access to qty and price)CheckTotal is evaluated in the context of order (has access to items and total)sum(items, %LineTotal) iterates over items, evaluates %LineTotal for each, and returns the sumExpressions support path expressions to access fields beyond the immediate validation context.
Path expression syntax and resolution rules are defined in §6.3.20 Field Path Expressions of the core specification.
| Prefix | Resolves From |
|---|---|
| (none) | Current context |
this. |
Current context (explicit) |
parent. |
Parent object |
root. |
Document root |
origin. |
Origin element of the aggregation (lambda only, §C.4.5) |
prev. |
Previous iteration element (lambda only, §C.4.5) |
next. |
Next iteration element (lambda only, §C.4.5) |
first. |
First collection element (lambda only, §C.4.5) |
last. |
Last collection element (lambda only, §C.4.5) |
Example:
{
"$compute": {
"ValidDiscount": "discount <= parent.maxDiscount"
}
}it VariableWhen an expression is evaluated as a field constraint ((%Name)), the variable it references the value of the field being validated; as a condition operand (Core §6.3.22), it references the value of the trigger field.
Example:
{
"$compute": {
"IsPositive": "it > 0"
},
"$oky": {
"quantity|(%IsPositive)": 10,
"price|(%IsPositive)": 99.99
}
}Scope: it is defined in the constraint expression and propagates to any referenced expression (%Name).
Rebinding inside aggregation lambdas. When an expression is evaluated as the lambda body of an aggregation function (map, filter, countIf, exists, notExists, sumIf, sum, average, min, max), it is rebound to the current iteration element for each evaluation of the body. This rebinding is local to the lambda - once the aggregation completes, it reverts to its outer value (the field being validated).
When the aggregation operates on a scalar collection (numbers, strings, booleans), the lambda body uses it to reference the current scalar element directly:
map([1, 2, 3], it + 10) // → [11, 12, 13]
filter([1, 2, 3, 4], it > 2) // → [3, 4]
exists(["a", "b", "c"], it == "b") // → true
countIf(chars(name), it == "a") // → number of 'a' characters in nameWhen the aggregation operates on an object collection, the lambda body references the object’s properties by name (the established 1.4.0 form); it then refers to the current object as a whole and is rarely needed:
map(items, code) // existing form (named property)
map(items, toUpperCase(code)) // existing form
filter(items, active == true) // existing formNested collection items. When the iterated element is itself a collection (a list of lists, a map of lists, etc.), it rebinds to that inner collection and can be passed to another aggregation function. This enables explicit nested-collection traversal without an implicit flatten:
sum(map(matrix, sum(it))) // total of all numbers in a list of lists
map(groups, sum(it)) // sum of each list value of a map of lists
countIf(map(rows, count(it)), it >= 3) // count rows with at least 3 elementsIn each example, the outer lambda iterates the parent collection and it is the inner collection (a list); the inner aggregation then reduces that list. Composition is explicit at every level - Okyline never flattens silently.
In contexts where it is not defined (standalone evaluations outside any constraint or lambda), referencing it resolves to null.
When an expression is evaluated inside the lambda of an aggregation function (countIf, exists, notExists, filter, sum, etc.), the following context variables are available in addition to the implicit current element:
| Variable | Resolves to | Null when |
|---|---|---|
origin |
The item whose validation triggered the aggregation call | Never (always the calling item) |
prev |
The element immediately before the current iteration element | Current element is first |
next |
The element immediately after the current iteration element | Current element is last |
first |
The first element of the iterated collection | Collection is empty |
last |
The last element of the iterated collection | Collection is empty |
index |
The 0-based index of the current iteration element | Never (always defined inside a lambda) |
size |
The total number of elements in the iterated collection | Never (always defined inside a lambda) |
All variables support dotted navigation: origin.amount, prev.date, first.id, last.status.
These variables are only defined inside aggregation lambdas. Outside this context, they resolve to null.
Positional predicates:
In addition to the navigation variables above, the following predicates are available inside aggregation lambdas:
| Predicate | Returns true when |
|---|---|
isOrigin |
Current iteration element is the origin element |
isFirst |
Current iteration element is the first in the collection |
isLast |
Current iteration element is the last in the collection |
These predicates are only defined inside aggregation lambdas. Outside this context, they resolve to null.
Implicit element access vs. origin:
Inside an aggregation lambda, unqualified field names (amount, status) refer to the current iteration element. origin refers to the element whose field constraint triggered the aggregation. These are distinct objects.
Example:
{
"$compute": {
"IsUnique": "countIf(parent.items, id == origin.id) == 1"
},
"$oky": {
"items|[*]": [{
"id|@ (%IsUnique)": "A001"
}]
}
}When validating items[0].id, the expression iterates over all items. For each iteration element, id is the element’s id and origin.id is items[0].id. The count equals 1 only if the id is unique.
expression ::= conditional
conditional ::= logical_or ( "?" expression ":" expression )?
logical_or ::= logical_and ( "||" logical_and )*
logical_and ::= equality ( "&&" equality )*
equality ::= comparison ( ("==" | "!=") comparison )*
comparison ::= addition ( (">" | "<" | ">=" | "<=") addition )*
addition ::= multiplication ( ("+" | "-") multiplication )*
multiplication ::= null_coalescing ( ("*" | "/") null_coalescing )*
null_coalescing ::= unary ( "??" unary )*
unary ::= ("!" | "+" | "-") primary | primary
primary ::= ( literal | identifier | compute_ref | function_call | list_literal | "(" expression ")" ) member_access*
member_access ::= "." identifier
compute_ref ::= "%" identifier [ "(" [ arguments ] ")" ]
function_call ::= identifier "(" [ arguments ] ")"
list_literal ::= "[" [ arguments ] "]"
arguments ::= expression ( "," expression )*
literal ::= number | string | boolean | null
identifier ::= segment ( "." segment )*
segment ::= ( letter | "_" | "@" | "$" ) ( letter | digit | "_" )*
Unary operators. At most one leading unary operator per operand: !x, -x, +x are valid; !!x, --x, +-x are not. Two additive operators MUST NOT be adjacent (a + -b); parenthesize instead: a + (-b).
Member access. A .identifier postfix retrieves a named field from an object returned by any expression: firstOf(items).amount, %getParty('S').name, findFirst(lines, cat == 'S').id. Dotted paths on bare identifiers (e.g. parent.x.y) are tokenized as a single identifier and resolved via the path-navigation rules of §C.4.3; the member_access postfix only applies when a . follows an expression terminator (), ]) or another member_access. Resolution on a null base yields null. A bare $NAME in an expression is a field name; a nomenclature is referenced as a quoted string, '$NAME' (§C.10.1).
List literals. A bracketed comma-separated sequence of expressions produces an in-memory list. List literals can appear anywhere a value is expected, including as arguments to aggregation functions and to in(). The empty list [] is allowed.
sum([1, 2, 3]) // → 6
in(status, ["DRAFT", "SENT", "PAID"]) // → true if status is one of the listed values
join(["a", "b", "c"], "-") // → "a-b-c"
count([]) // → 0| Operator | Type | Description | Example | Null Behavior |
|---|---|---|---|---|
?? |
Null coalescing | Returns left if non-null, otherwise right | price ?? 0 → 0 |
Short-circuits; high precedence |
+ |
Arithmetic | Addition | 2 + 3 → 5 |
Null propagates - Exception: string concatenation treats null as "" |
- |
Arithmetic | Subtraction | 5 - 2 → 3 |
Null propagates |
* |
Arithmetic | Multiplication | 3 * 2 → 6 |
Null propagates |
/ |
Arithmetic | Division | 6 / 2 → 3.0 |
Null propagates; division by zero → null |
> < >= <= |
Comparison | Numeric/lexicographic | age > 18 |
false if either operand is null |
== != |
Equality | Value equality | "A" == "A" |
null == null → true; null == x → false |
&& |
Logical | Conjunction | a && b |
null → false |
|| |
Logical | Disjunction | a || b |
null → false |
! |
Logical | Negation | !true → false |
!null → true |
? : |
Ternary | Conditional | x > 10 ? "hi" : "lo" |
Condition null → false |
In case of any discrepancy, the operator precedence defined in the grammar (§C.5) MUST take precedence.
| Operator | Type | Description | Example |
|---|---|---|---|
?? |
Null coalescing | Returns the left operand if it is non-null; otherwise returns the right operand (short-circuit evaluation). | price ?? 0 → 0 (if price is null) |
Precedence: The ?? operator has high precedence, binding tighter than both comparison operators and arithmetic operators.
Short-circuit evaluation: The right operand is not evaluated if the left operand is non-null.
Examples:
// If price is null, use default of 10
finalPrice = price ?? 10
// Chain multiple coalescings
value = a ?? b ?? c ?? 0 // First non-null value or 0
// In arithmetic context
total = (price ?? 0) * (quantity ?? 1)== and != compare values at every depth, by the nature of the operands:
| Operands | Equal when |
|---|---|
| two scalars | by the scalar rules; numbers per §C.12.3 |
| two lists | same length, and equal element by element, in order |
| two objects | same member names, in any order, and equal member by member |
| operands of different natures | never |
A member present with the value null is not an absent member: { "a": null } == { } is false.
| Function | Description | Example |
|---|---|---|
date(dateString, pattern?) |
Parses a string into a LocalDate (default pattern yyyy-MM-dd). |
date("2024-03-15") → LocalDate(2024-03-15) |
formatDate(date, pattern?) |
Formats a LocalDate to string (yyyy-MM-dd default). |
formatDate(date("2024-03-15"), "dd/MM/yy") → "15/03/24" |
today() |
Returns the current system date. | today() → 2025-11-05 |
daysBetween(start, end) |
Number of days difference (end - start). |
daysBetween("2024-03-15","2024-03-18") → 3 |
plusDays(date, days) |
Adds days to a date. | plusDays("2024-02-28",1) → "2024-02-29" |
minusDays(date, days) |
Subtracts days. | minusDays("2024-03-01",1) → "2024-02-29" |
plusMonths(date, months) |
Adds months. | plusMonths("2024-01-31",1) → "2024-02-29" |
minusMonths(date, months) |
Subtracts months. | minusMonths("2024-03-31",1) → "2024-02-29" |
plusYears(date, years) |
Adds years. | plusYears("2023-03-15",1) → "2024-03-15" |
minusYears(date, years) |
Subtracts years. | minusYears("2024-03-15",1) → "2023-03-15" |
isWeekend(date) |
True if Saturday or Sunday. | isWeekend("2024-03-16") → true |
isLeapYear(date) |
True if leap year. | isLeapYear("2024-03-15") → true |
year(date) |
Extracts year. | year("2024-03-15") → 2024 |
month(date) |
Extracts month. | month("2024-03-15") → 3 |
day(date) |
Extracts day of month. | day("2024-03-15") → 15 |
dayOfWeek(date) |
Day of week (MON=1, SUN=7). | dayOfWeek("2024-03-15") → 5 |
dayOfYear(date) |
Day of year (1-366). | dayOfYear("2024-03-15") → 75 |
weekOfYear(date) |
ISO week number (1-53). | weekOfYear("2024-01-01") → 1 |
quarter(date) |
Quarter (1-4). | quarter("2024-09-15") → 3 |
semester(date) |
Semester (1-2). | semester("2024-09-15") → 2 |
before(date1, date2) |
True if date1 is before date2. |
before("2024-01-01","2024-12-31") → true |
after(date1, date2) |
True if date1 is after date2. |
after("2024-12-31","2024-01-01") → true |
equals(date1, date2) |
True if same date. | equals("2024-03-15","2024-03-15") → true |
| Function | Description | Example |
|---|---|---|
isNull(v) |
True if v is null. |
isNull(null) → true |
hasValue(v) |
True if v is not null (strict opposite of isNull). |
hasValue("x") → true |
isNullOrEmpty(s) |
True if s is null or empty. |
isNullOrEmpty("") → true |
isEmpty(s) |
True if string length is 0. | isEmpty("") → true |
substring(s,start,len) |
Returns substring from start. | substring("Hello",1,3) → "ell" |
substringBefore(s,delim) |
Part before first occurrence. | substringBefore("a:b:c",":") → "a" |
substringAfter(s,delim) |
Part after first occurrence. | substringAfter("a:b:c",":") → "b:c" |
substringBeforeLast(s,delim) |
Part before last occurrence. | substringBeforeLast("a:b:c",":") → "a:b" |
substringAfterLast(s,delim) |
Part after last occurrence. | substringAfterLast("a.b.txt",".") → "txt" |
replace(s,target,repl) |
Replace all occurrences. | replace("foo bar foo","foo","baz") → "baz bar baz" |
replaceFirst(s,target,repl) |
Replace first occurrence. | replaceFirst("foo bar foo","foo","baz") → "baz bar foo" |
replaceLast(s,target,repl) |
Replace last occurrence. | replaceLast("foo bar foo","foo","baz") → "foo bar baz" |
trim(s) |
Removes leading/trailing spaces. | trim(" hi ") → "hi" |
ltrim(s) |
Removes leading spaces. | ltrim(" hi ") → "hi " |
rtrim(s) |
Removes trailing spaces. | rtrim(" hi ") → " hi" |
length(s) |
String length (null → 0). | length("hey") → 3 |
startsWith(s,prefix) |
Checks prefix. | startsWith("hello","he") → true |
endsWith(s,suffix) |
Checks suffix. | endsWith("hello","lo") → true |
contains(s,search) |
True if substring found. | contains("banana","an") → true |
removePrefix(s,prefix) |
Removes prefix if present. | removePrefix("Hello","He") → "llo" |
removeSuffix(s,suffix) |
Removes suffix if present. | removeSuffix("Hello","lo") → "Hel" |
removeRange(s,start,len) |
Removes len characters from start. |
removeRange("Hello",1,3) → "Ho" |
toUpperCase(s) |
Converts to upper case. | toUpperCase("Hi") → "HI" |
toLowerCase(s) |
Converts to lower case. | toLowerCase("Hi") → "hi" |
capitalize(s) |
Uppercases first character. | capitalize("hello") → "Hello" |
decapitalize(s) |
Lowercases first character. | decapitalize("Hello") → "hello" |
padStart(s,len,ch) |
Left pads string. | padStart("7",3,"0") → "007" |
padEnd(s,len,ch) |
Right pads string. | padEnd("7",3,"0") → "700" |
repeat(times,ch) |
Repeats char. | repeat(5,"*") → "*****" |
indexOf(s,sub) |
First index of substring. | indexOf("abracadabra","bra") → 1 |
indexOfFirst(s,sub) |
Alias for indexOf. |
indexOfFirst("abracadabra","bra") → 1 |
indexOfLast(s,sub) |
Last index of substring. | indexOfLast("abracadabra","bra") → 8 |
matches(s,'$Format') |
True if s matches the named format (contract $format or built-in). See §C.8.3. |
matches("[email protected]",'$Email') → true |
These functions bridge strings and lists, enabling expressive transformation pipelines when combined with the aggregation functions on scalar lists (§C.10).
| Function | Description | Example |
|---|---|---|
chars(s) |
Returns a list of one-element strings, one per Unicode codepoint of s. Surrogate pairs are kept intact (a single emoji is one element, not two). chars(null) and chars("") return []. |
chars("abc") → ["a","b","c"] |
split(s, sep) |
Splits s into a list of substrings using sep as a literal separator (not a regular expression). split(null, _) → []. split(s, null) and split(s, "") → [s] (no-op). split("", sep) → [""]. |
split("a,b,c", ",") → ["a","b","c"]split("a.b.c", ".") → ["a","b","c"] (literal dot, not regex) |
join(coll, sep) |
Concatenates the elements of coll separated by sep. Non-string elements are coerced via string conversion. null elements are skipped. join(null, _) and join([], _) → "". join(coll, null) is treated as join(coll, ""). |
join(["a","b","c"], "-") → "a-b-c"join([1, 2, 3], ",") → "1,2,3" |
Composability. The identity join(chars(s), "") == s holds for any non-null string. Combined with map/filter over scalar lists, these primitives enable expressive transformations:
// Uppercase each char of a string (equivalent to toUpperCase(s))
join(map(chars(s), toUpperCase(it)), "")
// Count vowels in a string
countIf(chars(name), in(it, ["a","e","i","o","u"]))
// Extract digits and sum them
sum(map(filter(chars(code), it >= "0" && it <= "9"), toNum(it)))
// Split a CSV, keep non-empty fields, rejoin
join(filter(split(csv, ","), it != ""), ",")String functions use defensive index handling to prevent runtime errors and ensure deterministic behavior.
"", index -1, etc.).substring("Hello", -10, 3) → "Hel" // negative start clamped to 0
substring("Hello", 1, -5) → "" // negative length treated as 0
substring("Hello", 100, 5) → "" // start beyond length → empty
substring("Hello", 1, 999) → "ello" // end clamped to string length
substring("Hello", 2, 0) → "" // zero length → empty
indexOf("abc", "z") → -1 // not found
padStart("Hi", 1, "x") → "Hi" // already long enough (no truncation)substring(s, start, length) is interpreted as a length, not an end index.matchesThe matches function tests whether a string conforms to a named format - either a $format declared in the contract or an Okyline built-in format. It exposes the same format vocabulary as a field’s ~$Format~ constraint to the expression language.
Form:
| Form | Description | Example |
|---|---|---|
matches(value, '$FormatName') |
True if value matches the named format |
matches(ref, '$Code') |
Semantics:
$ (mirroring in(value, '$Nomenclature')). A second argument that is not such a literal, or that names a format the contract does not know, MUST be rejected when the schema is loaded.$FormatName resolves with the same precedence as a field’s ~$Format~ constraint: a contract $format takes precedence over a built-in of the same name.$Email, $Date, $DateTime, $Time, $Uri, $Ipv4, $Ipv6, $Uuid, $Hostname.false when value is null.Example:
// A field whose value must be a well-formed email
// $compute: { "IsEmail": "matches(it, '$Email')" }
email|@ (%IsEmail)
// Reusing a contract $format
// $format: { "Code": "^[A-Z]{3}[0-9]{3}$" }
// $compute: { "IsCode": "matches(it, '$Code')" }
reference|@ (%IsCode)| Function | Description | Example |
|---|---|---|
abs(x) |
Absolute value. | abs(-5) → 5 |
sqrt(x) |
Square root. | sqrt(9) → 3.0 |
floor(x,scale?) |
Rounds down (optional decimals). | floor(3.1415,2) → 3.14 |
ceil(x,scale?) |
Rounds up. | ceil(3.1415,2) → 3.15 |
round(x,scale?,mode?) |
Round with mode (HALF_UP default). |
round(3.5,0,"HALF_EVEN") → 4.0 |
mod(a,b) |
Remainder of division. | mod(10,3) → 1 |
pow(base,exp) |
Power function. | pow(2,3) → 8.0 |
log(x) |
Natural logarithm. | log(1) → 0 |
log10(x) |
Base-10 logarithm. | log10(1000) → 3.0 |
toInt(v) |
Converts to integer, rounding HALF_UP. |
toInt(3.7) → 4 |
toNum(v) |
Converts to number. | toNum("42") → 42.0 |
toStr(v) |
Converts to string. | toStr(42) → "42" |
Transcendental functions. sqrt, log, log10, and pow when its exponent is not an integer within ±999, cannot be computed exactly in decimal. Their result is rounded to 15 significant digits before the fixed-point rule of §C.11.1 applies, so that two conforming implementations publish the same value. pow with an integer exponent within ±999 is computed exactly and is not concerned.
Outside their mathematical domain - sqrt of a negative number, log or log10 of zero or of a negative number, pow whose result is not finite - these functions yield null, never an infinity nor an error, consistently with division by zero (§C.11).
Aggregation functions operate on collections - JSON arrays, JSON objects used as maps, or list literals (§C.5.1). When the collection contains objects, the lambda expression typically references object properties by name. When the collection contains scalar values (numbers, strings, booleans), the lambda expression references the current element via it (see §C.4.4).
| Function | Object collection | Scalar collection | Description |
|---|---|---|---|
sum |
sum(collection, expr) |
sum(collection) |
Sum of values. |
average |
average(collection, expr) |
average(collection) |
Arithmetic mean of values. |
min |
min(collection, expr) |
min(collection) |
Minimum value. |
max |
max(collection, expr) |
max(collection) |
Maximum value. |
count |
count(collection) |
count(collection) |
Count of non-null elements. |
countAll |
countAll(collection) |
countAll(collection) |
Count of all elements (including null). |
countIf |
countIf(collection, expr) |
countIf(collection, predicate) |
Count of elements where the predicate is true. Use it for scalar elements. |
exists |
exists(collection, expr) |
exists(collection, predicate) |
True if at least one element satisfies the predicate. |
notExists |
notExists(collection, expr) |
notExists(collection, predicate) |
True if no element satisfies the predicate. |
sumIf |
sumIf(collection, predicate, expr) |
sumIf(collection, predicate, value) |
Sum of value (or it for scalar) where predicate is true. |
map |
map(collection, expr) |
map(collection, expr) |
Returns a list of expr evaluated for each element. Use it for scalar elements. |
filter |
filter(collection, expr) |
filter(collection, predicate) |
Returns elements where expr is true. |
The first argument can be a JSON array, a JSON object used as a map ([*:*]), or any list literal ([...]). For maps, values are iterated; keys are ignored for aggregation purposes.
Scalar vs object collections. When iterating over a scalar collection, the lambda body uses it to reference the current element (see §C.4.4 Rebinding inside aggregation lambdas). When iterating over an object collection, the lambda body references the object’s properties by name (the established 1.4.0 form). Both forms can be mixed in nested aggregations.
// Object collection: existing 1.4.0 form
sum(items, quantity * unitPrice)
// Scalar collection: new 1.5.0 form
sum([10, 20, 30])
sum(map(chars(code), toNum(it))) // sum of digit characters
// Mixed: scalar lambda inside an object lambda
sum(items, sum(map(chars(reference), toNum(it))))Note: The %identifier syntax is not a function but a reference operator for accessing compute expressions. See C.10.4 Compute Reference Syntax for details.
inThe in function tests whether a value belongs to a set of allowed values.
Forms:
| Form | Description | Example |
|---|---|---|
in(value, [...]) |
List literal | in(status, ["DRAFT", "SENT"]) |
in(value, '$NomenclatureName') |
Nomenclature lookup | in(status, '$INVOICE_STATUS') |
in(value, listField) |
JSON array field | in(code, allowedCodes) |
Semantics:
true if the value is found in the target set, false otherwise.null value → false.true.== (§C.6.2).The inline variadic form
in(value, 'A', 'B', 'C')was removed in 1.6.0. Usein(value, ['A', 'B', 'C']).
lookupThe lookup function retrieves a value from a key/value source by key.
Form:
| Form | Description | Example |
|---|---|---|
lookup(key, mapField) |
JSON object used as a map: returns the value at key, or null if absent |
lookup(currency, rates) |
lookup(key, '$Nomenclature') |
Key-value nomenclature (Core §6.1.3): returns the value associated with key, or null if absent or if the nomenclature has no values |
lookup(code, '$COUNTRY') |
Semantics:
key in the source, or null if the key is absent.null key → null. null source → null. Non-object source → null.toNum, toStr, etc. for explicit conversion.?? for fallback semantics: lookup(key, src) ?? defaultValue.Example - char-by-char transformation pipeline:
// Transform each char of a string using a map field, with passthrough for unknown chars
join(map(chars(code), lookup(it, charMap) ?? it), "")Five functions retrieve a single element from a collection (as opposed to aggregating, filtering, or mapping). They are complementary to filter/map/sum and compose naturally with the member-access postfix (§C.5.1).
| Function | Signature | Result |
|---|---|---|
firstOf |
firstOf(collection) |
First element, or null if empty. |
lastOf |
lastOf(collection) |
Last element, or null if empty. |
findFirst |
findFirst(collection, predicate) |
First element for which the predicate is true, or null. |
findLast |
findLast(collection, predicate) |
Last element (reverse iteration) for which the predicate is true, or null. |
at |
at(collection, index_or_key) |
Element at numeric index (0-based, list/array, or positional access on map values) or at string key (map only). null if out of bounds, not found, or type mismatch. |
Semantics:
null collection → null; empty collection → null.findFirst/findLast is evaluated in the item context of §C.4.2 and §C.4.5 (all list-iteration variables are available).findLast iterates from the end so it returns on the first match encountered; no full pass when the match is near the tail.at(collection, index) (numeric) uses 0-based indexing. Negative indices return null (use lastOf for the last element). On a map, returns the value at that positional offset (insertion order).at(map, key) (string) returns the value associated with key, or null if the key is absent. Only valid on a map: at(list, "0") returns null (no implicit String→Integer coercion).firstOf(Lines).Amount > 100
findFirst(Payments, status == 'OK').id
lastOf(Events).timestamp
at(Lines, 0).price == at(Lines, 1).price
findLast(Events, type == 'UPDATE').timestampEquivalences:
at(list, 0) ≡ firstOf(list) when the list is non-empty.firstOf(filter(list, pred)) ≡ findFirst(list, pred).findFirst(list, true) ≡ firstOf(list) on a non-empty list.%identifierThe %identifier syntax allows expressions to reference other computed expressions defined in the same $compute block.
Syntax:
%<identifier>
Where <identifier> is the name of a compute expression defined in the same $compute block.
Semantics:
%ComputeName evaluates the expression named ComputeName from the current $compute block% prefix distinguishes compute references from field accessesExamples:
{
"$compute": {
"BasePrice": "1000",
"Tax": "%BasePrice * 0.2",
"Total": "%BasePrice + %Tax"
}
}In this example:
Tax references BasePrice using %BasePriceTotal references both BasePrice and Tax using %BasePrice and %TaxAggregation Functions:
When using aggregation functions (such as sum, map, filter), the second parameter can be:
%identifier to reference a compute expression){
"$compute": {
"LineTotal": "netAmount * (1 + vat)",
"SubTotal": "sum(items, netAmount)",
"TotalWithFees": "sum(items, netAmount * 1.2)",
"InvoiceTotal": "sum(lines, %LineTotal)"
}
}%Name.field.deep accesses members of the compute’s result. Example: "DocCurrency": "%DocRoot.DocumentCurrencyCode".
Benefits:
fieldName) and compute references (%ComputeName)field|(%ComputeName))Constraints:
% must be followed immediately by a valid identifier$compute blocknull.toNum, toStr, etc.).null.null evaluate to false unless explicitly tested.today() is the one function whose result depends on the moment of evaluation.Compute error carrying the expression name and the cause.The Okyline Expression Language guarantees exact decimal arithmetic suitable for financial calculations.
Fixed-point arithmetic. Every numeric value, whether read from the document, written as a literal or produced by an operation, carries at most $decimalScale fractional digits (Core §7.3; 6 when the contract declares none). Every operation whose exact result would need more rounds it to that scale, HALF_UP, before it is used further; operations between integers are exact.
Consequences an author MUST expect:
+ and - are exact and associative: their operands are already at scale.* is not associative: a product carries twice the scale and is rounded. At scale 2, (1.11 * 1.11) * 100 yields 123.00 while 1.11 * (1.11 * 100) yields 123.21./ rounds, so a quotient does not multiply back into its dividend: (10 / 3) * 3 yields 9.99 at scale 2.0.1 + 0.2 is exactly 0.3. Implementations MUST NOT carry intermediate results in binary floating point.Errors are therefore bounded to one unit of the last place per operation, and reproducible: two conforming implementations yield the same result.
Examples (scale 6 unless stated):
0.1 + 0.2 → 0.3 // exact, not 0.30000000000000004
19.99 * 3 → 59.97 // exact
100.00 / 3 → 33.333333 // rounded to the scale
1000 * 0.196 → 196 // exact
(1.11 * 1.11) * 100 → 123.00 // scale 2: the product was rounded first
1.11 * (1.11 * 100) → 123.21 // scale 2: it was not
(10 / 3) * 3 → 9.99 // scale 2: a quotient does not round-tripNote - choosing the scale. Declare
$decimalScaleabove the precision of the data, so that intermediate products are not rounded away; then, where a business value is defined at fewer decimals than the scale, round explicitly to that precision,round(x, n), before comparing it.
Manual Rounding Control:
When finer control over rounding is required, use the round(x, scale?, mode?) function to explicitly specify the number of decimal places and rounding mode.
// Default: 6 decimals, HALF_UP
total * vatRate → 196.000000
// Explicit rounding to 2 decimals for display
round(total * vatRate, 2) → 196.00
// Banker's rounding (HALF_EVEN) for financial compliance
round(amount, 2, "HALF_EVEN") → 2.50
// Round down for conservative estimates
round(estimate, 0, "DOWN") → 99Available rounding modes: HALF_UP (default), HALF_DOWN, HALF_EVEN, UP, DOWN, CEILING, FLOOR.
When the mode is written as a literal, a name outside these seven MUST be rejected when the schema is loaded. A mode computed at run time - a parameter of a parameterized compute, a field - that is not one of them yields null.
Type promotion concerns the kind of a value - integer or decimal - as seen by type guards (_Integer_, _Number_), conversions and rendering. It does not change the arithmetic of C.11.1, and 12 == 12.0 holds (C.12.3).
Rules:
| Operation | Result Type |
|---|---|
int + int |
int |
int + decimal |
decimal |
decimal + decimal |
decimal |
int * int |
int |
int * decimal |
decimal |
int / int |
decimal (division always returns decimal) |
Examples:
5 + 7 → 12 // int + int → int
5 + 7.0 → 12.0 // int + decimal → decimal
5.0 + 7.0 → 12.0 // decimal + decimal → decimal
6 / 2 → 3.0 // division always returns decimal
6 * 2 → 12 // int * int → int
6 * 2.0 → 12.0 // int * decimal → decimalArithmetic operations (+, -, *, /) follow three-valued logic (similar to SQL semantics):
+ treats null as the empty string "".Examples:
10 + null → null
null * 5 → null
null / 2 → null
100 - null → null
// String concatenation exception
"Hello" + null → "Hello"
null + " World" → " World"Comparison operators (<, <=, >, >=) return false if either operand is null.
10 > null → false
null <= 5 → false
null > null → falseEquality operators have specific null handling:
null == null → truenull != null → falsenull == <any-value> → falsenull != <any-value> → trueNumeric equality. Numbers carry exactly $decimalScale fractional digits (§C.11.1); == compares them exactly at that scale, with no further rounding. Raising the scale therefore tightens equality: 1.004 == 1.00 holds at scale 2, where both are 1.00, and fails at scale 3. Values that must agree at a coarser precision are compared through round(x, n).
Boolean operations require boolean operands:
null is treated as false in boolean contexts.false.null && true → false
null || true → true
!null → trueFunctions handle null arguments according to their specific semantics:
"".user.address.city → null // if user or address is nullThe null propagation semantics for arithmetic operations follow SQL/database conventions, where operations involving unknown values (null) produce unknown results (null). This prevents silent calculation errors and makes null handling explicit via the ?? operator.
| Feature | Syntax | Description |
|---|---|---|
| Declaration | "$compute": {...} |
Root-level block for named expressions |
| Field constraint | field\|(%Name) |
Validate field using expression |
| Compute reference | %Name |
Reference another expression in $compute |
| Field access | fieldName |
Access field in current context |
| Nested access | obj.field |
Null-safe nested field access |
| Parent access | parent.field |
Access field in parent object (Core §6.3.20) |
| Root access | root.field |
Access field at document root (Core §6.3.20) |
| Field value | it |
Value of field being validated, or current scalar element inside an aggregation lambda (§C.4.4) |
| Origin element | origin |
Element whose validation triggered the aggregation (§C.4.5) |
| Iteration navigation | prev, next, first, last |
Positional access within aggregation lambda (§C.4.5) |
| Iteration position | index, size |
0-based index and total size of the iterated collection within an aggregation lambda (§C.4.5) |
| Positional predicates | isOrigin, isFirst, isLast |
Boolean predicates within aggregation lambda (§C.4.5) |
| Null coalescing | a ?? b |
Return b if a is null |
| List literal | [a, b, c] |
Inline list of expressions (§C.5.1) |
| Expression Result | Validation |
|---|---|
true |
Pass |
false |
Fail - Compute error |
null |
Fail - Compute error (“has to be boolean”) |
| Non-boolean | Fail - Compute error (“has to be boolean”) |
| Category | Operators |
|---|---|
| Arithmetic | +, -, *, / |
| Comparison | >, <, >=, <= |
| Equality | ==, != |
| Logical | &&, \|\|, ! |
| Null coalescing | ?? |
| Ternary | ? : |
| Category | Functions |
|---|---|
| Date | date, today, daysBetween, plusDays, minusDays, plusMonths, minusMonths, plusYears, minusYears, formatDate, isWeekend, isLeapYear, year, month, day, dayOfWeek, dayOfYear, weekOfYear, quarter, semester, before, after, equals |
| String | length, substring, substringBefore, substringAfter, substringBeforeLast, substringAfterLast, replace, replaceFirst, replaceLast, trim, ltrim, rtrim, startsWith, endsWith, contains, removePrefix, removeSuffix, removeRange, toUpperCase, toLowerCase, capitalize, decapitalize, padStart, padEnd, repeat, indexOf, indexOfFirst, indexOfLast, isEmpty, isNullOrEmpty, isNull, hasValue, matches |
| String ↔︎ List | chars, split, join |
| List access | firstOf, lastOf, findFirst, findLast, at |
| Numeric | abs, sqrt, floor, ceil, round, mod, pow, log, log10, toInt, toNum, toStr |
| Aggregation | sum, average, min, max, count, countAll, countIf, sumIf, exists, notExists, map, filter |
| Membership | in |
| Lookup | lookup |
| Context | Behavior |
|---|---|
Arithmetic (+, -, *, /) |
Null propagates |
String concatenation (+) |
Null treated as "" |
Comparisons (<, >, etc.) |
Returns false |
Equality (==) |
null == null → true |
Logical (&&, \|\|) |
Null treated as false |
| Division by zero | Returns null |
| Transcendental function outside its domain | Returns null |
{
"$version": "1.0.0",
"$title": "Invoice Schema with Computed Validation",
"$oky": {
"invoice": {
"invoiceId|@ ~$InvoiceId~": "INV-2025-0001",
"issueDate|@ ~$Date~": "2025-06-15",
"dueDate|@ ~$Date~ (%DueDateAfterIssue)": "2025-07-15",
"customer": {
"name|@ {2,100}": "Acme Corporation",
"email|@ ~$Email~": "[email protected]"
},
"lines|@ [1,100]": [
{
"description|@ {2,200}": "Consulting services",
"quantity|@ (1..1000)": 10,
"unitPrice|@ (>0)": 150.00,
"lineTotal|@ (%CheckLineTotal)": 1500.00
},
{
"description|@ {2,200}": "Travel expenses",
"quantity|@ (1..1000)": 1,
"unitPrice|@ (>0)": 350.00,
"lineTotal|@ (%CheckLineTotal)": 350.00
}
],
"subtotal|@ (%CheckSubtotal)": 1850.00,
"taxRate|@ (0..0.5)": 0.20,
"taxAmount|@ (%CheckTaxAmount)": 370.00,
"total|@ (%CheckTotal)": 2220.00,
"amountPaid|(>=0)": 0.00,
"balance|(%CheckBalance)": 2220.00,
"status|@ ($INVOICE_STATUS)": "DRAFT",
"$requiredIf status('PAID')": ["paymentDate"],
"paymentDate|~$Date~": "2025-07-10"
}
},
"$compute": {
"CheckLineTotal": "lineTotal == round(quantity * unitPrice, 2)",
"CheckSubtotal": "subtotal == sum(lines, lineTotal)",
"CheckTaxAmount": "taxAmount == round(subtotal * taxRate, 2)",
"CheckTotal": "total == subtotal + taxAmount",
"CheckBalance": "balance == total - (amountPaid ?? 0)",
"DueDateAfterIssue": "daysBetween(issueDate, dueDate) >= 0"
},
"$format": {
"InvoiceId": "^INV-[0-9]{4}-[0-9]{4}$"
},
"$nomenclature": {
"INVOICE_STATUS": "DRAFT,SENT,PAID,OVERDUE,CANCELLED"
}
}lineTotal must equal quantity × unitPrice (rounded to 2 decimals)subtotal must equal the sum of all lineTotal valuestaxAmount validated against subtotal × taxRatetotal must equal subtotal + taxAmount?? to default amountPaid to 0 if nulldueDate must be on or after issueDatepaymentDate is required only when status is 'PAID'{
"invoice": {
"invoiceId": "INV-2025-0042",
"issueDate": "2025-06-15",
"dueDate": "2025-07-15",
"customer": {
"name": "Tech Solutions Ltd",
"email": "[email protected]"
},
"lines": [
{
"description": "Software development",
"quantity": 40,
"unitPrice": 125.00,
"lineTotal": 5000.00
}
],
"subtotal": 5000.00,
"taxRate": 0.20,
"taxAmount": 1000.00,
"total": 6000.00,
"amountPaid": 3000.00,
"balance": 3000.00,
"status": "SENT"
}
}matches(value, '$Format') string function tests a value against a named format - a contract $format or an Okyline built-in - with the same resolution precedence as a field’s ~$Format~ constraint (§C.8.3). === and !== are withdrawn from the grammar (§C.5.1, §C.6): equality is == / != only. == compares lists element by element and objects member by member, in any member order; a null member is not an absent one (§C.6.2). Transcendental functions round to 15 significant digits and yield null outside their domain (§C.9). A literal rounding mode outside the seven names is a schema error at load time (§C.11.1). A compute key accepts a label after | (§C.2.2). Identifier segments may start with @ or $ (§C.5.1). Arithmetic is fixed-point per operation, and == compares exactly at the scale (§C.11.1, §C.12.3); 1.7.0 described precision-preserving chains and implicit rounding at equality.it rebinds to the inner collection inside an aggregation lambda when the iterated element is itself a list or map, enabling explicit nested-collection traversal such as sum(map(matrix, sum(it))) (§C.4.4). at(map, "key") retrieves a value by string key on a map; at(...) no longer coerces String→Integer, so at(list, "0") returns null (§C.10.3). Grammar: unary operators no longer chain and two additive operators may not be adjacent (§C.5.1).%Name(args), parameter scoping rules, and member access on object/list parameters (p.field); list element access functions firstOf, lastOf, findFirst, findLast, at (§C.10.3); member-access postfix expr.field on any expression (§C.5.1); dotted access on compute references %Name.field (§C.10.4); hasValue(v) as strict opposite of isNull (§C.8).[a, b, c] (§C.5); map/filter/countIf/exists/notExists/sumIf on scalar collections with it rebinding (§C.4.4, §C.10); index and size variables in aggregation lambdas (§C.4.5); chars, split, join string ↔︎ list primitives (§C.8.1); lookup function for key-value retrieval (§C.10.2); inline variadic form of in() removed; toNum now preserves arbitrary precision as an exact decimal.origin, prev, next, first, last) and positional predicates (isOrigin, isFirst, isLast); membership function in(); aggregation functions sumIf, map, filter; date functions dayOfWeek, dayOfYear, weekOfYear, quarter, semester, before, after, equals; string functions substringBeforeLast, substringAfterLast, replaceFirst, replaceLast, ltrim, rtrim, removePrefix, removeSuffix, removeRange, indexOfFirst, isNull; short-circuit evaluation for &&/||; revised operator precedence.End of Annex C - Okyline Expression Language (Normative)