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

validate-order.ts
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:

after-the-spec-changed.tsDoes not compile
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.

After a spec bump
# The spec moves upstream, so regenerate and let the compiler answer.
npx openapi-typescript ./openapi.yaml -o ./src/api.generated.ts
npx tsc --noEmit

Where the same argument applies

Nothing here is specific to OpenAPI: Prisma, Drizzle, protobuf and GraphQL codegen own their types the same way.

Honest limits