zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Validation and contracts

Shallow ValidationSupported

Shallow validation is for rechecking data whose deeper contents were already validated. It deliberately makes a weaker promise than is, assert or validate, in exchange for emitted code and runtime work that stop at a compile-time depth.

⚠️ Warning

Do not use a shallow validator as the security boundary for an untrusted request body, queue message, config file or database value. A shallow check can accept malformed data below its depth and still return a value typed as T. Use the full-depth validate, assert or is there.

API#

import { assert, assertShallow, isShallow, validateShallow } from '@zmdb/validator';

const topLevelOkay = isShallow<Order, 1>(value);
const order = assertShallow<Order, 2>(value);
const result = validateShallow<Order, 2>(value);

D is a positive integer type argument. The top-level value is depth 1, and omitting D means depth 1. It cannot be a runtime variable: the transformer uses the literal to omit deeper branches from the generated program.

The three results mirror their full-depth siblings:

That T is a TypeScript result, not a claim that every nested value was checked. The limit below is part of the contract.

The legitimate use#

Validate untrusted data once at its boundary, at full depth. A later internal boundary may recheck only the envelope when the nested values are already trusted:

const order = assert<PopulatedOrderRow>(untrustedBody); // full check, once

// Later, after storage or transport inside the same trust boundary:
const sameOrder = assertShallow<PopulatedOrderRow, 1>(order);

Depth 1 still checks the top-level object, required properties, top-level scalars, relation object shapes and list array-ness. It does not inspect fields inside those relations or elements inside those lists.

If a branch has not already been validated, name it unknown and validate it when it is consumed instead of pretending the whole value is trusted:

interface JobEnvelope {
  kind: string;
  payload: unknown;
}

const job = assert<JobEnvelope>(body);
const payload = assert<ResizeArgs>(job.payload);

What depth means#

Depth counts type constructors entered:

TypeDCheckedStops at
number1typeof value === 'number'nothing — no interior
{ a: number }1a is present and is a numbercomplete
{ a: { b: number } }1a is a non-null object and not an arrayinside a
{ a: { b: number } }2also b is present and is a numbercomplete
string[]1Array.isArray(value)every element
string[]2also typeof element === 'string' per elementcomplete
[string, number]1array-ness and tuple arityboth elements
discriminated object union1the discriminant and the selected arm's shapeinside the arm's properties

Presence, optionality and nullability are always checked at the level reached. A tuple checks arity at depth 1 because arity belongs to the tuple constructor. A discriminated union reads its discriminant at every depth so the validator does not narrow an arbitrary object to an arm on no evidence.

A depth larger than a finite type's nesting emits the same complete helper as full validation. A recursive type still has to be representable by reflection; shallow validation bounds its emitted/runtime walk, but is not an escape hatch for an unsupported type.

The emitted difference#

The current transformer produced these checks for { user: { id: number } }; whitespace is expanded here for readability.

Full depth:

const check = input =>
  typeof input === 'object' &&
  input !== null &&
  !Array.isArray(input) &&
  typeof input.user === 'object' &&
  input.user !== null &&
  !Array.isArray(input.user) &&
  typeof input.user.id === 'number' &&
  !Number.isNaN(input.user.id);

Depth 1:

const check = input => typeof input === 'object' && input !== null && !Array.isArray(input) && typeof input.user === 'object' && input.user !== null && !Array.isArray(input.user);

There is no runtime depth counter. The input.user.id branch is absent from the depth-1 output, which is why the generated program is smaller and why a string in that field is accepted.

What it does not guarantee#

isShallow returning true, assertShallow returning a value, and validateShallow returning success: true all share these limits. The wording below is the emitter specification's contract:

ConstructorAt depth D, does not guarantee
objectanything about the contents of a property whose type is itself a constructor
arraythat any element has the declared element type
tuplethat any element has its declared type — only that the arity is right
discriminated unionanything about the matched arm's properties beyond depth D
undiscriminated union_which_ arm matched, only that at least one matched to depth D
record / index signature(not applicable — an index signature is refused at reflection, §8)
optional / nullable(nothing extra — presence and nullability are always fully checked)
recursive typeanything below depth D, however deep the value actually is

Measured populated-row result#

The committed benchmark uses the real transformer output over eight PopulatedOrderRow values. Each has three populated relation objects and an items list with 100 rows.

modemedian ns/opmedian ops/smax/min spread
full448.662,228,8701.030x
shallow depth 19.95100,460,0991.044x

In that run, depth 1 used 2.22% of the full validator's time: a measured 45.07× ratio. Six semantic probes ran before timing. Full validation rejected malformed fields inside a relation and a list item; shallow depth 1 accepted both, while both modes rejected malformed top-level scalars, relation shapes and list shapes.

This is one local-machine result for one shape, not a general speed guarantee. The gain comes from the omitted work: a depth-1 array check is O(1) in its element count, while a full element walk is O(n). Measure your own shape before choosing a depth. The benchmark page documents the runner, and the raw artifact carries all 12 samples, semantic probes and input hashes.

It does not clone or prune#

All three shallow functions inspect the original value. assertShallow returns that same value, and successful validateShallow data is not a stripped copy. Extra properties follow the same rules as the full is/assert/validate family: they are ignored, not removed. Use an explicit projection or serializer when the goal is to produce a second shape.

---

See also: validate() · assert() · is() · AOT Setup