How-to › Parse, validate and transform data

How to write a validator whose schema looks like the data#

Write the schema as an example of an accepted value, with constructors for types and literals for defaults, so schema and sample read side by side.

Audience
API producer
Level
beginner
Topic
Validate data shapes
Languages
TypeScript and JavaScript
Verified

A reviewer opens a validation schema to check one field and finds thirty lines of builder calls. Working out what an accepted object looks like means reading the whole thing and assembling it in their head. The sample in the documentation and the schema in the code are two different artifacts, and nothing keeps them agreeing.

What you get

You will end up with a schema you can read as an example of the data, which fills in defaults as it validates. This is for you if your schemas are harder to read than the values they describe.

Short answer

Write the schema as an object shaped like the value you accept. A constructor in a slot means any value of that type, a literal means that value as a default, and a modifier wraps the slot it applies to. Validation returns the value with defaults filled in, so a config loader validates and completes in one call.

You will need

Node 22 or later, and a value worth writing down. The example uses shape, whose schemas are ordinary JavaScript objects rather than a builder chain.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
AjvThe schema is JSON Schema, shared with an OpenAPI document or another languageJSON Schema is verbose to hand-write, and its errors need mapping before a person reads themThe schema exists only in this codebase
shapeConfig and boundary checks where the schema should read as a sampleA smaller ecosystem than the alternatives, and no type inference in TypeScriptYou need the validated type inferred at compile time
ValibotTypeScript projects where bundle size decides, such as code shipped to a browserA builder syntax, and a pipeline style that reads differently from the dataThe schema is read more often than it is bundled
ZodTypeScript services that want the validated type inferred from the schemaEvery schema is a chain of calls, so a nested object is nested chainsTypes are not the reason you are validating

Voxgig maintains shape. It is one of four options here, not the recommendation.

The trade is legibility against type inference. Zod derives a TypeScript type from the schema, which removes a class of drift between the validator and the code using it. Its schemas read as code rather than as data. A literal schema reads as data and gives you no type. Pick by which of those two costs your team more.

Write the accepted value

Every slot means something, and none of them needs a call.

export const Service = Shape({
  name: String,
  port: 8080,
  tls: Optional(Boolean),
  retries: Min(0),
  upstream: {
    host: String,
    timeout_ms: 3000,
  },
  tags: [String],
  mode: Exact('live', 'test'),
})

Each slot carries both a type and, sometimes, a value. String in a slot accepts any string. 8080 accepts any number and supplies 8080 when the property is absent, so the default and the type are one declaration rather than two that can disagree. An array containing exactly one element describes a list of any length whose members take that element’s shape. Modifiers wrap the slot they constrain, so Min(0) sits where the number goes rather than trailing the property as a separate rule.

The result is a schema a reviewer can read as an example. Compare it against a sample payload in a pull request and the differences are visible without translating either one.

The cost of that legibility is that the schema and the data share a notation, so the notation has to carry the distinction between them. A literal is a default rather than a constant, an array of one element is a list rather than a one-element list, and both of those are conventions to learn. JSON Schema makes the opposite trade: nothing in a schema could be mistaken for the data, and no part of it reads like an example either.

Validate and complete in one call

The schema returns a value rather than a boolean, and the returned value is the input with defaults filled in.

node demo.mjs
accepted: {"name":"invoices","retries":2,"upstream":{"host":"db.example.com","timeout_ms":3000},"tags":["core"],"mode":"live","port":8080,"tls":false}
port as a string         port: Validation failed for property "port" with string "9090" because the string is not of type number.
a mode outside the set   mode: Value "staging" for property "mode" must be exactly one of: live, test
a negative retry count   retries: Value "-1" for property "retries" must be a minimum of 0 (was -1).

The input supplied five properties and the result has eight. port and upstream.timeout_ms came from the defaults in the schema, and tls filled with the zero value of its type. Each failure names the property and what was wrong with it, which is what a config error has to do to be useful to somebody paged at night.

Reporting every failing property at once matters as much as the message. A loader that stops at the first problem turns a misconfigured deployment into a sequence of restarts, each finding one more thing. The validator collects them, so a single failed start lists everything that has to change. That is the difference between one deployment window and four, and it costs nothing at the call site.

Check it worked

The behavior worth pinning down is what a literal means, because it is the part that differs most from the other validators.

test('a literal in the schema is a default, not a requirement', () => {
  const value = Service(minimal)

  assert.equal(value.port, 8080)
  assert.equal(value.upstream.timeout_ms, 3000)
})

test('a supplied value wins over the default', () => {
  assert.equal(Service({ ...minimal, port: 9090 }).port, 9090)
})
node --test schema.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 141.979497

When it goes wrong

An optional property arrives in the result anyway. Optional(Boolean) means the caller need not supply it, and the validated value still carries the key with its type’s zero value. Code testing for the key with in or hasOwnProperty finds it present. Test the value rather than the key, or leave the property out of the schema when absence has to stay absent.

The second surprise is a literal you meant as a constant. mode: 'live' reads like a requirement and behaves as a default, so a caller passing mode: 'test' is accepted. A fixed set of values needs the exact modifier, which is why Exact('live', 'test') appears in the schema.

When not to do this

Do not use shape where a JSON Schema document already exists. Two schemas describing one value drift apart, and the one nobody generates from is the one that goes stale.

Do not put a validator at every internal boundary. Validation belongs where untrusted data arrives: the request body, the queue message, the file somebody edited by hand. A check between two of your own functions costs time on every call, to restate something the tests already cover.

Do not rely on defaults to hide a missing configuration. A default port is a convenience, and a default database password is a production incident, so require what must be supplied.

Do not validate and then use the original object. The returned value is the one carrying the defaults, and code that validates for the check and then reads the input gets the incomplete version.

Last verified

Verified 2026-09-06 against Node 22.22.2 and shape 11.4.1. Both output blocks are what the preceding command printed. The Ajv, Valibot and Zod rows describe documented behavior and were not run.

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 22 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.