Contracts as data for TypeScript

One declaration.
Every artifact.

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.

weather.ts
@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 */ }
}
derived from the same descriptors, not written twice
toolSchemaForMethod(WeatherTool, 'getWeather')
{
  "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
}
Change one line@LessThanEqual(14) changes the schema the model reads and the check its reply faces together.
Total predicatesnull, NaN or the wrong type is reported as a contract violation, never a bare TypeError.
Redacted by defaultEvery path adorn emits through prints [REDACTED] unless you opt in.
Proven, not assertedGenerated schemas round-trip through OpenAI's live API, strict mode included.

Why adorn

Intent belongs on the signature, not scattered around it.

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.

01 · DRIFT

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.

02 · DATA

Constraints are data, not closures

@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.

03 · BOUNDARY

Enforced at every call

@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

Record, install, project.

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.

  1. DeclareParameter, field and method decorators, or plain descriptors with no decorator at all.
  2. RecordEach constraint is stored as a reified descriptor against (prototype, method, index).
  3. Install@Contract() runs after every member decorator and wraps each method with the recorded checks.
  4. ProjectDocs, schemas, tool schemas, telemetry and fuzzers all read the same registry.
core

Runtime validation

Runs on every call. A guard contract is kept in production; an assert is an internal self-check that's stripped there.

core

LLM tool schema

toolSchemaForMethod produces the exact parameters object a function-calling API wants, with OpenAI strict mode via {providerStrict: true}.

core

JSON Schema 2020-12

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.

core

Documentation

Markdown generated from the same statement that's enforced: documentClass → renderMarkdown, or adorn docs from the CLI.

core

Observability

A neutral, zero-dependency sink receives value-free violation and completion facts. adorn/otel maps them onto OpenTelemetry.

adorn/test

Property-based tests

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

Edit the declaration. Watch both sides move.

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.

params — descriptors in scope: NumberContracts, StringContracts, EnumContracts, ArrayContracts, ObjectContracts, NullableContracts
a model's reply: the tool-call arguments (JSON)
toolSchemaFrom({ name, description, params })

            
Verify.check(ObjectContracts.Fields(params), 'args', reply)

              

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

Round-tripped through a real model.

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.

CaseHTTPSchema acceptedArgs validVerdict
reminder: providerStrict + a Nullable optional field200yesyesROUND-TRIP OK
book: a Fields param with an optional field (non-strict)200yesyesROUND-TRIP OK
bookStrict: a Fields param, all fields required (strict)200yesyesROUND-TRIP OK
bookStrictOpt: a nested nullable optional field (strict)200yesyesROUND-TRIP OK

The validator is the real guard

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 "".

Refused at build time, not by a 400

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

Same descriptors, no decoration point needed.

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.

BOUNDARY

Verify.check

Non-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
PER-SESSION

toolSchemaFrom

A 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 });
ZERO CLASSES

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

Preconditions that read state, and a tester that respects them.

@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.

wallet.ts
@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;
    }
}
wallet.test.ts
import { fuzzSequence, formatSequenceReport } from 'adorn/test';

const report = fuzzSequence(Wallet, {
    subject: () => new Wallet(),
});
console.log(formatSequenceReport(report));
What a clean run means

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

What works, what's rough, what's next.

adorn is a focused, deliberately narrow 0.1. This page aims to make its limits as easy to find as its features.

  • shipped
    Constraint vocabularyNumeric, string, formats, @OneOf/@Const, @ArrayOf, @Dictionary, @Shape, @Nullable, plus defineConstraint for your own.
  • shipped
    InheritanceField and method-parameter contracts are inherited. A subclass can strengthen a contract but never weaken it.
  • shipped
    Pre- and postconditions@Requires, @EnabledWhen, @Ensures, and the reified, projectable @Returns.
  • shipped
    Five in-core projectionsDocs, JSON Schema, LLM tool schema (including OpenAI strict mode), observability, and privacy review.
  • shipped
    Safe-by-default redaction@Sensitive / @Telemetry, plus a build-time sensitive-flow detector (adorn check).
  • shipped
    Add-ons as subpath exportsadorn/test (fuzzing, including fuzzSequence) and adorn/otel, each behind an optional peer.
  • rough
    Parameter names come from toString()Minification degrades names in docs and schemas, though enforcement and every projection degrade together.
  • rough
    Closures can't be projected@Requires, @Ensures and @EnabledWhen are enforced but invisible to docs and schema.
  • rough
    No constructor-parameter decorators yetValidate at construction with a @Validate() static factory instead.
  • rough
    Legacy decorators onlyNeeds experimentalDecorators and a metadata-emitting compiler (tsc, ts-node, ts-jest), not esbuild or SWC.
  • next
    TC39 standard decorators adapteradorn/stage3, recording into the same core model, so the paradigm survives the language's decorator transition.
  • next
    old() and constructor contractsPostconditions that reference pre-call state, and invariants established at construction. Both wait on evidence from real use.
  • next
    Format arbitraries for the fuzzerThe fuzzer currently declines on string formats rather than guessing.
  • next
    Add-on ecosystemResilience, rate-limit, idempotency, cache and policy decorators, deliberately kept out of core.

Reach for adorn when

  • you expose methods as LLM tools and need the schema and validation to agree
  • you validate untrusted input at an HTTP or queue boundary
  • you want docs and privacy reviews that can't go stale
  • you want property-based tests derived from contracts you already wrote

Don't assume yet

  • coercion or defaults injection, since adorn rejects and never transforms
  • per-request policy: configuration is process-level and set once at startup
  • proofs, because the fuzzer samples rather than proves
  • cross-checking against TypeScript types or TS default values

Write the contract once.

The tool schema, the validator, the docs and the telemetry follow from it.