Skip to content

Define a DTO

Define a named data shape once with model(), then reuse it across request bodies, response shapes, and standalone parsing.

AI generated, pending review Updated 10 days ago · Flare 0.3

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.

  1. Define the DTO as a named class that extends model({ ... }).
  2. Use the class as a contract body.
  3. Reuse the same class as a contract response shape.
  4. Call DTO.safeParse(input) anywhere you need to parse the shape on its own.

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.

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.

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.

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.

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

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.