Define a DTO
Define a named data shape once with model(), then reuse it across request bodies, response shapes, and standalone parsing.
You have a data shape that shows up in more than one place: the body a client posts, the JSON you send back, and a parse you run outside any route. Defining it three times drifts. Define it once as a DTO with model({ ... }), give it a name, and reuse the same token everywhere.
model() gives the shape an identity. Use it instead of an anonymous schema({ ... }) when the same shape needs to be referred to by name in several places. The validation rules and wire parsing are identical to schema(); what you get on top is an extendable, named class.
- Define the DTO as a named class that extends
model({ ... }). - Use the class as a contract
body. - Reuse the same class as a contract
responseshape. - Call
DTO.safeParse(input)anywhere you need to parse the shape on its own.
Define the DTO
Section titled “Define the DTO”Extend model({ ... }) with the field descriptor. The class name is the DTO’s identity.
import { model, str, int, optional } from "@flare-ts/lib/schema";
class CreateUser extends model({ name: str.min(1), email: str, age: optional(int),}) {}CreateUser is now a SchemaToken you can pass anywhere a schema is accepted, plus a named class you can refer to across the app.
Use it as a request body
Section titled “Use it as a request body”Put the DTO on an httpContract entry’s body. The pipeline validates the incoming body once, before the handler runs, and a failure returns a 400 with field errors.
import { ControllerBase, httpContract } from "@flare-ts/core";import { Post } from "@flare-ts/core/decorators";import { model, str, int, optional } from "@flare-ts/lib/schema";
class CreateUser extends model({ name: str.min(1), email: str, age: optional(int),}) {}
const UserApi = httpContract({ create: { body: CreateUser, },});
class UserController extends ControllerBase { public static override deps = []; public static override state = []; public static override contract = UserApi;
@Post("/users") create() { const { body } = this.ctx.extract(UserApi.create); if (!body) return this.badRequest({ error: "Request body is required." }); return this.created({ id: 1, name: body.name }); }}The value from extract() is a plain object, not a CreateUser instance. Guard for null before reading fields when the inbound body is empty or missing.
Reuse the same DTO as a response shape
Section titled “Reuse the same DTO as a response shape”Define the response DTO once and list it under the contract’s response map for the status you return. Flare serializes the plain object you return with that shape’s rules.
import { ControllerBase, httpContract } from "@flare-ts/core";import { Post } from "@flare-ts/core/decorators";import { model, str, int, optional } from "@flare-ts/lib/schema";
class CreateUser extends model({ name: str.min(1), email: str, age: optional(int),}) {}
class UserResponse extends model({ id: int, name: str, email: str,}) {}
const UserApi = httpContract({ create: { body: CreateUser, response: { 201: UserResponse }, },});
class UserController extends ControllerBase { public static override deps = []; public static override state = []; public static override contract = UserApi;
@Post("/users") create() { const { body } = this.ctx.extract(UserApi.create); if (!body) return this.badRequest({ error: "Request body is required." }); return this.created({ id: 1, name: body.name, email: body.email }); }}One definition now drives both ends. The body DTO validates what comes in, the response DTO shapes what goes out, and you return a plain object either way.
Parse the DTO on its own
Section titled “Parse the DTO on its own”Call the static safeParse. On success data is a plain object typed as the DTO’s shape, not a class instance.
import { model, str, int, optional } from "@flare-ts/lib/schema";
class CreateUser extends model({ name: str.min(1), email: str, age: optional(int),}) {}
const result = CreateUser.safeParse({ name: "Ada", email: "ada@example.com" });if (!result.success) { throw new Error(result.error.fields[0]?.message);}const user = result.data;safeParse accepts a JSON string, an ArrayBuffer, or a plain object. Reach for this in a CLI, a queue consumer, or a test fixture.
Two things to get right
Section titled “Two things to get right”The model class has no argument-taking constructor. new CreateUser({ ... }) does not type-check; even new CreateUser() returns an empty object rather than a parsed one. To validate, call CreateUser.safeParse(input), or put the DTO on a contract body and read the parsed value from extract().
optional() on a model returns a SchemaToken, not another model class. Call it only when nesting the DTO as an optional field inside a parent descriptor:
import { model, schema, str, int } from "@flare-ts/lib/schema";
class Profile extends model({ bio: str }) {}
const Account = schema({ id: int, profile: Profile.optional(),});For an optional primitive field, wrap the primitive instead (optional(int)).
Discriminated unions
Section titled “Discriminated unions”model() has no union form: a class cannot carry a union instance type. Declare the union with schema(), and give the token and the type the same name to keep one identifier for both the value and type positions:
import { schema, int, str } from "@flare-ts/lib/schema";
type Pet = { kind: "cat"; lives: number } | { kind: "dog"; breed: string };const Pet = schema<Pet, "union">("kind", { cat: { lives: int }, dog: { breed: str },});
const cat = Pet.safeParse({ kind: "cat", lives: 9 });Pet works on contract entries and in type annotations exactly like a model class name.
See also
Section titled “See also”- Models (DTOs): the full
model()surface and overloads - Primitives and builders: primitives and
schema()the DTO builds on - HTTP contracts: descriptor fields on routes and controllers