# schema

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

This is the Markdown version of https://osl-lang.org/packages/schema. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
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>`](/packages/json.md) 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.

```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"})
```

Output:

```text
Ada 36
false
false
```

`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](/learn/errors.md) instead.

```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
)
```

Output:

```text
ValueError
schema.Schema.parse: age: Expected number, received string
```

## 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.

```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
```

Output:

```text
["customer"] Expected length at least 2
["items",2,"qty"] Expected at least 1
["items",3,"sku"] Required
0
```

## 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.

```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: "  ada@example.com ", tags: "math"})
log a.email "|" a.newsletter "|" a.tags
auto b = signup.parse({email: "bob@example.com", newsletter: true, tags: ["x", "y"]})
log b.newsletter b.tags
```

Output:

```text
ada@example.com | false | ["math"]
true ["x","y"]
```

## 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.

```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
```

Output:

```text
true
true
true
false
hi
```

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.

```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)
```

Output:

```text
true false
true true false false
true false
```

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.

```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
```

Output:

```text
true
2
```

## 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.

```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({})
```

Output:

```text
true
false
true
false
```

## Validating JSON from outside

Put the pieces together: parse untrusted text with [json](/packages/json.md), then validate before using it.

```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
)
```

Output:

```text
start 1
rejected: ["action Expected one of [start stop], received fly","count Expected at least 1"]
```

**Quiz.** What is the difference between `parse` and `issues`?

- [ ] `parse` returns a result, `issues` throws.
- [x] `parse` throws on the first problem, `issues` lists every problem.
- [ ] They do the same thing.

Answer: Use `issues` when the person needs to fix a whole form at once. `parse` stops at the first problem.

### Exercise: Check a signup

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

Starting code:

```osl
import "std:schema"

auto signup = schema.object({
  name: schema.string().minLen(2),
  age: schema.integer().min(0)
})
log signup.isValid({name: "A", age: -1})
```

It should print:

```text
["age"]
["name"]
invalid
```

Hint 1: `signup.issues(value)` returns a list of records with a `path`.

Hint 2: `isValid` gives the final word.

A solution:

```osl
import "std:schema"

auto signup = schema.object({
  name: schema.string().minLen(2),
  age: schema.integer().min(0)
})
auto value = {name: "A", age: -1}
for p in signup.issues(value) ( log p.path )
if signup.isValid(value) ( log "valid" ) else ( log "invalid" )
```

## API reference

## API reference

Every member of `std:schema`, generated from the source of OSL.

### 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.
