Custom Presets
A preset is a published npm package whose default export is a v2 Preset. Publishing a preset lets you reuse a decorator mapping across projects, and lets other teams adopt your decorator library with a one-line config change.
Anatomy
type Preset = {
name: string; // unique preset name (used by `extends` / `replaces`)
extends?: string[]; // other preset package names to inherit from
controllers?: ControllerHandler[]; // class-target handlers
methods?: MethodHandler[]; // method-target handlers
parameters?: ParameterHandler[]; // parameter-target handlers
controllerJsDoc?: ControllerJsDocHandler[];
methodJsDoc?: MethodJsDocHandler[];
parameterJsDoc?: ParameterJsDocHandler[];
};A handler matches a decorator (or JSDoc tag) by name and contributes to a draft:
type ControllerHandler = {
match: { name: string; on?: 'class' };
apply: (ctx: HandlerContext, draft: ControllerDraft) => void;
replaces?: true | string;
marker?: ResolverMarker;
};Package to Install
The contract surface lives in @trapi/core, not @trapi/metadata. @trapi/core ships the IR types, handler/preset types, and authoring helpers (controller(...), into, append, flag, readString, …) — and has no typescript dependency. Preset packages should peer-depend on @trapi/core only; they don't need the metadata extraction pipeline at runtime.
Minimal Example
// src/index.ts
import {
type Preset,
ParamKind,
controller,
method,
parameter,
} from '@trapi/core';
const routeControllerHandler = controller({
match: { name: 'Route', on: 'class' },
apply: (ctx, draft) => {
const arg = ctx.argument(0);
if (arg?.kind === 'literal' && typeof arg.raw === 'string') {
draft.paths = [arg.raw];
} else if (arg?.kind === 'array' && Array.isArray(arg.raw)) {
draft.paths = arg.raw.filter((v): v is string => typeof v === 'string');
} else {
draft.paths = [''];
}
},
});
const httpGetHandler = method({
match: { name: 'HttpGet', on: 'method' },
apply: (_ctx, draft) => { draft.verb = 'get'; },
});
const fromBodyHandler = parameter({
match: { name: 'FromBody', on: 'parameter' },
apply: (_ctx, draft) => { draft.in = ParamKind.Body; },
});
const preset: Preset = {
name: '@my-org/trapi-preset',
controllers: [routeControllerHandler],
methods: [httpGetHandler],
parameters: [fromBodyHandler],
};
export default preset;controller(...), method(...), parameter(...) are identity helpers — they exist to preserve narrow types at declaration sites.
Extending Another Preset
Use extends to inherit handlers from another preset:
const preset: Preset = {
name: '@my-org/trapi-preset',
extends: ['@trapi/preset-decorators-express'],
controllers: [
// Recognise @Route(...) in addition to inherited @Controller(...)
routeControllerHandler,
],
};By default, all handlers from extends parents and your own preset run additively. To shadow a parent handler, set replaces:
controller({
match: { name: 'Controller', on: 'class' },
replaces: true, // shadow ALL parent handlers matching the same name
apply: (ctx, draft) => { /* ... */ },
});replaces: '<presetName>' shadows handlers contributed by exactly that parent preset; replaces: true shadows every parent's matching handlers. Same-preset siblings are always additive — replaces only affects inherited handlers.
Built-in Helpers
For simple cases, into(), append(), and flag() save boilerplate:
import { append, controller, flag, into, method } from '@trapi/core';
method({ match: { name: 'Path', on: 'method' }, apply: into('path').positional(0) });
method({ match: { name: 'Tags', on: 'method' }, apply: append('tags').positionalAll() });
controller({ match: { name: 'Hidden', on: 'class' }, apply: flag('hidden') });Resolver Markers
If your preset renames decorators that the type resolver consumes (@Hidden, @Deprecated, @Extension, @IsInt/@IsLong/@IsFloat/@IsDouble), tag the handler with a marker so the type resolver discovers it:
import {
MarkerName,
NumericKind,
controller,
flag,
parameter,
} from '@trapi/core';
controller({
match: { name: 'Skip', on: 'class' },
apply: flag('hidden'),
marker: MarkerName.Hidden, // or just 'hidden'
});
parameter({
match: { name: 'AsInteger', on: 'parameter' },
apply: (_ctx, draft) => {
draft.validators.isInt = { value: 'int' };
},
marker: { numeric: NumericKind.Int }, // or { numeric: 'int' }
});The marker tells the type resolver "this handler represents the concept of hidden/numeric/etc." — preset authors are free to use any decorator name and the resolver still finds it. JSDoc handlers can carry markers too (marker: 'deprecated' on a JSDoc handler matching tag: 'gone' makes the resolver treat @gone as deprecated).
JSDoc Handlers
JSDoc tags can drive metadata too. Register handlers under methodJsDoc / controllerJsDoc / parameterJsDoc:
import { methodJsDoc } from '@trapi/core';
const summaryJsDocHandler = methodJsDoc({
match: { tag: 'summary' },
apply: (ctx, draft) => {
if (ctx.source.text) draft.summary = ctx.source.text;
},
});Decorator handlers run before JSDoc handlers on the same node, so JSDoc acts as an override layer.
Package Setup
{
"name": "@my-org/trapi-preset",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"peerDependencies": {
"@trapi/core": "^2.0.0"
}
}@trapi/core must be a peer dependency — consumers have it installed already (transitively via @trapi/metadata), and you want to use their version, not bundle your own.
Consuming
In the target project:
npm install --save @my-org/trapi-presetawait generateMetadata({
entryPoint: 'src/controllers/**/*.ts',
preset: '@my-org/trapi-preset',
});The string is resolved by the loader (named export preset, then default export) and validated against the v2 Preset schema before any handler runs. Misshapen presets fail loud at load time with a path to the offending field.
Worked Examples
examples/decorators— minimal end-to-end example: a runtime decorator library + aPreset, a fixture controller, and a Vitest spec that assertsgenerateMetadataresolves the expected shape.@trapi/preset-typescript-rest— maps@Path,@GET,@POST,@QueryParam,@FileParam, etc. to handler functions; published as a real npm package.@trapi/preset-decorators-express— maps the@decorators/expressdecorator set; self-contained (routing + TRAPI markers + JSDoc handlers).
Testing a Preset
The simplest test harness is to feed generateMetadata() a fixture controller that uses your decorators and assert on the resulting metadata:
import { generateMetadata } from '@trapi/metadata';
import preset from '../src';
const metadata = await generateMetadata({
entryPoint: ['test/fixtures/**/*.ts'],
preset: preset.name, // resolved via npm; or use absolute path during local dev
});
expect(metadata.controllers).toHaveLength(1);
expect(metadata.controllers[0].paths).toEqual(['/users']);For lower-level unit testing, you can call validatePreset(preset) and loadRegistry(preset, { resolver }) from @trapi/core directly to verify the shape and materialise a Registry without going through generateMetadata. The @trapi/core/test-helpers module also exports literalArg, identifierArg, arrayArg, objectArg, typeArg, and createHandlerContext for synthesising decorator inputs.
Publishing Checklist
- [ ]
nameis unique and matches the package name - [ ] All decorators your library exports are mapped
- [ ]
markeris set on handlers for@Hidden/@Deprecated/@Extension/@IsInt/etc. if you rename them - [ ]
@trapi/coreis a peer dependency, not a direct dependency (and@trapi/metadatais not listed — preset authors should not depend on the metadata package) - [ ] Package is ESM (
"type": "module") - [ ]
exportsfield points to both the JS bundle and the type declarations - [ ] The default export is the
Preset(TRAPI checks named exportpreset, then default export, then the module itself) - [ ] A fixture test covers a realistic controller
Once published, consider opening a pull request against the TRAPI monorepo to add a link from the documentation — it helps other users find framework support.