# 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.

Source: https://voxgig.com/howto/write-a-schema-that-looks-like-the-data

- Audience: api-producer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-09-06
- Published: 2026-09-06

## 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](https://github.com/voxgig/shape), whose schemas are ordinary JavaScript objects rather than
a builder chain.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Ajv](https://ajv.js.org/) | The schema is JSON Schema, shared with an OpenAPI document or another language | JSON Schema is verbose to hand-write, and its errors need mapping before a person reads them | The schema exists only in this codebase |
| [shape](https://github.com/voxgig/shape) | Config and boundary checks where the schema should read as a sample | A smaller ecosystem than the alternatives, and no type inference in TypeScript | You need the validated type inferred at compile time |
| [Valibot](https://valibot.dev/) | TypeScript projects where bundle size decides, such as code shipped to a browser | A builder syntax, and a pipeline style that reads differently from the data | The schema is read more often than it is bundled |
| [Zod](https://zod.dev/) | TypeScript services that want the validated type inferred from the schema | Every schema is a chain of calls, so a nested object is nested chains | Types 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.

```ts title="schema.mjs"
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](https://json-schema.org/) 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.

```bash
node demo.mjs
```

```text output
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.

```ts title="schema.test.mjs"
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)
})
```

```bash
node --test schema.test.mjs
```

```text output
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.

## Related how-tos

- [Accept comments and unquoted keys in a JSON config file](/howto/accept-comments-and-unquoted-keys-in-json)

- [Merge nested config objects with predictable precedence](/howto/merge-nested-config-objects-with-precedence)

## 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.