schema
import {
parse,
string,
number,
boolean,
unknown,
literal,
array,
object,
optional,
tuple,
union,
taggedUnion,
} from 'mikro/schema'
import type {Infer, Schema} from 'mikro/schema'A lightweight runtime validation library for data crossing trust boundaries: CBOR-decoded protocol messages, device config payloads, or any unknown value that needs a verified shape before use.
When do I need this?
Schemas are for data whose shape you can't verify at compile time. If you're just passing values between your own functions, TypeScript's type system already has you covered. Reach for mikro/schema at the boundaries where untyped data enters your program.
When to use
- Typed storage:
nvsStorageandrtcStoragerequire a schema so values are validated when read back. Data in NVS can come from a previous firmware version; data in RTC RAM can be corrupted by a brownout. - External input: Any data from outside the runtime (HTTP responses, serial messages, config payloads)
- Shared schemas: Define a shape once, use it for both validation and type inference via
Infer
When not to use
- Internal data: Values your code just created don't need validation. Use
satisfies Infer<typeof schema>for compile-time type checking without runtime cost. - Hot loops: Validation has overhead (typeof checks, property lookups per field). For high-frequency data, validate once at the boundary, then pass typed values through.
- Complex transforms:
parse()validates shapes and nothing else: no transforms, no coercion. Defaults apply only through the separateapplyDefaults. If you need to reshape data, do that separately.
Defining schemas
Schemas are plain tagged objects. They carry no methods and cost nothing until passed to parse().
const SensorReading = object({
temperature: number(),
humidity: number(),
label: optional(string()),
})Primitives
string() // matches typeof === 'string'
number() // matches typeof === 'number'
boolean() // matches typeof === 'boolean'unknown()
Accepts any value without validation. Infers as unknown. Useful for partial validation where some fields are left unchecked.
object({type: literal('data'), payload: unknown()})literal(value)
Matches a specific primitive value using strict equality (===).
literal('error') // matches only the string 'error'
literal(42) // matches only the number 42
literal(true) // matches only truearray(element)
Matches an array where every element matches the given schema.
array(string()) // string[]
array(object({x: number(), y: number()})) // {x: number, y: number}[]object(shape)
Matches a plain object with the specified fields. Extra keys are ignored (not rejected).
object({name: string(), age: number()})Missing required fields cause a validation error. To make a field optional, wrap it with optional().
optional(schema)
Marks an object field as optional. The key may be absent from the object. If the key is present, its value must match the inner schema, except undefined, which reads the same as an absent key (JSON and CBOR cannot represent it, so the two cases are indistinguishable after a round trip anyway).
object({
name: string(),
label: optional(string()), // key may be absent, but if present must be a string
})tuple(elements)
Matches an array of exactly the given length, each position validated against its own schema.
tuple([number(), number()]) // [number, number]
tuple([string(), number()]) // [string, number]A wrong length reports "expected N elements"; a wrong element reports at its index.
union(members)
Matches if the value matches any member schema. Tries each member in order; returns ok on the first match.
union([string(), number()]) // string | numberWhen no member matches, the error reports "value did not match any union member". A union with no members matches nothing at all, so the config-schema check the CLI and the registry run rejects an empty one outright.
taggedUnion(key, branches)
Matches an object by looking up a discriminator field and validating against the corresponding branch. O(1) dispatch instead of trying every branch.
const Message = taggedUnion('type', {
error: object({message: string()}),
success: object({value: number()}),
})
// Inferred: {type: 'error', message: string} | {type: 'success', value: number}The discriminator field (type above) is injected into each branch's inferred type automatically. You don't need to include it in the branch schemas.
Annotations
Constructors take an optional trailing options object for annotations: extra properties stored on the schema node. Structural arguments stay positional; annotations trail.
Annotations come in two kinds, and the difference matters to anyone reading a published schema:
- Display annotations (
title,description,mask) describe how a value should be presented. They never change what validates, and a consumer that does not render a form may ignore any it does not recognise. - Constraints (
min,max,integer,minLength,maxLength,minItems,maxItems,format,unit) change what validates. A consumer may not ignore one it does not recognise, because ignoring a constraint means accepting a value the schema's author ruled out.
format and unit are checked only where config is written
parse() enforces every numeric and length bound wherever it runs, device included, so parse(number({max: 30}), 200) returns an error.
format is the exception. It is checked by a registry when an operator saves a value and by mikro ota pack, but not by parse(), because matching a URL or an email address needs regular expressions a microcontroller has no engine for. parse(string({format: 'ipv4'}), 'nope') returns ok. unit constrains nothing anywhere; it tells a form how to render a number.
object({
mqttUrl: string(),
interval: number({default: 60}),
logLevel: union([literal('debug'), literal('info')], {default: 'info'}),
tags: array(string(), {default: []}),
enabled: boolean({default: false}),
})default
Available on every node except object() (objects are structure; their fields carry the defaults), unknown() and optional(). The default must itself match the node; a mismatch throws a TypeError where the schema is written. optional() and default are mutually exclusive, since both define what absence means.
Defaults come in two tiers, matching how applyDefaults builds a value:
- Plain objects compose. An object materializes field by field, so its fields carry the defaults. Passing a default to
object()itself throws aTypeError. - Arrays, tuples and unions (tagged or plain) are wholesale. A present value replaces the node whole, so a default on one of them is a complete value or nothing. A union has nothing to compose from anyway: no rule can pick a member.
A default written anywhere below a wholesale unit would never fill anything, so it throws a TypeError where it is written:
// throws: a default under a taggedUnion never applies; give the union itself a
// whole-value default instead (found at .a.x)
taggedUnion('kind', {a: object({x: number({default: 1})})})
// the supported form: one whole value on the unit
taggedUnion('kind', {a: object({x: number()})}, {default: {kind: 'a', x: 1}})The rule holds through nested objects and optional(), and it rejects a nested unit's own whole-value default too, since that one never applies either.
parse() ignores defaults entirely: it validates, nothing else. Defaults take effect through applyDefaults, which builds the effective value for a partial input. This is what device config uses: the OTA registry validates the effective config on every serve and sends the deviation overlay, which the device spreads over its own manifest defaults.
Annotations serialize with the schema (JSON.stringify of a schema node is its wire form), and consumers ignore annotation properties they don't recognize.
title and description
A label and an explanatory sentence, on every node kind. A registry renders an operator-facing config form from your published schema, and without them the field names are the labels.
object({
broker: object(
{
url: string({title: 'Broker URL', description: 'Where the device publishes readings.'}),
},
{title: 'MQTT'},
),
interval: number({title: 'Poll interval', default: 60}),
})A node's title labels the control it renders, and a child's labels the child. So a union of literals gets its own title for the field and one per literal for each choice, and a taggedUnion's title labels the selector while each branch object's title labels that branch. Titles are capped at 80 characters and descriptions at 500.
They go on the node they describe, never on the optional() wrapper: absence is what optional() expresses, identity is what the node it wraps expresses.
mask
On string() and number(): do not display this value in cleartext. A form renders a password input, and anything else that prints a config document redacts.
That is all it does. It is not a security feature. Config values are plaintext end to end, stored in the registry, carried in the clear inside TLS on the check-in response, and held in device NVS as plaintext. Credentials belong in env vars, which are set over the cable and never travel through a registry. A masked field cannot carry a default, since a default credential is the same placeholder on every device.
Constraints
number() takes min, max and integer; string() takes minLength and maxLength; array() takes minItems and maxItems.
object({
pin: number({title: 'LED pin', default: 15, min: 0, max: 30, integer: true}),
ssid: string({minLength: 1, maxLength: 32}),
topics: array(string(), {maxItems: 8}),
})These are what stop an operator saving a value the device cannot survive. A registry checks every write against the published schema, and that is the only gate before a value reaches a device, so a bound that is not in the schema is not a bound at all.
They catch typos and magnitude errors, not board-specific validity. A usable GPIO set differs per chip and one schema is authored for every chip an app targets, so min and max on a pin are an approximation and your app should still check its pins at runtime.
A default must satisfy its own constraints, and a default that breaks its own bound throws a TypeError where the schema is written, the same as one of the wrong type.
format
A named shape for a string, from a closed set: url, hostname, ipv4, mac, email.
object({
broker: string({format: 'url'}),
fallback: string({format: 'ipv4', default: '192.168.1.1'}),
})There is deliberately no pattern. A registry evaluates your published schema against operator input, so a caller-supplied regular expression would hand anyone who can publish a denial-of-service vector against the registry. These expressions are fixed and linear.
url accepts any scheme with an authority, not only http and https, because mqtt:// and ws:// are ordinary device-config values. Restricting the scheme is not expressible today.
unit
The unit of a number, from a closed set, so a form can show it, pick a sensible control and offer a readable hint next to an awkward magnitude.
object({
interval: number({title: 'Check-in interval', unit: 'ms', default: 60_000}),
clock: number({unit: 'kHz', default: 400}),
setpoint: number({unit: 'Cel', default: 21}),
})The set is the IANA SenML Units and Secondary Units registries, minus the entries SenML marks as not recommended for new producers, plus the microcontroller units its registry lacks (us, kHz, mW, uA, mAh, MiB, kohm, Bd and others). Keys are ASCII; a renderer maps them to a display symbol, so Cel shows as °C, us as µs and Ohm as Ω.
A ratio from 0 to 1 is /, which is the registry's name for a dimensionless value. A form renders it with no suffix, since 0.8 / would mean nothing to an operator. count behaves the same way. Use / for a duty cycle or a gain, and /100 for a field an operator thinks of as 0 to 100.
Two symbols do not mean what you would expect:
- SenML's
%is not a percentage. It is a synonym for the ratio/(0 to 1), and the RFC says so explicitly, so it is excluded here. A 0-100 field uses/100, which renders as%. - There is no
dfor day. A baredis the SI deci- prefix, which the SenML naming rules forbid as a standalone symbol. Useh.
The unit describes the stored value and never converts it. Declare the unit your app actually reads. If an operator should think in minutes, declare the field in minutes and multiply once where you read it, rather than storing seconds and displaying minutes.
Changing a field's unit in a later release is a breaking change even though nothing fails validation: 30 means one thing under s and another under ms, so every value already stored is silently reinterpreted.
enumOf(entries)
A closed list of values with a label for each, which is what a form renders as a select or a radio group.
const schema = object({
logLevel: enumOf(
[
{value: 'debug', title: 'Debug', description: 'Verbose; not for production.'},
{value: 'info', title: 'Info'},
{value: 'warn', title: 'Warning'},
],
{default: 'info'},
),
})It is sugar, not a node kind: it builds a union of annotated literals, so it validates and serializes exactly as one. Use union([literal(...)]) directly when the values need no labels. The labels are what this adds. It is spelled enumOf because enum is a reserved word and an export named enum could not be imported under its own name.
Validation
parse(schema, value)
Validates value against schema. Returns a Result: ok with the typed value, or err with a SchemaError describing what went wrong and where.
const Device = object({
chip: string(),
id: optional(string()),
})
const result = parse(Device, {chip: 'esp32c6'})
if (result.ok) {
result.value.chip // string
result.value.id // string | undefined
}Validation is fail-fast: it stops at the first error.
parse() checks structure and every bound, but not format. parse(number({max: 30}), 200) reports above the maximum of 30; parse(string({format: 'ipv4'}), 'nope') returns ok. See Constraints.
applyDefaults(schema, value)
Builds the effective value for a partial input: schema defaults with value layered over them. Objects materialize, recursing per field and dropping keys the schema doesn't know (a present value that is not an object passes through untouched, so parse() still reports it). Every other node is replaced wholesale by a present value, which is why only a whole-value default on a unit applies (a default written below one is rejected where it is written). An absent array reads as its default, or [] without one.
const schema = object({host: string({default: 'mqtt.local'}), port: number({default: 1883})})
applyDefaults(schema, {port: 9000}) // {host: 'mqtt.local', port: 9000}
applyDefaults(schema, undefined) // {host: 'mqtt.local', port: 1883}The result is unvalidated. A required field with no default and no supplied value stays missing, so run the result through parse() to get a typed value:
const config = parse(schema, applyDefaults(schema, {port: 9000}))Error reporting
Errors include the path to the offending value using dot-bracket notation:
const schema = object({
items: array(object({name: string()})),
})
const result = parse(schema, {items: [{name: 'ok'}, {name: 42}]})
if (!result.ok) {
result.error.message // 'expected string, got number'
result.error.path // '.items[1].name'
}Type inference
Use Infer<S> to extract the TypeScript type from a schema without calling parse:
const Device = object({
chip: string(),
version: number(),
label: optional(string()),
})
type Device = Infer<typeof Device>
// {chip: string, version: number, label?: string | undefined}This is purely a compile-time operation. No runtime cost.
InferRead: the read type
Infer<S> is the write type: everything an operator has to supply for a document to validate. InferRead<S> is the read type, what a document rebuilt from defaults can hand back, so a field defaults cannot fill is optional there. This is the shape device config is read as:
const Config = object({
interval: number({default: 60}),
apiKey: string(),
})
type Write = Infer<typeof Config>
type Read = InferRead<typeof Config>
A field stays required in the read type when the materialized defaults always contain it: any node carrying a default, or a plain object whose fields all fill (or are optional()), which InferRead recurses into. The rest read as optional: a defaultless leaf, a defaultless array, a wholesale unit with no whole-value default, and a plain object with any unfillable field, which is omitted whole. optional() fields are optional in both types.
Types
Schema
The union of all schema node types. Use this as a constraint when writing functions that accept any schema:
type Schema =
| StringSchema
| NumberSchema
| BooleanSchema
| UnknownSchema
| LiteralSchema
| ArraySchema
| ObjectSchema
| OptionalSchema
| TupleSchema
| UnionSchema
| TaggedUnionSchemaSchemaError
type SchemaError = {name: 'ValidationFailed'; message: string; path: string}Created via SchemaError.ValidationFailed(message, path).