OAS, JSON Schema, AJV: a solid domain in a few steps with xndrjs
In many TypeScript projects, the contract already exists.
Maybe your backend exposes an OpenAPI spec. Maybe that OpenAPI document is the shared source of truth between teams. Maybe it already contains important rules: required fields, enums, formats, minLength, additionalProperties, nested objects, accepted payloads, and rejected payloads.
The point is simple:
if my backend exposes an OAS contract, I do not want to redefine the types by hand.
And, more importantly, I do not want to duplicate the same knowledge three times:
- once in OpenAPI
- once in TypeScript types
- once in runtime validation
That duplication feels small at the beginning, but it gets expensive as soon as the domain grows. A field is renamed, an enum gets a new value, an object becomes stricter, and suddenly you need to remember to update types, validators, mappers, and tests.
With xndrjs, we can use a more direct approach:
- download or collect the OpenAPI document
- turn it into a resolved bundle
- use the schemas as JSON Schema inputs compiled by AJV
- generate TypeScript types with
openapi-typescript - plug everything into
domain.shape,domain.primitive, anddomain.proof
The result is a solid domain, statically typed and validated at runtime, without rewriting contracts by hand.
The oas-core-validator-demo example app shows the full integration.
The problem: static types are not enough
Section titled “The problem: static types are not enough”Imagine receiving this payload from a backend:
const payload = { id: "u-1", email: "dev@example.com", tier: "pro", isVerified: true, tags: ["alpha", "beta"],};In TypeScript, we could write:
type UserDTO = { id: string; email: string; tier: "free" | "pro"; isVerified: boolean; tags?: string[];};But this immediately creates two problems.
First, we are manually copying information that already exists in the OpenAPI document.
Second, the TypeScript type does not validate anything at runtime. If a payload comes from the network, storage, a query string, a form, a message queue, or any other external boundary, it is still unknown to the program. We can tell the compiler that it is a UserDTO, but we have not proven it.
This is exactly where xndrjs fits:
unknown -> AJV -> xndrjs domain valueAJV checks the runtime contract. xndrjs takes the validated result and turns it into a trusted domain value.
1. Start from OpenAPI
Section titled “1. Start from OpenAPI”In the demo, we use a multi-file OpenAPI 3.1 spec:
openapi: 3.1.0info: title: OAS -> Core Validator Demo version: 1.0.0paths: {}components: schemas: Tier: $ref: "./schemas/common.yaml#/Tier" User: $ref: "./schemas/user.yaml#/User" VerifiedUser: $ref: "./schemas/user.yaml#/VerifiedUser"The User schema lives in a separate file:
User: type: object required: - id - email - tier - isVerified properties: id: type: string minLength: 1 email: type: string format: email tier: $ref: "./common.yaml#/Tier" isVerified: type: boolean tags: type: array items: type: string minLength: 1 default: [] additionalProperties: falseAnd we can also model a more specific guarantee, such as VerifiedUser:
VerifiedUser: allOf: - $ref: "#/User" - type: object properties: isVerified: const: true required: - isVerifiedThis is already domain modeling: not in the sense of classes full of methods, but in the sense of explicit constraints on data.
2. Codegen: download and prepare the OAS
Section titled “2. Codegen: download and prepare the OAS”In a real application, the OpenAPI spec often comes from a backend endpoint, or from an artifact generated in CI.
curl https://api.example.com/openapi.json -o openapi/openapi.jsonIn the demo, the spec is local, but the conceptual step is the same: take the OAS and generate the artifacts your app needs.
The main command is:
{ "scripts": { "codegen": "tsx scripts/codegen.ts && pnpm exec openapi-typescript src/generated/openapi.bundled.json -o src/generated/openapi.types.ts" }}This script creates two files:
src/generated/openapi.bundled.jsonsrc/generated/openapi.types.ts
The first one is used by AJV for runtime validation.
The second one is used by TypeScript for static type inference.
3. Bundling: from multi-file OAS to compilable schemas
Section titled “3. Bundling: from multi-file OAS to compilable schemas”Real OpenAPI specs are rarely a single flat file. They usually contain $refs, shared components, separate files, and schemas reused in several places.
AJV can validate JSON Schema, but it is much more convenient to give it a resolved, stable, versionable bundle.
In the demo, we do this with @apidevtools/swagger-parser:
import { mkdir, writeFile } from "node:fs/promises";import { fileURLToPath } from "node:url";import path from "node:path";import SwaggerParser from "@apidevtools/swagger-parser";
const appRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");const oasPath = path.join(appRoot, "openapi", "openapi.yaml");const generatedDir = path.join(appRoot, "src", "generated");const bundledPath = path.join(generatedDir, "openapi.bundled.json");
async function main() { await mkdir(generatedDir, { recursive: true });
const bundled = (await SwaggerParser.bundle(oasPath)) as Record<string, unknown>;
await writeFile(bundledPath, `${JSON.stringify(bundled, null, 2)}\n`, "utf8");}With OpenAPI 3.1, schemas under components.schemas are based on JSON Schema. So the practical flow is:
multi-file OpenAPI -> bundled OpenAPI -> component schemas -> AJVIf you work with OpenAPI 3.0, the idea is the same, but you may need a more explicit conversion step to JSON Schema because the OAS 3.0 schema dialect does not perfectly match JSON Schema.
With OpenAPI 3.1, the flow is much more direct.
4. Generate TypeScript types with openapi-typescript
Section titled “4. Generate TypeScript types with openapi-typescript”Now that we have a bundle, we generate the types:
pnpm exec openapi-typescript src/generated/openapi.bundled.json -o src/generated/openapi.types.tsThis produces a TypeScript file with the component map:
import type { components } from "./generated/openapi.types.js";
type Schemas = components["schemas"];
type UserDTO = Schemas["User"];type TierDTO = Schemas["Tier"];The important thing is that UserDTO and TierDTO are not handwritten types.
They are derived from OpenAPI.
So when the contract changes, we regenerate the artifacts and the compiler tells us where the application code needs to adapt.
5. Compile schemas with AJV
Section titled “5. Compile schemas with AJV”@xndrjs/domain-ajv provides the adapter between AJV and the xndrjs domain core.
For a plain JSON Schema, you can use jsonSchemaToValidator:
import { domain, jsonSchemaToValidator } from "@xndrjs/domain-ajv";
const Tier = domain.primitive( "Tier", jsonSchemaToValidator<"free" | "pro">({ type: "string", enum: ["free", "pro"], }));Of course, you dont’ want to pass the generics to jsonSchemaToValidator by hand.
When you start from an OpenAPI bundle, the most convenient option is write a small helper around openApiComponentToValidator:
import { domain, openApiComponentToValidator, type OpenApiBundle } from "@xndrjs/domain-ajv";import type { components } from "./generated/openapi.types.js";
type Schemas = components["schemas"];
function openApiValidatorFor<K extends Extract<keyof Schemas, string>>( bundle: OpenApiBundle, name: K) { return openApiComponentToValidator<Schemas[K]>(bundle, name);}This small helper connects the two worlds:
nameis constrained to actual OpenAPI schema names- the validator output is inferred from
components["schemas"] - AJV compiles the runtime schema
xndrjsreceives a standardValidator
From there, the domain becomes very small:
const Tier = domain.primitive("Tier", openApiValidatorFor(bundle, "Tier"));const User = domain.shape("User", openApiValidatorFor(bundle, "User"));const VerifiedUserProof = domain.proof("VerifiedUser", openApiValidatorFor(bundle, "VerifiedUser"));We now have three different concepts without duplicating the contract:
Tieris a validated primitiveUseris a domain shapeVerifiedUserProofis an additional proof that can be applied to aUser
6. Use the domain in the app
Section titled “6. Use the domain in the app”At this point, the external payload enters the boundary as untrusted data.
const validPayload: UserDTO = { id: "u-1", email: "dev@example.com", tier: "pro", isVerified: true, tags: ["alpha", "beta"],};Creating a domain value means going through validation:
const user = User.create(validPayload);If we also want to prove that the user is verified:
import { pipe } from "@xndrjs/domain-ajv";
const verifiedUser = pipe(User.create(validPayload), VerifiedUserProof.assert);This line is small, but it says a lot:
raw payload -> User -> VerifiedUserWe are not just doing a type assertion. We are crossing two gates:
- the first one checks that the payload is a
User - the second one checks that this
Usersatisfies theVerifiedUserproof
If a wrong payload arrives:
const invalidPayload = { id: "", email: "not-an-email", tier: "enterprise", isVerified: "yes", tags: [""],};User.create(...) fails, and xndrjs normalizes AJV errors into domain issues:
try { User.create(invalidPayload as unknown as UserDTO);} catch (error) { // DomainValidationError}The benefit is that the application does not need to reason directly about AJV’s internal error format. AJV remains the validation engine, while the rest of the domain speaks the xndrjs language.
7. The full pipeline
Section titled “7. The full pipeline”The final pipeline looks like this:
Backend OpenAPI -> download or checkout the spec -> resolved OAS bundle -> JSON Schema-compatible component schemas -> AJV compile -> xndrjs Validator -> domain.primitive / domain.shape / domain.proofIn parallel:
OpenAPI bundle -> openapi-typescript -> components["schemas"] -> inferred DTO typesSo runtime and compile time both derive from the same source.
That is the important part: we are not trying to keep two manual representations in sync. We are making one source of truth feed both levels.
Why this makes the domain more solid
Section titled “Why this makes the domain more solid”The result is not just “validate a payload”.
The result is application code where valid values have a precise place.
Before validation:
unknownAfter validation:
UserAfter an additional proof:
VerifiedUserThis makes the domain more readable because every transition has a name.
And it makes the domain more robust because the normal path through the code goes through validation, not through an as UserDTO written to silence TypeScript.
xndrjs does not ask you to throw away the contracts you already have. It lets you bring them into the domain in a few steps:
- OAS as the source of truth
- JSON Schema as the validatable format
- AJV as the runtime engine
openapi-typescriptfor typesxndrjsto turn everything into domain
The full demo is here: apps/oas-core-validator-demo.
To try it:
pnpm --filter @xndrjs/oas-core-validator-demo run demoAnd that is the point:
in a few steps, you can take an existing OpenAPI contract and get a solid domain with
xndrjs, without redefining types and validators by hand.