kvantjs/schema
The built-in bidirectional schema library for kvant.
Why schemas?
Storage values are untyped: URLs, cookies, web storage — all hold plain strings. A schema defines the contract between the stored representation and your typed state:
parse(value): storage to state. Called on reads.encode(value): state to storage. Called on writes.
Without a schema, values pass through as-is.
With one, useSearchParams('count', kv.number()) hands you a real number
and writes a canonical string back to the URL.
Why kvantjs/schema?
kvantjs/schema is a small schema library purpose-built to work with type-constrained key-value interfaces.
If you know Zod, you already know most of the API:
.().(1).(1)What makes it different from Zod & friends:
- Bidirectional. Every schema both parses and encodes. Validation-only libraries can't serialize your state back to a string.
- Encodes to serializable values. Guarantees that any valid state value can be stored in type-constrained key-value interfaces.
- Smart casting. Schemas accept loosely-typed raw input and cast it:
'42'becomes42,'true'becomestrue, etc. There is no strict "input must already be a specific type" stage. - No errors by design. Schemas never throw. Invalid input parses to
undefined(or your default), which is exactly the resilience you want when reading user-editable URLs and cookies.
kvantjs/schema is framework-agnostic. Import it anywhere:
import * as kv from 'kvantjs/schema'kvantjs/schema is not tied to kvant hooks. It works anywhere you need
bidirectional parsing or smart casting, including server-side code:
import * as from 'kvantjs/schema'
import { } from 'kvantjs'
const = .({
: .().(''),
: .().(0),
})
export function (: URL) {
return .(
(.)
)
}Prefer to use Zod or another validation library? See Zod and Custom Schema.
API
Strings
kv.string()
Parses raw input via String() constructor.
.().('hello') // 'hello'
.().(42) // '42'
.().(null) // undefinedChainable filters:
.().(3) // rejects strings shorter than 3 chars
.().(20) // rejects strings longer than 20 chars
.().(8) // exact length
.().() // alias of .min(1)
.().(/^[a-z]+$/)
.().('sk_')
.().('.json')
.().('@')
.().() // rejects non-uppercase strings
.().() // rejects non-lowercase stringsChained constraints like .min(), .regex(), are filters,
not validators: a rejected value falls back to undefined (or your default) rather than
producing an error. This is a deliberate difference from Zod: kvantjs/schema
never fails, because the storage it reads (URLs, cookies) is user-editable and
must degrade gracefully.
Mutating transforms:
.().()
.().()
.().()
.().() // Unicode normalization
.().(0, 8)Mutating transforms are applied in both directions (parse and encode), so the stored value is always canonical.
kv.uriComponent()
Decodes with decodeURIComponent(),
encodes with encodeURIComponent().
Malformed escape sequences parse to undefined.
.().('Hello%20World%21') // 'Hello World!'
.().('Hello World!') // 'Hello%20World!'kv.base64()
Decodes/encodes Base64 strings (atob()/btoa()).
Invalid Base64 parses to undefined.
.().('SGVsbG8=') // 'Hello'
.().('Hello') // 'SGVsbG8='kv.base64url()
URL-safe Base64 (RFC 4648 §5):
-/_ alphabet, no padding. Ideal for URLs and cookies.
.().('SGVsbG8') // 'Hello'
.().('Hello') // 'SGVsbG8'Numbers
kv.number()
Smart-casts raw input with Number.parseFloat().
NaN and non-finite values parse to undefined.
.().('3.14') // 3.14
.().('abc') // undefined
.().(3.14) // 3.14Chainable filters:
.().(0)
.().(0) // alias: .min(0)
.().(0)
.().(0) // alias: .max(0)
.().()
.().()
.().()
.().()
.().(5) // alias: .step(5)Mutating transforms:
.().(0, 100) // clamps into [min, max], .clamp(max) clamps above only
.().()
.().()
.().()
.().()kv.int()
An integer schema: parses like kv.number(), then truncates toward zero and
clamps into the safe integer range.
.().('42.9') // 42 (truncated)
.().('abc') // undefinedkv.index()
Zero-based state mapped to a one-based stored value. Useful for page
numbers, where ?page=1 should read as index 0.
.().('1') // 0
.().('3') // 2
.().(0) // 1Negative state values are clamped to 0, stored values below 1 clamp to 0
in state.
kv.hex()
Hexadecimal string to an integer. Encodes with an even digit count.
.().('ff') // 255
.().(255) // 'ff'
.().(10) // '0a'Booleans
kv.boolean()
Parses truthiness via Boolean() constructor.
This means the string 'false' parses to true. For string representations of booleans, use
kv.stringbool() instead.
.().('anything') // true
.().('') // false
.().(1) // true
.().(0) // falsekv.stringbool()
Parses common string representations of booleans. Case-insensitive by default.
.().('true') // true
.().('yes') // true
.().('0') // false
.().('maybe') // undefined
.().(true) // 'true'
.().(false) // 'false'Defaults:
- Truthy values:
'true','1','yes','on','y','enabled'. - Falsy values:
'false','0','no','off','n','disabled'.
Customize the recognized strings:
const = .({
: ['on'],
: ['off'],
: 'sensitive', // default: 'insensitive'
})When encoding, the first entry of each list is used:
.(true) // 'on'Enums
kv.enum()
Validates against a fixed set of string values. Accepts an array or an enum-like object (including TypeScript Enums):
const = .(['newest', 'oldest', 'popular'])
// or: kv.enum(SortOrder) / kv.enum({ Newest: 'newest', ... })
.('newest') // 'newest'
.('random') // undefinedExtract the schema's values as an enum-like object:
const values = .Derive narrower enums:
const extracted = .(['newest', 'oldest'])
const excluded = .(['oldest'])Unknowns
kv.unknown()
Pass any value through unchanged. Inferred type is unknown.
const value = .().({ : 1 }) // { a: 1 }kv.any()
Pass any value through unchanged. Inferred type is any.
const value = .().({ : 1 }) // { a: 1 }Because unknown values are not parsed, kv.unknown() and kv.any() cannot guarantee
they are serializable. Prefer using an explicit schema if you plan to use it for encoding.
Dates
Date schemas parse stored strings/numbers into Date
objects and serialize them back.
Chainable filters:
.().(new ('2020-01-01'))
.().(new ('2030-01-01'))Mutating transforms:
.().(new ('2020-01-01'), new ('2030-01-01'))kv.isoDatetimeToDate()
Full ISO 8601 datetime strings to Date.
.().('2026-02-15T10:30:00.000Z') // Date (2026-02-15T10:30:00.000Z)
.().(new ('2026-02-15T10:30:00.000Z')) // '2026-02-15T00:00:00.000Z'
.().('not a date') // undefinedkv.isoDateToDate()
Shorter ISO format YYYY-MM-DD to Date.
.().('2026-02-15T10:30:00.000Z') // Date (2026-02-15T00:00:00.000Z)
.().(new ('2026-02-15T10:30:00.000Z')) // '2026-02-15'kv.isoYearMonthToDate()
Shorter ISO format YYYY-MM to Date.
.().('2026-02-15T10:30:00.000Z') // Date (2026-02-01T00:00:00.000Z)
.().(new ('2026-02-15T10:30:00.000Z')) // '2026-02'kv.isoYearToDate()
Shorter ISO format YYYY to Date.
.().('2026-02-15T10:30:00.000Z') // Date (2026-01-01T00:00:00.000Z)
.().(new ('2026-02-15T10:30:00.000Z')) // '2026'kv.epochMillisToDate()
Unix timestamps to Date.
.().('1771151400000') // Date (2026-02-15T10:30:00.000Z)
.().(new (1771151400000)) // 1771151400000kv.epochSecondsToDate()
Unix timestamps in seconds to Date.
.().('1771151400') // Date (2026-02-15T10:30:00.000Z)
.().(new (1771151400000)) // 1771151400Composites
kv.object(shape)
Parses plain objects property-by-property. Each property schema receives the raw value at its key.
const = .({
: .().(''),
: .().(0),
: .(.()).([]),
})
const filters = .({ : 'shoes', : '2' }) // { q: 'shoes', page: 1 }Properties that parse to undefined are kept as explicit
undefined if the key was present in the input, and omitted otherwise.
Access the property schemas via .shape:
const qSchema = ..Get an enum schema of the property keys via .keyof():
const keys = .() // like kv.enum(['q', 'page', 'tags'])
.('page') // 'page'
.('random') // undefinedMerge new properties into the object via .extend(). Later schemas
override existing keys:
const filters = .({ : .(['asc', 'desc']) }).({})Like .extend(), but restrict overriding keys to schemas with compatible
input/output types, via .safeExtend():
.({ : .().(1).('') })
.({ q: .() })Pick a subset of properties via .pick():
const filters = .({ : true, : true }).({})Omit properties via .omit():
const filters = .({ : true }).({})Make properties optional via .partial():
const allOptional = .().({})
const qOptional = .({ : true }).({})Define a schema for unrecognized keys via .catchall(). Instead of being
stripped, unknown keys are parsed (and encoded) through it:
const = .(.())
const filters = .({ : 'shoes', : 'red' }) // { q: 'shoes', page: 0, tags: [], color: 'red' }Unknown keys are stripped by default. If you need to keep the unknown keys, use kv.looseObject():
const = .({
: .().(''),
: .().(0),
})
const filters = .({ : 'shoes', : 'red' }) // { q: 'shoes', page: 0, color: 'red' }
const encoded = .({ : 'shoes', : 1, : 'red' }) // { q: 'shoes', page: 2, color: 'red' }Because unknown values are not parsed, kv.looseObject() cannot guarantee
they are serializable. Prefer .catchall() with an explicit
schema if you plan to use it for encoding.
kv.array(schema)
Wraps a schema into an array of itself:
const = .(.())
// or: kv.string().array()
const numbers = .(['1', '2', '3']) // [1, 2, 3]A single (non-repeated) raw value is
wrapped into a one-item array, handy for interfaces that may repeat a key,
like search params (?numbers=3.14):
numbersSchema.parse('3.14') // [3.14]kv.array() automatically filters out elements that parse to undefined from the output array by default.
If you need to preserve undefined elements, use kv.looseArray() instead:
const numbers = .(.()).(['1', 'invalid', '3']) // [1, 3]
const looseNumbers = .(.()).(['1', 'invalid', '3']) // [1, undefined, 3]Alternatively you can reject the whole array if any element parses to undefined
with a simple .transform():
const = .(.())
.( => ?.( => !== ) ? : )
const numbers = .(['1', 'invalid', '3']) // undefinedChainable filters:
.(.()).() // alias: .min(1)
.(.()).(1)
.(.()).(5)
.(.()).(3)Mutating transforms:
.(.()).(0, 10)kv.tuple(items, rest?)
Fixed-length array with per-position schemas. Trailing items whose schema
parses to undefined are omitted from the output.
const = .([.().(0), .()])
const range = .(['0', '100']) // [0, 100]Variadic rest argument:
const = .([.().('users')], .())
const path = .(['users', '42', '7'])kv.set(schema)
A Set
of the inner schema's outputs. Parses from single or repeated raw values. Encodes to an array.
const = .(.())
const ids = .(['1', '2', '2']) // Set { 1, 2 }
const encoded = .(new ([1, 2])) // [1, 2]kv.set() automatically filters out elements that parse to undefined from the output set by default.
If you need to preserve undefined elements, use kv.looseSet() instead:
const ids = .(.()).(['1', 'invalid', '3']) // Set { 1, 3 }
const looseIds = .(.()).(['1', 'invalid', '3']) // Set { 1, undefined, 3 }Chainable filters:
.(.()).() // alias: .min(1)
.(.()).(1)
.(.()).(5)
.(.()).(3)Mutating transforms:
.(.()).(0, 10)kv.record(key, value)
Object schema whose keys and values are (de)serialized through schemas.
const = .(.(), .())
const cache = .({ : '1', : '2' }) // { a: 1, b: 2 }With an enum key schema:
const = .(.(['id', 'name']), .())
const user = .({ : '42', : 'Alice' })kv.record() requires all enum keys to be
present (missing keys parse to undefined values).
Use kv.partialRecord() to allow missing keys:
const = .(.(['id', 'name']), .())
const user = .({ : '42', : 'Alice' })kv.map(key, value)
Like kv.record(), but produces a Map.
const = .(.(), .())
const votes = .({ : '1', : '2' }) // Map { 'a' => 1, 'b' => 2 }
const encoded = .(new ([['a', 1]])) // { a: 1 }Wrappers & combinators
All schemas expose these chainable methods:
.default(value, options?)
Falls back to value when parsing yields undefined. The default can be a
plain value or a factory function (called per parse).
const loosePage = .().('1')
const page = .().(0).('invalid') // 0By default, when the state equals the default, the stored value is cleared, so defaults never end up written to the URL or cookies:
const = .().(0)
const encoded = .(0) // undefined, default value stays internalTo opt out of clearing pass { clearOnDefault: false }:
const = .().(0, { : false })
const encoded = .(0) // 0, default value is written to storage.default() checks equality using Object.is()
by default. For custom equality checks (e.g. for arrays/objects), pass a custom isDefault function:
.(.()).([], {
: (, ) => ?. === .
&& .((, ) => === []),
}).prefault(value, options?)
Unlike .default(), .prefault() doesn't stop the parsing process
to eagerly return a default value, when the input is undefined, but instead
the default value is given as an input for the wrapped schema.
Use it when you want it to go through transforms, or when the default value is easier to express in its stored form:
const = .().('2024-01-01')
.() // Date('2024-01-01').optional()
Makes a schema optional (allows undefined to pass through):
const = .().('').()
const value = .() // undefined.nullable()
Makes a schema nullable (allows null to pass through):
const = .().('').()
const value = .(null) // null.nullish()
Makes a schema nullish (both optional and nullable):
const = .().('').()
const value = .(null) // null.singular(index?)
Picks one element when the raw value is an array:
.().(['1', '2']) // undefined, because the schema expects a single value
.().().(['1', '2']) // 1Defaults to the first element. Pass a negative index (via .at() semantics)
or a function for custom picking:
.().(-1).(['1', '2']) // 2
.().( => . - 1) // last elementUseful for interfaces that allow repeated keys (e.g. search params) when you only care about one value:
useSearchParams('page', kv.index().singular())kv.json(schema, options?)
JSON string to typed value. Parses with JSON.parse(),
then validates the result against the inner schema. Encodes with
JSON.stringify().
const = .({
: .(['light', 'dark']).('light'),
})
const = .()
const settings = .('{"theme":"dark"}') // { theme: 'dark' }
const encoded = .({ : 'dark' }) // '{"theme":"dark"}'.pipe(schema)
Chains two schemas: b parses a's output. Encoding runs in reverse
(b encodes first, then a).
const = .({
: .(['light', 'dark']).('light'),
})
const = .()
.(.())
const settings = .('eyJ0aGVtZSI6ImRhcmsifQ') // { theme: 'dark' }
const encoded = .({ : 'dark' }) // 'eyJ0aGVtZSI6ImRhcmsifQ'.transform(fn)
Pipes the schema into a bidirectional transformation.
A bare function applies to both directions and its output type must be assignable to the input type:
const = .(.())
const looseTags = .(['a', {}, 'c']) // ['a', undefined, 'c']
const tags = .( => ?.( => !== )).(['a', {}, 'c']) // ['a', 'c']For asymmetric transforms, pass { decode, encode }:
const = .().({
: => ?.(',').(),
: => ?.(','),
})
const values = .('a,b,c') // ['a', 'b', 'c']
const csv = .(['a', 'b']) // 'a,b'kv.preprocess(def, schema)
Transforms the raw input before the schema parses it, and the raw output
after encoding. Since the preprocessor is expected to handle arbitrary raw
storage values, its decode input type is unknown by default:
const = .(
{
: (raw) => ( ?? '').(','), : () => ?.(','),
},
.(.()),
)
.(3.14) // ['3.14']
.('a,b,c') // ['a', 'b', 'c']
.(['a', 'b']) // 'a,b'To narrow the input type and get proper typing, annotate it explicitly:
const = .(
{
: (: string | undefined) => ?.(','),
: () => ?.(','),
},
.(.()),
)
.(3.14).('a,b,c') // ['a', 'b', 'c']
.(['a', 'b']) // 'a,b'.overwrite(def)
Transforms the output in place (after parse and before encode) without changing the schema's types. This allows you to transform a value while keeping access to the original schema's methods and properties.
A bare function applies to both directions and must be safe to run on its own output (idempotent):
.()
.( => .(25, '.'))
.()
.()Pass { decode, encode } to transform each direction separately:
.()
.({
: => - 1,
: => + 1,
})
.(0).refine(check, fallback?)
Keeps values passing check. The check runs in both directions.
Rejected values fall back to undefined by default:
const = .().( => >= 1 && <= 5)
.('9') // undefinedYou can specify a custom fallback value to use when the check fails as a second argument
(which is required, if the output type of the inner schema doesn't allow undefined):
const = .().(0).( => <= 10, 10)
.('99') // 10This is what powers built-in chainable filters (.min(), .max(), etc.).
kv.custom(def)
Builds a schema from plain parse/encode functions:
const = .({
: (: string) => {
const = ()
return .() && % 2 === 0 ? :
},
: (: number | undefined) => != null ? () : ,
})Use it if you want to integrate kvantjs/schema's built-in wrappers & combinators
with your own parsing logic.
.apply(fn)
Passes the schema to a function and returns its result. Useful for custom reusable combinators:
const = (: .) => {
return .(0, 100).(5)
}
.().()Type utilities
const = .().(0)
// Output type (state), alias: `kv.infer`
type Output = .<typeof >
// Stored input type (what encode() returns)
type Input = .<typeof >
// Raw storage type (what parse() accepts, unknown by default for most schemas)
type RawInput = .<typeof >kv.KvantType<Output, Input, RawInput> is the fluent interface every schema
implements. You can extend it when typing your own schema factories.