Getting started
1 · Install
npm i adorn
The only runtime dependency is tslib. It ships CommonJS with .d.ts types and supports Node.js 20.19 and later (CI runs 20.19, 22 and 24).
2 · Enable decorators
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true } }
reflect-metadata is not required. Without it, the generated docs just lose their Type column. The decorator path needs a metadata-emitting compiler (tsc, ts-node, ts-jest), and esbuild, SWC, Vite and Bun don't emit that metadata. The descriptor-first path (Verify, contract(), toolSchemaFrom) works with no flags at all.
3 · Contract a tool
import {
Contract, Tool, Describe, NotEmpty, OneOf,
GreaterThanEqual, LessThanEqual, toolSchemaForMethod,
} from 'adorn';
@Contract()
export class WeatherTool {
@Tool('Look up a weather forecast.')
getWeather(
@Describe('the city to look up') @NotEmpty() location: string,
@OneOf(['celsius', 'fahrenheit']) unit: string,
@GreaterThanEqual(1) @LessThanEqual(7) days: number,
): string {
return `${days}-day forecast for ${location} in ${unit}`;
}
}
// Hand this to any function-calling API as the tool's `parameters`:
export const parameters = toolSchemaForMethod(WeatherTool, 'getWeather');
4 · Route the model's call through it
const tool = new WeatherTool();
const args = JSON.parse(toolCall.function.arguments);
try {
return tool.getWeather(args.location, args.unit, args.days);
} catch (e) {
// A contract violation: hand the message back to the model so it can retry.
return `error: ${(e as Error).message}`;
}
Validate the parsed arguments first with Verify.check(ObjectContracts.Fields({...}), 'args', args), which returns {ok, value} or {ok: false, path, expected, message}. See Working with values.
Parameter constraints
Each one is a decorator for a parameter (@Between(1, 10)) and a descriptor in a namespace (NumberContracts.Between(1, 10)). Every predicate is total: a wrong-type value is a violation, never a TypeError.
| Decorator | Accepts | Schema |
|---|---|---|
@GreaterThan(n) / @GreaterThanEqual(n) | number > n / ≥ n | exclusiveMinimum / minimum |
@LessThan(n) / @LessThanEqual(n) | number < n / ≤ n | exclusiveMaximum / maximum |
@Between(a, b) | a ≤ number ≤ b | minimum + maximum |
@Integer() · @MultipleOf(n) | whole number · divisible by n | type: integer · multipleOf |
@MinLength(n) · @MaxLength(n) · @StringMinLength(n) | length bounds | minLength · maxLength |
@NotEmpty() | a non-empty string | minLength: 1 |
@Matches(pattern, flags?) | matches the pattern | pattern |
@Email() · @Url() | email · URL | pattern · format: uri |
@Uuid() · @DateTime() · @Ipv4() · @Format(name) | validating formats; date-time is strict RFC 3339 | format |
@OneOf([...]) | strict === membership | enum |
@Const(v) | exactly v | const |
Stacked decorators conjoin and evaluate top to bottom. @Positive() and friends were removed on purpose, because a name can't carry a boundary. Write @GreaterThan(0).
Composition
const member = ObjectContracts.Fields({
name: StringContracts.NotEmpty(),
roles: ArrayContracts.ArrayOf(EnumContracts.OneOf(['admin', 'editor', 'viewer']), { minItems: 1 }),
note: NullableContracts.Nullable(StringContracts.NotEmpty()),
});
Verify.check(member, 'member', { name: 'Ada', roles: ['admin', 'wizard'], note: null });
// { ok: false, path: ['roles', 1], expected: 'one of "admin", "editor", "viewer"', … }
@ArrayOf(item, {minItems?, maxItems?}): every element satisfiesitem. A failure names the element's index.@Dictionary(value): an open map of string keys to values matchingvalue.@Shape(FieldClass): a closed DTO validated against another class's contracted fields.ObjectContracts.Fields({...}): an optional-aware object literal, with no class needed.@Nullable(item): also acceptsnull. This is different from@Optional, which means the value may be absent.
Markers
| Marker | Effect |
|---|---|
@Optional() | An absent argument is fine, and the parameter is dropped from the tool schema's required. If any inheritance level requires it, it stays required. |
@Default(v) | Publishes {default: v} into the schema and implies @Optional. It does not inject the value; the host applies it. |
@Describe('…') | The property's description, which the model reads. |
@Tool('…') | The tool's top-level description. |
Pre- and postconditions
@Contract()
class Bank {
@Requires((amount: number, balance: number) => amount <= balance, 'cannot overdraw')
@Returns(NumberContracts.GreaterThanEqual(0))
@Ensures((result: unknown, amount: number, balance: number) =>
(result as number) === balance - amount, 'balance drops by exactly the amount')
withdraw(@GreaterThanEqual(1) amount: number, @GreaterThanEqual(0) balance: number): number {
return balance - amount;
}
}
new Bank().withdraw(30, 100); // 70
new Bank().withdraw(150, 100); // throws @Requires (caller overdrew)
new Bank().withdraw(0, 100); // throws @GreaterThanEqual(1) on `amount`
| Decorator | When | Blames | Projects |
|---|---|---|---|
@EnabledWhen((self, ...args) => bool) | before, reads state | caller | no |
@Requires((...args) => bool) | before | caller | no |
@Returns(descriptor) | after, on the settled value | callee | yes |
@Ensures((result, ...args) => bool) | after, on the settled value | callee | no |
All four stack on one signature. A guard that returns false means "not enabled"; a guard that throws is treated as a defect in the guard itself and propagates unchanged.
Field contracts
@Contract()
class Account {
@Enforce.greaterThanEqual(0) balance = 0; // validated on every assignment
@Enforce.notEmpty() owner = 'unknown';
}
Enforce has a method for every constraint (greaterThan, between, oneOf, uuid, arrayOf, shape, nullable and so on). CreateFieldDecorator(descriptor) wraps any descriptor, including your own. Field contracts are inherited: a subclass can strengthen them but never weaken them.
Working with values
Use these when there's no signature to decorate. They take the same descriptors and run the same oracles, as functions.
| Function | Use it for |
|---|---|
Verify.check(d, name, value) | Untrusted input as a value, such as an HTTP 400. Narrows unknown and reports path and expected. |
Verify.that(d, name, value) | The same check, but it throws, returning the narrowed value on success. |
toolSchemaFrom({name, description, params}, opts) | Tool schemas whose permitted values are known only at runtime. |
contract(spec, fn) | Enforcing params, requires, ensures and returns on a plain function, sync or async. |
violationFromCheck(result) | Emitting a functional refusal on the observation stream. |
defineConstraint({...}) | Your own first-class constraint, which projects like a built-in. |
const params = {
slotId: EnumContracts.OneOf(availableSlots), // known only now
partySize: NumberContracts.Between(1, 8),
};
const schema = toolSchemaFrom({ name: 'book', params }, { providerStrict: true });
const reply = Verify.check(ObjectContracts.Fields(params), 'args', modelArgs);
To try this live, use the playground.
Projections
| Projection | One class | Whole registry |
|---|---|---|
| LLM tool schema (prescriptive) | toolSchemaForMethod(Cls, 'm', {providerStrict?}) | n/a |
| JSON Schema 2020-12 (descriptive) | schemaForClass(Cls) | generateSchema() |
| Markdown docs | renderMarkdown([documentClass(Cls)]) | generateMarkdown() |
| Privacy review | serializeTelemetry(telemetryForClass(Cls)) | generateTelemetryReport() |
The model types (ClassDoc, MethodDoc, ToolSchema, TelemetryReport and so on) are exported so you can type variables that hold them.
Configuration
Set these once at startup. They're process-level, not per-request.
| Call | Effect |
|---|---|
setViolationPolicy('throw' | 'warn' | handler) | 'warn' proceeds into the method. Use it to stage a new contract over live traffic. |
setEnforcementMode('development' | 'production' | 'auto') | Production strips assert-trust contracts. guard contracts stay on. |
setDefaultTrust('guard' | 'assert') | The default for contracts that don't declare one. |
disableGuardEnforcement({confirm, reason}) | The only way to strip guard contracts. |
setValueDisclosure('none' | 'all') | Shows raw values in adorn's own output, for local debugging only. |
setContractDiagnosticsEnabled(bool) | Dev-mode warnings, such as constraints declared without @Contract(). |
Privacy
Redaction is on by default and needs no annotation. @Sensitive() on a field, parameter or class vetoes disclosure even under setValueDisclosure('all'). @Telemetry.value(), .redact(strategy) and .result() opt values into the observation path.
transfer(ssn: string) has to be marked on the parameter itself. JavaScript's value semantics break the link back to a @Sensitive field once the primitive is passed.
CLI
adorn docs --out CONTRACTS.md src/tools/*.ts # Markdown documentation adorn schema --out schema.json src/tools/*.ts # JSON Schema (draft 2020-12) adorn check tsconfig.json # @Sensitive values reaching an adorn emitter
docs and schema load, then project: they require each entry so its decorators run, which means importing executes the module, side effects included. .ts entries need ts-node and typescript in your project. check is static: it never runs your code, and it exits non-zero if it finds a leak.
Add-ons
Both ship inside the package as subpath exports. Importing the root never loads them, and each one needs its peer dependency only when you use it.
adorn/test
Needs fast-check. fuzzMethod, fuzzClass and fuzzFunction (each with an async variant) test one unit against its own contract. fuzzSequence tests call sequences on stateful objects.
npm i -D fast-check
adorn/otel
Needs @opentelemetry/api. createOtelSink() turns the observation stream into spans, a duration histogram and value-free violation events.
setObservationSink(createOtelSink());
Gotchas
@Contract()is required. Without it, constraints are neither enforced nor projected. adorn warns in development.- Decorator order with
@Validate(). Method decorators it should see must sit below it. Class-level@Contract()doesn't care about order. - TypeScript defaults are honored.
f(@GreaterThanEqual(1000) t = 30_000)called asf()uses the default.@Defaultexists to publish a default to the schema. - A TypeScript default and
@Defaultcan disagree.@Default(10) x = 20publishes 10 and runs with 20. adorn doesn't reconcile the descriptor with TypeScript. - No constructor-parameter decorators yet. Use a
@Validate()-decoratedstatic of(...)factory with a private constructor. static #privateblocks class decorators (TS18036). Move it to module scope.@NotEmpty()accepts whitespace. Pair it with@Matches(/\S/)when blank input should fail.- A wrapped method reports
length === 0. Its.nameis correct, andcontract()preserveslength.
API index
Every runtime export of adorn, adorn/test and adorn/otel. The site's build check fails if this list drifts from the library. Press / to search.