Skip to main content

Mixin Types

Overview​

A MixinType composes fields from multiple base types into a single class — the equivalent of multiple inheritance for OPRA types. Where a regular extends chain is limited to one parent, MixinType merges any number of ComplexTypes, MappedTypes, or other MixinTypes.

ComplexTypeBase
├── ComplexType → single-parent class
├── MappedType → projected/transformed class
└── MixinType → multi-parent composite class

MixinType only accepts types from the ComplexTypeBase hierarchy. Passing a SimpleType or EnumType is not supported and will throw at registration time.


Basic Usage​

Call MixinType with an array of base types to produce a composite class. Use it directly as the base of a @ComplexType():

import { ComplexType, ApiField, MixinType } from '@opra/common';

@ComplexType()
class Timestamped {
@ApiField({ readonly: true }) declare createdAt?: Date;
@ApiField({ readonly: true }) declare updatedAt?: Date;
}

@ComplexType()
class SoftDeletable {
@ApiField({ readonly: true }) declare deletedAt?: Date;
@ApiField() declare isDeleted?: boolean;
}

@ComplexType({ name: 'Article' })
class Article extends MixinType([Timestamped, SoftDeletable]) {
@ApiField({ required: true }) declare title: string;
@ApiField() declare body?: string;
}

// Article fields: createdAt, updatedAt, deletedAt, isDeleted, title, body

All fields from every listed type are merged into the resulting class. Fields declared directly on Article are added on top.


Field Merging​

Fields are merged in array order. When two base types declare a field with the same name, the later type wins (last-write-wins).

import { ComplexType, ApiField, MixinType } from '@opra/common';

@ComplexType()
class Base {
@ApiField({ required: true }) declare status: string;
@ApiField() declare createdAt?: Date;
}

@ComplexType()
class Override {
// Redefines status as optional and deprecated
@ApiField({ deprecated: 'Use state instead' }) declare status?: string;
}

@ComplexType({ name: 'Entity' })
class Entity extends MixinType([Base, Override]) {}

// Entity.status: optional, deprecated (Override's version wins)
// Entity.createdAt: optional (from Base, unaffected)

To control which definition takes precedence, put the type whose version you want to keep last in the array.


Additional Fields Resolution​

When base types have different additionalFields settings, MixinType resolves them with the following priority:

PriorityValueBehaviour
1 (highest)trueAny additional property is allowed; wins over everything
2First defined valueThe first non-undefined additionalFields among the bases
—undefined / omittedAdditional properties are stripped (default)

If any base allows all additional fields (additionalFields: true), the mixin is fully open — regardless of what the other bases say.

import { ComplexType, MixinType } from '@opra/common';
import { StringType } from '@opra/common';

@ComplexType({ additionalFields: new StringType() })
class StringMap {}

@ComplexType({ additionalFields: true })
class OpenBag {}

@ComplexType({ additionalFields: ['error'] })
class StrictDto {}

// MixinType([StringMap, StrictDto]) → additionalFields: StringType (first defined wins)
// MixinType([StrictDto, StringMap]) → additionalFields: ['error'] (first defined wins)
// MixinType([StrictDto, OpenBag]) → additionalFields: true (true always wins)

Nested MixinTypes​

A MixinType can itself appear inside another MixinType array, enabling deep composition:

import { ComplexType, ApiField, MixinType } from '@opra/common';

@ComplexType()
class HasId {
@ApiField({ readonly: true }) declare _id?: number;
}

@ComplexType()
class HasTimestamps {
@ApiField({ readonly: true }) declare createdAt?: Date;
@ApiField({ readonly: true }) declare updatedAt?: Date;
}

@ComplexType()
class HasOwner {
@ApiField() declare ownerId?: number;
}

// First compose audit fields
const Auditable = MixinType([HasId, HasTimestamps]);

// Then compose that with ownership
@ComplexType({ name: 'OwnedDocument' })
class OwnedDocument extends MixinType([Auditable, HasOwner]) {
@ApiField({ required: true }) declare name: string;
}

// OwnedDocument fields: _id, createdAt, updatedAt, ownerId, name

Fields are flattened and deduplicated depth-first — nesting does not create nested objects.


Extending a MixinType​

Add new fields on top of a mixin by declaring them directly on the @ComplexType() class:

import { ComplexType, ApiField, MixinType } from '@opra/common';

@ComplexType()
class Localized {
@ApiField() declare locale?: string;
@ApiField() declare timezone?: string;
}

@ComplexType()
class Contactable {
@ApiField() declare email?: string;
@ApiField() declare phone?: string;
}

@ComplexType({ name: 'UserProfile' })
class UserProfile extends MixinType([Localized, Contactable]) {
@ApiField({ required: true }) declare username: string;
@ApiField() declare avatarUrl?: string;
@ApiField() declare bio?: string;
}

// UserProfile fields: locale, timezone, email, phone, username, avatarUrl, bio

You can also chain mixins — use a MixinType as a base for another MixinType:

const BaseRecord = MixinType([HasId, HasTimestamps]);

@ComplexType({ name: 'Product' })
class Product extends MixinType([BaseRecord, Localized]) {
@ApiField({ required: true }) declare sku: string;
@ApiField() declare price?: number;
}

Options Reference​

All DataType.Options are accepted as a second argument to MixinType:

MixinType(bases, options?)
OptionTypeDescription
namestringRegistry name for the resulting type. Defaults to {BaseName}Mixin.
descriptionstringHuman-readable description included in the schema export.
abstractbooleanCannot be used directly in fields — only extended.
additionalFieldsboolean | DataType | ['error'] | ['error', string]Overrides the resolved additionalFields policy.
scopePatternstring | RegExp | (string | RegExp)[]Restricts which scopes can use this type.
embeddedbooleanNot exposed as a standalone named type in the schema.
examplesDataTypeExample[]Example values for schema documentation.
// Standalone named mixin — registered and reusable
const AuditFields = MixinType([HasId, HasTimestamps], {
name: 'AuditFields',
description: 'Standard audit trail fields shared across all entities',
abstract: true,
});

// Anonymous embedded mixin — not exposed in the schema registry
const InlineBase = MixinType([Localized, Contactable], { embedded: true });