Data Modelling
OPRA's type system is the single source of truth for your API's data contract. You define your types once in TypeScript and OPRA uses those definitions to validate incoming requests, coerce values to their correct runtime types, filter output fields by scope, and generate the API schema — all automatically.
Type categories
| Type | What it is |
|---|---|
| Simple types | Scalars — string, number, date, and custom domain primitives with their own coercion and validation |
| Complex types | Structured objects with named fields (@ApiField). The primary tool for entities, request bodies, and response shapes |
| Enums | Closed sets of allowed values. Any value outside the set is rejected at decode time |
| Mapped types | Projections of an existing complex type — fields picked, omitted, or made optional without redeclaring them |
| Mixins | Merges of multiple complex types into one class — the equivalent of multiple inheritance |
| Arrays | Typed array wrappers around any other type, with minOccurs / maxOccurs constraints |
A model in practice
A complex type is a plain TypeScript class. Fields are declared with @ApiField() and carry their full contract — type, required status, and constraints:
import { ComplexType, ApiField } from '@opra/common';
@ComplexType()
export class Customer {
@ApiField({ type: 'integer' })
declare id: number;
@ApiField({ required: true })
declare givenName: string;
@ApiField({ required: true })
declare familyName: string;
@ApiField()
declare email?: string;
@ApiField({ scope: 'admin' }) // never sent to non-admin callers
declare internalScore?: number;
}
From this single declaration OPRA derives:
- Input validation — missing required fields, wrong types, and out-of-range values are rejected before the handler is called
- Output encoding — response values are coerced to declared types;
internalScoreis stripped for public callers automatically - Schema — the type appears in
GET /$schemaand is used byoprimpto generate typed client code
Request body variants with mapped types
Rather than defining a separate DTO by hand, derive it from the entity:
import { OmitType, PartialType } from '@opra/common';
import { Customer } from './customer.js';
// Create DTO — server assigns id, clients don't submit it
export class CustomerCreate extends OmitType(Customer, ['id']) {}
// Patch DTO — all fields optional for partial update
export class CustomerPatch extends PartialType(Customer) {}
→ DATA MODELS — full reference for all type categories