Skip to main content

Data Models

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 data, coerce values to their correct runtime types, filter output fields by scope, serialize responses, and publish the API schema. There is no separate schema file, no code generation step, and no risk of the documentation drifting from the implementation.


Why a type system?

Most frameworks leave data validation, serialization, and documentation as separate concerns — you write TypeScript types for the compiler, validation schemas for runtime, and OpenAPI annotations for documentation. Each layer must be kept in sync manually, and divergence is a constant source of bugs.

OPRA collapses these layers into one. A type declared with @ComplexType() and @ApiField() is simultaneously:

  • A runtime codec — values are decoded (parsed and coerced) on the way in, and encoded (transformed and filtered) on the way out.
  • A schema definition — the type is included in the API document served at /$schema and used by the CLI to generate typed client code.
  • A security boundary — fields tagged with a scope are invisible to callers that don't hold that scope; they are stripped from both input and output automatically.
  • A documentation artifact — names, descriptions, constraints, and examples are part of the type definition and appear in the published schema without any separate annotation.

Type categories

Data Model TypeWhat it represents and when to use it
Simple TypesScalar values — string, number, date, and custom domain primitives with their own coercion and validation.
Complex TypesStructured objects with named fields (@ApiField). The primary tool for entities, request bodies, and response shapes.
EnumsClosed sets of allowed values. Any value outside the set is rejected at decode time.
Type MappingsProjections of an existing complex type — fields picked, omitted, or made optional without redeclaring them (PickType, OmitType, PartialType, RequiredType).
MixinsMerges of multiple complex types into one class — the equivalent of multiple inheritance.
ArraysTyped array wrappers around any other type, with minOccurs / maxOccurs constraints.

How types flow through a request

When a request arrives at an OPRA controller, the type system is active at every stage:

  1. Input decoding — the request body, path parameters, query parameters, headers, and cookies are each decoded against their declared type. Values are coerced to the correct JavaScript type; values that cannot be coerced are rejected with a structured validation error before the handler is called.

  2. Scope filtering (input) — fields in the request body that belong to a scope the caller does not hold are silently stripped before validation runs. A public caller cannot write to an admin-only field even if the field is present in the body.

  3. Handler execution — the handler receives already-decoded, already-scoped data. No manual parsing is needed.

  4. Output encoding — the return value is encoded against the declared response type. Fields are coerced to their declared types; scope-restricted fields are stripped. The encoded value is then serialized and sent.

  5. Schema publication — the same type definitions are exported as part of the API document at GET /$schema. Clients use this document to generate typed stubs and validate their own requests.


Scopes and field-level security

Every @ApiField() can carry a scope attribute — a pattern string that controls which callers can read or write that field. The adapter compares the field's scope against the active request scope and silently omits non-matching fields from both input and output.

This means sensitive fields (passwordHash, internalScore, billingDetails) are enforced at the type layer, not scattered across handler code. A field that should never reach a public client simply never does — regardless of what the database returns or what the handler puts in the response object.

Complex Types — Scopes


Named vs embedded types

Types can be named — registered in the ApiDocument under a label and referenceable from multiple places — or embedded — defined inline within a single field or operation and not reused.

Named types appear in the /$schema output and can be referenced by OPRA clients, code generators, and other documents. Embedded types are local to their declaration site and do not appear by name in the schema.

In practice, entities and shared DTOs should always be named types. Small inline shapes with no reuse value can be embedded.


Relation to the API schema

Every named type registered in an ApiDocument is included in the schema exported at GET /$schema. The OPRA CLI (oprimp) fetches this schema and generates TypeScript client code — typed request builders and response types — directly from the type definitions. Because both the server and the client derive from the same source, the contract is always in sync.

Schema