Skip to content

Validation as a value

tree | T stops the program at the first value that does not read. A pipeline over many values wants the other answer too: which ones failed, why, and what to do with them. validate(T) gives that as a value.

One result per value

d | validate(T) is a Result<T, Invalid>: ok of the value read, or err of one Invalid that holds the original tree and every reason it did not read. A match covers both, and nothing is thrown:

type UserData : DataContract = {
    id: String
    require id != ""
}

d : Data = {id: ""}
match d | validate(UserData) {
    ok(user) => user.id,
    err(e) => fmt("{} violation of {}", e.violations.size(), e.contract),
}
"1 violation of UserData"

Over a List<Data>, a Seq<Data> or a Stream<Data>, validate(T) is elementwise and keeps the shape: a List<Result<T, Invalid>>, a stream of results. An invalid element is a value in the collection, not an exception out of it. oks() keeps the successes at type T, and errs() keeps the failures at type Invalid:

type UserData : DataContract = {
    id: String
    age?: Int

    require id != "" as id_present
    require age? >= 0
    warn age? < 120
}

xs : List<Data> = [{id: "u-1", age: 34}, {id: "", age: -3}, {age: "x"}, {id: "u-4", age: 130}]
results = xs | validate(UserData)
[
    results | oks() | map(u => u.id),
    results | errs() | flat_map(e => e.violations) | map(v => v.rule)
]
[["u-1", "u-4"], ["UserData.id_present", "UserData.2", "UserData.id.missing", "UserData.age.type"]]

Four things to see. The second value breaks two rules and both are reported. The third is missing id and has a text age: both structural faults are reported, and no rule ran over it. The fourth breaks only the warning and is among the successes. And every reason has a stable name: a named rule by its name, an unnamed one by its position, a structural fault by its field and kind.

What a violation holds

A Violation is a record, not a message. Its contract and path say where, its kind says what sort of fault, missing, type, undeclared or rule, its rule is the stable identity and text the rule as written. For a structural fault, expected holds the declared type and actual the tree the field held:

type UserData : DataContract = {
    id: String
    age: Int
}

d : Data = {age: "forty-two"}
match d | validate(UserData) {
    ok(_) => [],
    err(e) => e.violations | map(v => [v.rule, v.kind, v.expected | unwrap_or("-"), fmt("{}", v.actual)]),
}
[["UserData.id.missing", "missing", "String", "none"], ["UserData.age.type", "type", "Int", "\"forty-two\""]]

A message is there for display, and a program reads the structured fields instead. Violation and Invalid are themselves open contracts declared in the standard library, so a producer may attach a source or a position, and a failure written to a file reads back with | Invalid.

The original value

Invalid.value is the tree as it was validated, before any default was applied. A rejected value carries what is needed to repair it or to replay it later, without a lookup elsewhere:

type UserData : DataContract = {
    id: String
    age: Int = 18
    require id != "" as id_present
}

xs : List<Data> = [{id: "u-1"}, {id: ""}, {id: "u-3"}, {id: "", age: 3}]
xs | validate(UserData) | errs() | map(e => e.value)
[{id: ""}, {age: 3, id: ""}]

Both sides at once

A pipeline that keeps the accepted values and does something else with the rejected ones is a fork: one input broadcast to named branches, each a pipeline of its own. Each result lands on one side, in source order, and each side is an ordinary collection to continue with or to write out:

type UserData : DataContract = {
    id: String
    require id != "" as id_present
}

xs : List<Data> = [{id: "u-1"}, {id: ""}, {id: "u-3"}]
{ accepted, rejected } = xs | validate(UserData) | fork { accepted = oks(), rejected = errs() }
[accepted | map(u => u.id) | json, rejected | map(e => e.violations | map(v => v.rule)) | json]
[[
  "u-1",
  "u-3"
], [
  [
    "UserData.id_present"
  ]
]]

The rejected side written to a sink is a dead letter: the full rejected tree and its violations, as data, where another program reads it back.

The policy is the pipeline

The contract says what is valid. What happens to an invalid value is not a setting of the contract; it is the shape of the pipeline around it, and the same contract serves each:

To Write
stop at the first invalid value xs \| T
keep every result and decide xs \| validate(T)
skip the invalid xs \| validate(T) \| oks()
keep the accepted, route the rejected xs \| validate(T) \| fork { accepted = oks(), rejected = errs() }
accept with a note a rule written warn

Over a stream that does not end the same words hold, and the stream continues past an invalid element as it continues past any other:

type Reading : DataContract = {
    value: Int
    require value >= 0
}

fn readings() : Stream<Data> {
    yield {value: 1}
    yield {value: -1}
    yield {value: 2}
    for r in readings() {
        yield r
    }
}

readings() | validate(Reading) | oks() | map(r => r.value) | take(5)
[1, 2, 1, 2, 1]

validate(T) reads a Data tree or an open view. A value that is already a closed record was checked when it was made, and validate(Point) over a Point is a type error saying so: there is nothing left to find out.