Pure TypeScriptSupported
What works with no build plugin, and what does not. The short version: a validator call that gets its shape from a type argument needs @zmdb/compiler, because a type argument does not exist at runtime. Everything that gets its shape from a value does not.
The part that needs the build step#
import { is } from '@zmdb/validator';
is<User>(payload); // needs the transformeris<T>, isShallow<T, D>, assert<T>, assertShallow<T, D>, validate<T>, validateShallow<T, D>, equals<T>, assertEquals<T>, random<T>, toJsonSchema<T>, schemaOf<T>, toolFor<T>, protoDescriptor<T>, protoDecode<T>, protoEncode<T>, grpcDescriptor<S> and loadGrpcService<S> are the seventeen calls the transformer currently rewrites. It replaces each with emitted code built from the reflected IR. Where it did not run over a file, the type argument is gone and the call throws — the validation utilities ask for a runtime witness, while the schema, protobuf and gRPC artifact calls name the build transform that should have replaced them.
toolFor<T>() is imported from the independently installed @zmdb/ai package. That provider-neutral package adds no provider SDK peer; the other transformed calls retain the package imports shown throughout this guide.
The five protobuf and gRPC artifact calls are imported from @zmdb/protobuf; reflection and emission run once through @zmdb/compiler.
There is no fallback that inspects T at runtime, because there is nothing to inspect. An earlier version of this page described the untransformed path as "slower but working"; it fails open, which is worse than failing, so it now throws. See AOT Setup.
The part that does not#
Rule-first validation. validate(rule, value) takes the constraint as a value, so it runs anywhere:
import { tags, validateRule as validate } from '@zmdb/validator';
validate(tags.Min(0), input.price); // boolean
validate(tags.Pattern('^[^@]+@[^@]+$'), input.email);
validate(tags.Enum('draft', 'review', 'published'), input.status);| Rule | Checks |
|---|---|
Min(n) / Max(n) | a number within an inclusive bound |
MinLength(n) / MaxLength(n) | a string's length |
Pattern(re) | a string against a regular expression |
Enum(...values) | membership — variadic, not an array |
Every rule answers false for a value of the wrong type rather than throwing, and the emitted form has identical boolean semantics — that equivalence is what makes this a safe fallback rather than a second implementation. An unknown kind throws.
Serialization. Neither stringify nor parse is transformed, so both work unchanged:
import { parse, stringify } from '@zmdb/validator/serialization';
const json = stringify(user); // JSON.stringify, plus one fixed bigint TypeError
const result = parse(json); // { success, data? , issues? } — malformed JSON is a value, not a throwparse<T>'s type argument is an unvalidated claim, exactly as JSON.parse's cast would be. The checking step is separate, and it is one of the transformed validation calls.
A validator with an explicit schema. The nine validation/generation utilities accept a TypeIR value as their fallback witness (random takes it first; the eight value-checking calls take it second). The three shallow calls additionally accept their depth as a third fallback-only argument. The schema and protobuf calls cannot use that escape hatch because their public contract is compile-time-only:
import { assert, type TypeIR } from '@zmdb/validator';
const ir: TypeIR = { kind: 'scalar', scalar: 'string' };
assert(rawValue, ir); // no type argument, no transformerThere used to be a second accepted shape, a hand-written TypeDescriptor. It is gone: a descriptor is a type written out again by hand, in a form nothing checks against the type it claims to describe, so it drifts silently the moment the interface is edited.
Where the IR comes from is the catch: reflecting it from a type is what the build step does. Writing one by hand is reasonable for a scalar and unreasonable for a table.
Everything that is not the validator. The query compiler, the repository, WhereDTO/ListDTO handling, the migration engine, @zmdb/web routing, @zmdb/app DI, and all of the derived DTO _types_ are plain TypeScript and plain functions. They need no plugin. The one exception inside that list is schemaOf<T>(), which is how a declaration becomes a runtime schema object — so the value you pass to defineRepository comes from the build step even though the repository itself does not.
Comparison#
| Aspect | Rule-first / explicit IR | Type argument + AOT |
|---|---|---|
| Setup | none | build plugin |
| Shape from | a value you wrote | the type you declared |
| Coverage | five constraint keywords | the whole type |
| Performance | a switch per call | straight-line, no allocation |
| Failure | false | false, or an AssertError with paths |
The performance line is the least interesting one. What the type-argument path buys is that the check cannot drift from the declaration, because there is only one declaration.
Cross-links#
- AOT Setup — configuring the plugin
- jit-vs-aot — what the emitted code looks like
- assert · validate
- Tag Reference — the type-level constraint vocabulary