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' becomes 42, 'true' becomes true, 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:

routes/products.tsx
import * as  from 'kvantjs/schema'
import {  } from 'kvantjs'

const  = .({
  : .().(''),
  : .().(0),
})

export async function ({  }: Route.) {
  const { ,  } = .(
    (.)
  )!
  // ...
}

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)     // undefined

Chainable 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 strings

Chained 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.14

Chainable 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')   // undefined

kv.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)   // 1

Negative 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)          // false

kv.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')  // undefined

Extract the schema's values as an enum-like object:

const values = .
const values: {
    newest: "newest";
    oldest: "oldest";
    popular: "popular";
}

Derive narrower enums:

const extracted = .(['newest', 'oldest'])
const extracted: kv.KvantEnum<{
    newest: "newest";
    oldest: "oldest";
}>
const excluded = .(['oldest'])
const excluded: kv.KvantEnum<{
    newest: "newest";
    popular: "popular";
}>

Unknowns

kv.unknown()

Pass any value through unchanged. Inferred type is unknown.

const value = .().({ : 1 }) // { a: 1 }
const value: unknown

kv.any()

Pass any value through unchanged. Inferred type is any.

const value = .().({ : 1 }) // { a: 1 }
const value: any

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')                          // undefined

kv.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)) // 1771151400000

kv.epochSecondsToDate()

Unix timestamps in seconds to Date.

.().('1771151400')             // Date (2026-02-15T10:30:00.000Z)
.().(new (1771151400000)) // 1771151400

Composites

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 }
const filters: {
    q: string;
    page: number;
    tags: string[];
} | undefined

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 = ..
const qSchema: kv.KvantDefault<kv.KvantString, "", kv.KvantDefaultOptions<kv.KvantString, "">>

Get an enum schema of the property keys via .keyof():

const keys = .() // like kv.enum(['q', 'page', 'tags'])
const keys: kv.KvantEnum<{
    q: "q";
    page: "page";
    tags: "tags";
}>
.('page') // 'page' .('random') // undefined

Merge new properties into the object via .extend(). Later schemas override existing keys:

const filters = .({ : .(['asc', 'desc']) }).({})
const filters: {
    q: string;
    page: number;
    tags: string[];
    sort?: "asc" | "desc" | undefined;
} | undefined

Like .extend(), but restrict overriding keys to schemas with compatible input/output types, via .safeExtend():

.({ : .().(1).('') })

.({ q: .() })
Type 'KvantNumber' is not assignable to type 'never'.

Pick a subset of properties via .pick():

const filters = .({ : true, : true }).({})
const filters: {
    q: string;
    tags: string[];
} | undefined

Omit properties via .omit():

const filters = .({ : true }).({})
const filters: {
    q: string;
    tags: string[];
} | undefined

Make properties optional via .partial():

const allOptional = .().({})
const allOptional: {
    q?: string | undefined;
    page?: number | undefined;
    tags?: string[] | undefined;
} | undefined
const qOptional = .({ : true }).({})
const qOptional: {
    page: number;
    tags: string[];
    q?: string | undefined;
} | undefined

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' }
const filters: {
    [x: string]: string | undefined;
    q: string;
    page: number;
    tags: string[];
} | undefined

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 filters: {
    [x: string]: unknown;
    q: string;
    page: number;
} | undefined
const encoded = .({ : 'shoes', : 1, : 'red' }) // { q: 'shoes', page: 2, color: 'red' }
const encoded: {
    [x: string]: unknown;
    q?: string | undefined;
    page?: number | undefined;
} | undefined

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]
const numbers: number[] | undefined

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 numbers: number[] | undefined
const looseNumbers = .(.()).(['1', 'invalid', '3']) // [1, undefined, 3]
const looseNumbers: (number | undefined)[] | undefined

Alternatively you can reject the whole array if any element parses to undefined with a simple .transform():

const  = .(.())
  .( => ?.( =>  !== ) ?  : )

const numbers = .(['1', 'invalid', '3']) // undefined
const numbers: number[] | undefined

Chainable 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]
const range: [number, (number | undefined)?] | undefined

Variadic rest argument:

const  = .([.().('users')], .())

const path = .(['users', '42', '7'])
const path: [string, ...(number | undefined)[]] | undefined

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 ids: Set<number> | undefined
const encoded = .(new ([1, 2])) // [1, 2]
const encoded: number[] | undefined

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 ids: Set<number> | undefined
const looseIds = .(.()).(['1', 'invalid', '3']) // Set { 1, undefined, 3 }
const looseIds: Set<number | undefined> | undefined

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 }
const cache: Record<string, number | undefined> | undefined

With an enum key schema:

const  = .(.(['id', 'name']), .())

const user = .({ : '42', : 'Alice' })
const user: Record<"id" | "name", string | undefined> | undefined

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' })
const user: Partial<Record<"id" | "name", string | undefined>> | undefined

kv.map(key, value)

Like kv.record(), but produces a Map.

const  = .(.(), .())

const votes = .({ : '1', : '2' }) // Map { 'a' => 1, 'b' => 2 }
const votes: Map<string, number> | undefined
const encoded = .(new ([['a', 1]])) // { a: 1 }
const encoded: Partial<Record<string, number | undefined>> | undefined

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 loosePage: number | undefined
const page = .().(0).('invalid') // 0
const page: number

By 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 internal
const encoded: number | undefined

To opt out of clearing pass { clearOnDefault: false }:

const  = .().(0, { : false })

const encoded = .(0) // 0, default value is written to storage
const encoded: number

.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
const value: string | undefined

.nullable()

Makes a schema nullable (allows null to pass through):

const  = .().('').()

const value = .(null) // null
const value: string | null

.nullish()

Makes a schema nullish (both optional and nullable):

const  = .().('').()

const value = .(null) // null
const value: string | null | undefined

.singular(index?)

Picks one element when the raw value is an array:

.().(['1', '2']) // undefined, because the schema expects a single value
.().().(['1', '2']) // 1

Defaults to the first element. Pass a negative index (via .at() semantics) or a function for custom picking:

.().(-1).(['1', '2'])    // 2
.().( => . - 1)   // last element

Useful 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 settings: {
    theme: "light" | "dark";
} | undefined
const encoded = .({ : 'dark' }) // '{"theme":"dark"}'
const encoded: string | undefined

.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 settings: {
    theme: "light" | "dark";
} | undefined
const encoded = .({ : 'dark' }) // 'eyJ0aGVtZSI6ImRhcmsifQ'
const encoded: string | undefined

.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 looseTags: (string | undefined)[] | undefined
const tags = .( => ?.( => !== )).(['a', {}, 'c']) // ['a', 'c']
const tags: string[] | undefined

For asymmetric transforms, pass { decode, encode }:

const  = .().({
  :  => ?.(',').(),
  :  => ?.(','),
})

const values = .('a,b,c') // ['a', 'b', 'c']
const values: string[] | undefined
const csv = .(['a', 'b']) // 'a,b'
const csv: string | undefined

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) => ( ?? '').(','),
raw: unknown
: () => ?.(','), }, .(.()), ) .(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)
Argument of type 'number' is not assignable to parameter of type 'string'.
.('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') // undefined

You 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') // 10

This 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 >
type Output = number
// Stored input type (what encode() returns) type Input = .<typeof >
type Input = number | undefined
// Raw storage type (what parse() accepts, unknown by default for most schemas) type RawInput = .<typeof >
type RawInput = unknown

kv.KvantType<Output, Input, RawInput> is the fluent interface every schema implements. You can extend it when typing your own schema factories.

On this page