Copies disagree
Raise a limit in the validator and forget the schema, and the model is told one thing while the code enforces another. A drifted doc misleads a human who can compensate. A drifted tool schema silently corrupts every call.
Contracts as data for TypeScript
Decorators adorn a function with its contract. The constraint you write on a parameter is the JSON Schema an LLM reads and the check its reply has to pass. It also produces the docs and the telemetry. Everything comes from one declaration, so the artifacts can't drift apart.
0.1.0, MIT. The only runtime dependency is tslib. The suite is
1,355 tests across 78 suites, and the public surface is pinned by a test. The generated tool
schemas have been run end to end through OpenAI function calling.
@Contract()
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 { /* the body only ever sees valid arguments */ }
}
{
"title": "WeatherTool.getWeather",
"description": "Look up a weather forecast.",
"type": "object",
"properties": {
"days": { "type": "number", "minimum": 1, "maximum": 7 },
"location": {
"description": "the city to look up", "type": "string", "minLength": 1
},
"unit": { "type": "string", "enum": ["celsius", "fahrenheit"] }
},
"required": ["location", "unit", "days"],
"additionalProperties": false
}
// throws before the body runs: the model asked for 99 days Value Error: WeatherTool.getWeather(location, unit, days) parameter 'days' expected a number less than or equal to 7 but received [REDACTED] instead // values are redacted by default, with no annotation needed
### WeatherTool.getWeather(location, unit, days) _Look up a weather forecast._ | Parameter | Constraint | Expectation | | ------------ | ---------- | ------------------------------------ | | `location` | notEmpty | a non-empty string | | `unit` | oneOf | one of "celsius", "fahrenheit" | | `days` | min | a number greater than or equal to 1 | | `days` | max | a number less than or equal to 7 |
## WeatherTool · getWeather | Position | Name | Discloses | @Sensitive | | -------- | ---------- | ------------------ | ---------- | | #0 | `location` | redacted (default) | — | | #1 | `unit` | redacted (default) | — | | #2 | `days` | redacted (default) | — | > Reports what each position is configured to disclose. It makes no > claim that a value ever actually flowed at runtime.
@LessThanEqual(14) changes the schema the model reads and the check its reply faces together.null, NaN or the wrong type is reported as a contract violation, never a bare TypeError.[REDACTED] unless you opt in.Why adorn
A function's intent is usually spread across a check buried in the body, a schema in another file, a sentence in the docs and a hand-written test. Each one is maintained by hand, so each one drifts.
Raise a limit in the validator and forget the schema, and the model is told one thing while the code enforces another. A drifted doc misleads a human who can compensate. A drifted tool schema silently corrupts every call.
@GreaterThanEqual(1) records an identity plus JSON-serializable params, and the predicate is derived from them. A schema keyword, a docs row, a fuzzer generator and the runtime check are all read from that one record.
@Contract() wraps each contracted method, so a call with invalid arguments is rejected before the body runs. adorn validates and never coerces: a malformed argument is a violation, not something to quietly patch up.
How it works
A TypeScript parameter decorator runs once, when the class is defined, and never sees an argument value. adorn bridges that gap by splitting the work: decorators record descriptors, the class decorator installs enforcement, and projections read the same registry.
(prototype, method, index).@Contract() runs after every member decorator and wraps each method with the recorded checks.Runs on every call. A guard contract is kept in production; an assert is an internal self-check that's stripped there.
toolSchemaForMethod produces the exact parameters object a function-calling API wants, with OpenAI strict mode via {providerStrict: true}.
schemaForClass describes what enforcement will accept. When unsure it falls back to the weaker true statement, because an over-claiming schema is worse than none.
Markdown generated from the same statement that's enforced: documentClass → renderMarkdown, or adorn docs from the CLI.
A neutral, zero-dependency sink receives value-free violation and completion facts. adorn/otel maps them onto OpenTelemetry.
fast-check generators derived from the contract. Generation is sound: it produces only inputs the contract accepts, or declines and says so.
Playground · real adorn, in your browser
This isn't a mock-up. The page loads the actual adorn build (0.1.0),
bundled from dist/. The left pane declares tool arguments as descriptors. The right
side shows the tool schema built from them and checks a model reply against the same
declaration.
The params pane is evaluated as JavaScript in your browser, and nothing is sent anywhere. Try
changing Between(1, 8): the schema's minimum/maximum and
the verdict on the reply change together. This is the descriptor-first path. The decorator form
(@Between(1, 8) on a parameter) records exactly the same descriptor.
Evidence
The schemas adorn generates were sent to OpenAI's live function-calling API (gpt-4o-mini,
temperature 0). Each schema was accepted, the model returned a tool call, and its arguments were
validated against the same declaration that produced the schema.
| Case | HTTP | Schema accepted | Args valid | Verdict |
|---|---|---|---|---|
reminder: providerStrict + a Nullable optional field | 200 | yes | yes | ROUND-TRIP OK |
book: a Fields param with an optional field (non-strict) | 200 | yes | yes | ROUND-TRIP OK |
bookStrict: a Fields param, all fields required (strict) | 200 | yes | yes | ROUND-TRIP OK |
| bookStrictOpt: a nested nullable optional field (strict) | 200 | yes | yes | ROUND-TRIP OK |
Run locally with no network: a bad nested date-time is rejected at ["when"], and so is a missing required field. Nullable(NotEmpty()) accepts null and still rejects "".
Strict mode can't express a truly optional property, so a bare non-nullable @Optional under providerStrict fails at build time with an actionable message. Without that, OpenAI would just reject the request.
The verbatim schemas and method are in docs/q31-capstone-results.md.
Beyond the decorator
Decorators are the ergonomic form, not the foundation. A raw unknown off the wire, a
tool whose enum is known only at session start, or a codebase with no classes can use the same
descriptors directly and get the same enforcement and projections.
Verify.checkNon-throwing. It narrows unknown to the constraint's output type and reports the failing path.
const r = Verify.check(member, 'body', json); if (!r.ok) return res.status(400).json(r); r.value.roles; // typed
toolSchemaFromA runtime enum that no decorator could express. One const feeds both the schema and the validator.
const params = {
slotId: EnumContracts.OneOf(openSlots),
};
toolSchemaFrom({ name: 'book', params });
contract(spec, fn)The functional twin of @Contract, @Requires, @Ensures and @Returns. It's async-aware and runs the same oracles.
const score = contract(
{ returns: NumberContracts.Between(0, 100) },
async (seed: number) => seed,
);
Design by contract, for stateful objects
@Requires relates a call's arguments. @EnabledWhen also receives the
instance, so "withdraw is enabled only while the balance covers it" is a declared
contract instead of an if hidden in the body. fuzzSequence then drives
generated call sequences against one evolving instance. It skips calls that aren't enabled yet
and checks that invariants survive every transition.
@Contract()
class Wallet {
@Enforce.greaterThanEqual(0) balance = 0;
deposit(@GreaterThan(0) amount: number) {
this.balance += amount;
}
@EnabledWhen((self: Wallet, amount: number) =>
amount <= self.balance, 'balance covers it') // ◆
@Returns(NumberContracts.GreaterThanEqual(0))
withdraw(@GreaterThan(0) amount: number) {
this.balance -= amount;
return this.balance;
}
}
import { fuzzSequence, formatSequenceReport } from 'adorn/test';
const report = fuzzSequence(Wallet, {
subject: () => new Wallet(),
});
console.log(formatSequenceReport(report));
No generated sequence broke an invariant or a postcondition, which isn't the same as "correct". The tester samples rather than proves, and its report says so. A guard that throws instead of returning false is reported as a defect.
Honest project status
adorn is a focused, deliberately narrow 0.1. This page aims to make its limits as easy to find as its features.
@OneOf/@Const, @ArrayOf, @Dictionary, @Shape, @Nullable, plus defineConstraint for your own.@Requires, @EnabledWhen, @Ensures, and the reified, projectable @Returns.@Sensitive / @Telemetry, plus a build-time sensitive-flow detector (adorn check).adorn/test (fuzzing, including fuzzSequence) and adorn/otel, each behind an optional peer.toString()Minification degrades names in docs and schemas, though enforcement and every projection degrade together.@Requires, @Ensures and @EnabledWhen are enforced but invisible to docs and schema.@Validate() static factory instead.experimentalDecorators and a metadata-emitting compiler (tsc, ts-node, ts-jest), not esbuild or SWC.adorn/stage3, recording into the same core model, so the paradigm survives the language's decorator transition.old() and constructor contractsPostconditions that reference pre-call state, and invariants established at construction. Both wait on evidence from real use.Learn
Install, enable decorators, and write a first contracted tool.
Open guide → LOOK UPEvery decorator, descriptor and projection, plus a searchable index of the public surface.
Open reference → UNDERSTANDThe registry, the installer, trust levels, and the rules projections follow.
Read architecture → RUNEight runnable scripts, one per capability, each a single npx ts-node away.
Ninety numbered decisions, including the ones that removed features.
Read decisions → TRACKRelease notes, following Keep a Changelog and semantic versioning.
Read changelog →The tool schema, the validator, the docs and the telemetry follow from it.