Packages

schema

Describe what valid data looks like, then check and clean untrusted values and list every problem at once.

import "std:schema"

schema checks data whose shape you don't control: a request body, a config file, a form. You build a schema out of small parts (string, integer, object, array), then ask it to parse a value. A good value comes back cleaned up, a bad one is reported with the path to the problem.

Reach for it when json.parse<T> is too rigid: values that can be one of several kinds, rules such as "at least 3 characters", or error messages you want to show users.

A first schema#

Schemas are values. schema.object takes the shape you expect, and parse returns the value if it fits.

main.osl
import "std:schema"

auto user = schema.object({
  name: schema.string(),
  age: schema.integer().min(0)
})

auto ok = user.parse({name: "Ada", age: 36})
log ok.name ok.age
log user.isValid({name: "Ada", age: -1})
log user.isValid({name: "Ada"})
Click the code to edit it

isValid gives a yes or no. A key that isn't marked optional is required, so a missing age fails.

Try it: change age to 36.5 and check isValid. An integer schema wants whole numbers.

Failure with parse#

parse throws a ValueError that names the path of the first problem. Wrap the call in try to get a result instead.

main.osl
import "std:schema"

auto user = schema.object({name: schema.string(), age: schema.integer()})

switch try(user.parse({name: "Ada", age: "old"})) (
  case ok(v)
    log "fine"
  case err(e)
    log e.kind
    log e.message
)
Click the code to edit it

List every problem#

When the user needs to fix a whole form, one error at a time is tedious. issues checks everything and returns a record per problem, each with a path (object keys and 1-based array positions) and a message. An empty list means the value is valid.

main.osl
import "std:schema"

auto order = schema.object({
  customer: schema.string().minLen(2),
  items: schema.array(schema.object({
    sku: schema.string(),
    qty: schema.integer().min(1)
  }))
})

auto bad = {customer: "X", items: [{sku: "a", qty: 2}, {sku: "b", qty: 0}, {qty: 1}]}
for issue in order.issues(bad) (
  log issue.path issue.message
)
log order.issues({customer: "Ada", items: []}).len
Click the code to edit it

Clean up while you check#

parse returns the value normalised, not just approved. trim removes surrounding whitespace, defaultValue fills in a missing key, and oneOrMany accepts one item or a list and always gives you a list.

main.osl
import "std:schema"

auto signup = schema.object({
  email: schema.string().trim(),
  newsletter: schema.boolean().defaultValue(false),
  tags: schema.oneOrMany(schema.string())
})

auto a = signup.parse({email: "  [email protected] ", tags: "math"})
log a.email "|" a.newsletter "|" a.tags
auto b = signup.parse({email: "[email protected]", newsletter: true, tags: ["x", "y"]})
log b.newsletter b.tags
Click the code to edit it

Optional, nullable and strict#

optional allows a missing key (or null), nullable allows null, and strict rejects keys the shape doesn't mention. By default unknown keys pass through, which is kind to newer clients but means you should not trust them. Use strict when you'd rather refuse.

main.osl
import "std:schema"

auto s = schema.object({id: schema.integer(), note: schema.string().optional()})
log s.isValid({id: 1})
log s.isValid({id: 1, note: null})
log s.isValid({id: 1, extra: true})
log s.strict().isValid({id: 1, extra: true})
log s.strict().parse({id: 1, note: "hi"}).note
Click the code to edit it

Try it: add .strict() to s before the first isValid.

Limits and choices#

Numbers have min, max, gt and lt. Strings, arrays and objects have minLen, maxLen and length. enum and literal accept fixed values, and union takes the first schema that matches.

main.osl
import "std:schema"

auto level = schema.enum(["low", "mid", "high"])
log level.isValid("mid") level.isValid("max")

auto id = schema.union([schema.integer(), schema.string().minLen(3)])
log id.isValid(5) id.isValid("abc") id.isValid("ab") id.isValid(true)

auto pct = schema.number().min(0).max(100)
log pct.isValid(99.5) pct.isValid(100.1)
Click the code to edit it

Try it: add "mid" to the enum values, then check "max".

Maps with record#

record is for objects whose keys you can't list but whose values all look alike, such as a map from name to score.

main.osl
import "std:schema"

auto scores = schema.record(schema.integer().min(0))
log scores.isValid({ada: 10, bob: 7})
log scores.issues({ada: 10, bob: -3, cy: "x"}).len
Click the code to edit it

Combining schemas#

Schemas are immutable: every method returns a new one. That makes it easy to build variations. extend adds fields, partial makes every field optional (useful for a PATCH request), and requireAny demands at least one of several fields.

main.osl
import "std:schema"

auto base = schema.object({name: schema.string(), email: schema.string()})
auto admin = base.extend({role: schema.enum(["admin", "owner"])})
auto patch = base.partial().requireAny(["name", "email"])

log base.isValid({name: "A", email: "a@x"})
log admin.isValid({name: "A", email: "a@x"})
log patch.isValid({email: "new@x"})
log patch.isValid({})
Click the code to edit it

Validating JSON from outside#

Put the pieces together: parse untrusted text with json, then validate before using it.

main.osl
import "std:json"
import "std:schema"

auto request = schema.object({
  action: schema.enum(["start", "stop"]),
  count: schema.integer().min(1).defaultValue(1)
})

for text in [`{"action": "start"}`, `{"action": "fly", "count": 0}`] (
  auto doc = json.parse(text).unwrap()
  auto problems = request.issues(doc)
  if problems.len > 0 (
    log "rejected:" problems.map(p -> p.path.join(".") ++ " " ++ p.message)
    continue
  )
  auto req = request.parse(doc)
  log req.action req.count
)
Click the code to edit it
Quick check

What is the difference between parse and issues?

Your turn

Check a signup

Print the path of every problem in the value, one per line, then valid or invalid.

main.osl
Ctrl Enter runs
Expected output
["age"]
["name"]
invalid

API reference#

31 members, generated from the std:schema source of OSL 9.3.0.

Functions

#schema.any() *schema.Schema

any accepts every value, null included, and returns it unchanged.

#schema.array(*schema.Schema item) *schema.Schema

array accepts arrays whose every item matches item, and returns the items as item normalises them.

#schema.boolean() *schema.Schema

boolean accepts true and false.

#schema.enum(array values) *schema.Schema

enum accepts any one of values. Throws ValueError for an empty array.

#schema.integer() *schema.Schema

integer accepts finite whole numbers.

#schema.literal(any value) *schema.Schema

literal accepts only value itself.

#schema.number() *schema.Schema

number accepts finite numbers.

#schema.object(object shape) *schema.Schema

object accepts objects whose keys match the schemas in shape; a key whose schema isn't optional is required. Keys not in shape pass through unless .strict() is used. Throws TypeError when a shape value is not a schema.

#schema.oneOrMany(*schema.Schema item) *schema.Schema

oneOrMany accepts an array of item values or a single item value, and always returns an array.

#schema.record(*schema.Schema item) *schema.Schema

record accepts objects whose every value matches item, whatever the keys.

#schema.string() *schema.Schema

string accepts strings.

#schema.union(array schemas) *schema.Schema

union accepts a value matching any of schemas, tried in order, and returns it as the first matching schema normalises it. Throws TypeError when an item is not a schema and ValueError for an empty array.

*schema.Schema

#schema.defaultValue(any value) *schema.Schema

defaultValue returns a copy of the schema that uses value in place of null or a missing key.

#schema.extend(object shape) *schema.Schema

extend returns a copy of an object schema with the fields of shape added or replaced, keeping its other modifiers. Throws TypeError on other schemas or when a shape value is not a schema.

#schema.gt(number value) *schema.Schema

gt returns a copy of a number or integer schema that requires values > value. Throws TypeError on other schemas.

#schema.isValid(any value) boolean

isValid reports whether s.parse would accept value.

#schema.issues(any value) {message: string, path: array}[]

issues validates value and lists every problem as records {path, message}, where path holds the object keys and 1-based array indexes leading to the bad value ([] for the value itself). [] means the value is valid.

#schema.length(number value) *schema.Schema

length returns a copy of a string, array or object schema that requires exactly value characters, items or keys. Throws TypeError on other schemas and ValueError unless value is a whole number >= 0.

#schema.lt(number value) *schema.Schema

lt returns a copy of a number or integer schema that requires values < value. Throws TypeError on other schemas.

#schema.max(number value) *schema.Schema

max returns a copy of a number or integer schema that requires values <= value. Throws TypeError on other schemas.

#schema.maxLen(number value) *schema.Schema

maxLen returns a copy of a string, array or object schema that allows at most value characters, items or keys. Throws TypeError on other schemas and ValueError unless value is a whole number >= 0.

#schema.min(number value) *schema.Schema

min returns a copy of a number or integer schema that requires values >= value. Throws TypeError on other schemas.

#schema.minLen(number value) *schema.Schema

minLen returns a copy of a string, array or object schema that requires at least value characters, items or keys. Throws TypeError on other schemas and ValueError unless value is a whole number >= 0.

#schema.nullable() *schema.Schema

nullable returns a copy of the schema that also accepts null.

#schema.optional() *schema.Schema

optional returns a copy of the schema that also accepts null or a missing key.

#schema.parse(any value) any

parse validates value and returns it normalised: strings trimmed where .trim() is used, defaults filled in, a oneOrMany value as an array, and keys outside a strict object's shape removed. Throws ValueError naming the path of the first problem, such as "schema.Schema.parse: users[2].name: Expected string, received number"; use try(s.parse(value)) for a result, and s.issues(value) to list every problem.

#schema.partial() *schema.Schema

partial returns a copy of an object schema with every field optional. Throws TypeError on other schemas.

#schema.requireAny(array fields) *schema.Schema

requireAny returns a copy of an object schema that requires at least one of fields to be present, even as null. Throws TypeError on other schemas.

#schema.requireAnyValue(array fields) *schema.Schema

requireAnyValue returns a copy of an object schema that requires at least one of fields to hold a non-null value. Throws TypeError on other schemas.

#schema.strict() *schema.Schema

strict returns a copy of an object schema that rejects keys not in its shape. Throws TypeError on other schemas.

#schema.trim() *schema.Schema

trim returns a copy of a string schema that trims surrounding whitespace before checking. Throws TypeError on other schemas.