Integration
Rules on generated types
openapi-typescript already turned the spec into TypeScript. The shape is decided, it is checked in, and it is not yours to move. What is missing is the part OpenAPI cannot express or your server does not enforce — and adding it should not mean writing the shape a second time.
1. The file the generator wrote
Nobody edits this. It is overwritten every time the spec changes.
// api.generated.ts — written by openapi-typescript. Do not edit.
export interface components {
schemas: {
Order: {
/** Format: uuid */
id: string;
customer: {
name: string;
email: string;
};
items: {
sku: string;
quantity: number;
}[];
};
};
}2. The rules, declared against it
No second definition of the shape. The paths are checked against the generated type, so a typo in "customer.emial" is a compile error rather than a rule that never fires.
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
import { stringMinPlugin } from "@maroonedog/luq/plugins/stringMin";
import { stringEmailPlugin } from "@maroonedog/luq/plugins/stringEmail";
import { uuidPlugin } from "@maroonedog/luq/plugins/uuid";
import { numberMinPlugin } from "@maroonedog/luq/plugins/numberMin";
interface components {
schemas: {
Order: {
id: string;
customer: { name: string; email: string };
items: { sku: string; quantity: number }[];
};
};
}
type Order = components["schemas"]["Order"];
export const validateOrder = Builder()
.use(requiredPlugin)
.use(stringMinPlugin)
.use(stringEmailPlugin)
.use(uuidPlugin)
.use(numberMinPlugin)
.for<Order>()
.v("id", (b) => b.string.required().uuid())
.v("customer.name", (b) => b.string.required().min(2))
.v("customer.email", (b) => b.string.required().email())
.v("items[*].sku", (b) => b.string.required().min(3))
.v("items[*].quantity", (b) => b.number.required().min(1))
.build();3. What happens when the spec changes
This is the part that pays for the direction. Someone upstream renames customer to buyer. You regenerate. The rule that referenced the old field stops compiling:
import { Builder } from "@maroonedog/luq";
import { requiredPlugin } from "@maroonedog/luq/plugins/required";
import { stringEmailPlugin } from "@maroonedog/luq/plugins/stringEmail";
// The spec renamed `customer` to `buyer`, so the regenerated file says:
interface components {
schemas: {
Order: {
id: string;
buyer: { name: string; email: string };
};
};
}
type Order = components["schemas"]["Order"];
Builder()
.use(requiredPlugin)
.use(stringEmailPlugin)
.for<Order>()
// The rule was written against the old shape. It no longer type-checks.
.v("customer.email", (b) => b.string.required().email())
.build();A hand-written schema does not: it keeps validating a field the API no longer sends.
# The spec moves upstream, so regenerate and let the compiler answer.
npx openapi-typescript ./openapi.yaml -o ./src/api.generated.ts
npx tsc --noEmitWhere the same argument applies
Nothing here is specific to OpenAPI: Prisma, Drizzle, protobuf and GraphQL codegen own their types the same way.
Honest limits
- Luq reads the generated type, not the OpenAPI document. Constraints the spec carries —
maxLength,pattern,format— do not survive into TypeScript, so you write those rules yourself. If you want the document itself to drive validation, that is the JSON Schema entry point, and it is a different trade. - A renamed field is caught. A field whose type widens in a way your rules still accept is not — that is a change the compiler has no reason to reject.