Skip to content

Structured Data

Real inputs are usually objects containing optional fields, collections, and variant values. Build those schemas from small child pipelines, then let the structural step compose their outputs and issues.

Start from the output you need

The checked example below accepts an order-like object, normalizes nested strings and a loose numeric quantity, preserves the declared optional note property, and constrains status to two branches.

ts
import { v } from 'valchecker'

export const orderSchema = v.object({
	id: v.string()
		.toTrimmed()
		.isNotEmpty(),
	items: v.array(v.object({
		sku: v.string()
			.toTrimmed()
			.isNotEmpty(),
		quantity: v.looseNumber()
			.isFinite()
			.isInteger()
			.isAtLeast(1),
	}))
		.isNotEmpty(),
	note: [v.string()
		.toTrimmed()],
	status: v.union([
		v.literal('draft'),
		v.literal('submitted'),
	]),
})

Its runtime test locks the resulting value, including note: undefined when that declared optional property is absent.

Choose the structural boundary deliberately

NeedStructural toolReader-level intent
Produce only the declared shapeobject()Parse a known object contract and omit unrelated input properties from the output.
Reject unexpected own enumerable keysstrictObject()Treat extra keys as invalid input.
Preserve unknown own propertieslooseObject()Validate known fields while carrying unrelated properties through.
Validate repeated itemsarray()Apply one child schema to each array item.
Accept one of several schema shapesunion()Try declared branches in order and return the first successful branch output.

Those links own the exact unknown-key, issue, and parameter contracts. The application decision is which boundary matches the data you intend to expose downstream.

Compose field pipelines before the object

Keep normalization and validation close to the field they describe:

ts
import { v } from 'valchecker'

const profileSchema = v.object({
	displayName: v.string()
		.toTrimmed()
		.isNotEmpty(),
	tags: v.array(v.string()
		.toLowercase())
		.isLengthAtMost(10),
})

The outer object composes those finished child pipelines. It should not become a second place to restate every string or array rule.

Use optional fields for absence, not invalid values

A one-element tuple marks an object property as optional:

ts
const accountSchema = v.object({
	id: v.string(),
	nickname: [v.string()
		.toTrimmed()],
})

That means the property may be absent. It does not make an invalid present value valid. If nickname exists, its child schema still runs normally.

Use Fallback and Recovery when the requirement is instead to replace a recoverable failure with an intentional value.

Model variants with schemas, not post-parse switches

A union is useful when each variant has a distinct validated shape:

ts
const eventSchema = v.union([
	v.object({
		type: v.literal('click'),
		x: v.number()
			.isFinite(),
		y: v.number()
			.isFinite(),
	}),
	v.object({
		type: v.literal('keypress'),
		key: v.string()
			.isNotEmpty(),
	}),
])

When a union fails, branch provenance is context rather than data location. See Issues and Paths for that distinction.

Keep leaf details in Reference

A guide should explain composition decisions, not enumerate every primitive constraint. Use the generated API Reference for exact built-in step contracts, and Types and Runtime Behavior when the question is how the composed schema affects inferred input/output types.