gustave
v1.3.4
Published
Design by Contract for TypeScript, in Eiffel's own grammar: require, old, ensure, do — plus class invariants
Maintainers
Readme
gustave
Design by Contract for TypeScript, in Eiffel's grammar: require,
require else, old, ensure, rescue, do, plus class invariants.
Conditions have names, and the names show up in editor hovers, lint output,
and violation reports. The core has zero dependencies. One declaration gives
you a runtime guard, a property-test oracle, and documentation.
Quick start
npm install gustaveimport { spec } from "gustave";
export const divide = spec<(a: number, b: number) => number>()
.named("divide")
.require({ "divisor is nonzero": (_a, b) => b !== 0 })
.ensure({
"result times divisor equals dividend": ({ result, args: [a, b] }) =>
result * b === a,
})
.do((a, b) => a / b);The signature is stated once, as the type argument. Every clause and the
implementation are contextually typed from it, so nothing else needs
annotations. .named gives violations their in divide heading. Skip it
and the spec reports anonymously, or use nameContracts (below) to fill
export names in bulk.
divide(10, 0)
ContractViolation: precondition violated in divide: divisor is nonzero
predicate: (_a, b) => b !== 0
args[0]: 10
args[1]: 0A violation report carries the name, the condition, the predicate source, and the concrete values it rejected. Async implementations work the same way: postconditions run against the resolved value and violations reject the promise.
The clause grammar is Eiffel's, from Meyer's Design by Contract. require
takes preconditions over the arguments, require else weakens them with an
alternative, old takes a before-call snapshot, ensure takes
postconditions over { result, args, old }, rescue takes conditions on how
the call may fail, and do attaches the implementation. yield is this
library's extension for async generators. The snapshot type is inferred from
the old callback, and old/evidence must come before the
ensure/yield/rescue clauses that read them. The types enforce that
order, and so does the runtime.
A condition name is declared once per clause kind. Re-declaring one is an error, never a silent replacement: violation reports identify a condition by its name, so two conditions sharing one are indistinguishable in the report, and the usual way a second appears is a copy-pasted clause. The same name in two different clause kinds is fine.
The check runs wherever the names are visible. Record clauses are caught by the
types and again when the chain is built. A factory names its conditions in a
return type, so the chain can't read them — those are caught by the types, and
at runtime as each factory is first materialized, which is the first checked call
for an ordinary clause and the first call reaching that branch for a
requireElse alternative. Nothing runs a factory more than once per checked
call to find out.
const push = spec<(arr: number[], value: number) => number[]>()
.old((arr) => ({ length: arr.length }))
.ensure({
"length grew by one": ({ args: [arr], old }) => arr.length === old.length + 1,
})
.do((arr, value) => { arr.push(value); return arr; });Why a chain and not one call? TypeScript's type-argument inference is all or nothing: supply one explicit type argument (the signature) and inference for the rest turns off, which would collapse the condition-name literals that hovers and tooling depend on. The signature and the inferred names cannot share a call, so each clause gets its own call and its own inference.
Binding arguments once
Each predicate clause also takes a factory form: a function that receives the arguments once and returns the conditions, which close over them. This is how Eiffel itself reads, where a clause sees the routine's formal arguments in scope. Reach for it when predicates keep re-binding the same parameters, or when conditions share derived setup:
export const transfer = spec<(db: Db, from: Account, to: Account, cents: number) => Promise<Receipt>>()
.require((_db, from, to, cents) => ({
"amount is positive": () => cents > 0,
"amount is an integer": () => Number.isInteger(cents),
"accounts are distinct": () => from.id !== to.id,
}))
.ensure((_db, from, to, cents) => ({
"receipt matches the request": ({ result }) =>
result.fromId === from.id && result.toId === to.id && result.cents === cents,
}))
.do(async (_db, from, to, cents) => { /* ... */ });Every condition is a function, in both forms: require conditions become
thunks, and ensure/yield conditions keep their { result, old }
context while reaching the arguments by closure, so the args: [, x]
destructuring disappears. The thunk is what defers evaluation to the check,
gives the violation report the condition's own source line, and keeps the
two clause kinds symmetric. The factory body runs per checked call, so
shared derivations (a normalized Set, say) are computed once for all of
the clause's conditions instead of once per predicate.
The record form stays the default for self-contained one-liners. Both forms
carry their condition names into the hover brand, and the ESLint rule
evaluates both: for a factory it binds the factory's parameters to the
call's static arguments and runs the body, derived bindings included. One
difference: describe() cannot list a factory's condition names without
binding its arguments, so its prose shows a placeholder — pass a sample tuple
(describe({ with: [db, from, to, 100] })) to bind the factories and list the
real names. The sample is used for its names only; no condition is evaluated.
Naming is explicit. There is no stack-trace or source-file magic guessing a
binding name at runtime. Name a spec with .named("..."), or call
nameContracts(mod) to fill anonymous specs' names from a module's export
bindings. autocheck runs it for you, and a production entry point can run
nameContracts(await import("./pricing.js")) once per module or barrel. An
explicit .named("...") is never overridden.
The contract as its own artifact
Stop the chain before .do() and you have a standalone Spec: a named
contract with no implementation attached. It can live in a shared package,
document itself via describe(), judge candidate implementations via
check() and exercise(), and be .do()'d by several providers. Every
violation then reports against the same named contract.
export const HighestBidder = spec<(bids: Bid[]) => Bid | null>()
.named("HighestBidder")
.ensure({
"returns null only for an empty bid list": ({ result, args: [bids] }) =>
(bids.length === 0) === (result === null),
"the winner is one of the submitted bids": ({ result, args: [bids] }) =>
result === null || bids.includes(result),
"no other bid outbids the winner": ({ result, args: [bids] }) =>
result === null || bids.every((b) => b.amount <= result.amount),
});
export const selectHighestBidder = HighestBidder.do((bids) => {
if (bids.length === 0) return null;
return bids.reduce<Bid | null>((best, bid) =>
!best || bid.amount > best.amount ? bid : best, null);
});describe() renders the contract as prose (require: and ensure:
headings, one bullet per condition). exercise(fn, args) enforces the spec
on one concrete call of a bare implementation, and it ignores the global
disable switch so test harnesses can't silently pass.
Contracts in the editor
Hovering a contracted binding shows the conditions as literal types:
Contracted<(a: number, b: number) => number, { pre: "divisor is nonzero"; ... }>.
Call-site hovers show only the resolved signature (a TypeScript display
rule), so the package ships a language-service plugin that appends the
contract as prose to hovers everywhere, call sites included:
// tsconfig.json. Editor-only; tsc ignores plugins.
{ "compilerOptions": { "plugins": [{ "name": "gustave/ts-plugin" }] } }const divide: Contracted<(a: number, b: number) => number, { ... }>
require:
• divisor is nonzero
ensure:
• quotient times divisor equals dividendThe prose is read from the Contracted brand's literal types, so it works
across files and package boundaries. The tsconfig entry is project config:
commit it and every clone gets contract hovers.
The plugin is progressive enhancement. Without it you still get the
Contracted<...> types on binding hovers, the ESLint rule, and the runtime
checks. Editor notes:
- VS Code works with just the tsconfig entry in trusted workspaces. Accept the "enable workspace TypeScript plugin" prompt if it appears. If hovers don't pick it up, switch to the workspace TypeScript version.
- coc.nvim: use
coc-extension/instead — see below. The plugin route also works with coc-tsserver, but only via a probe location, since coc-tsserver doesn't pass--allowLocalPluginLoads: put the project root in"tsserver.pluginPaths": ["/absolute/path/to/your/project"](tsserver appends/node_modulesand resolves relative paths against its own cwd, so it must be absolute). - Other tsserver-based clients work if they pass
--allowLocalPluginLoadsor expose a plugin-probe-locations setting. - tsgo / TypeScript 7 has no plugin API, so the plugin can't load there at all. Binding hovers and the ESLint rule work unchanged.
coc.nvim and tsgo: coc-gustave
The plugin needs a language service to host it, which rules out tsgo (no
plugin API) and makes coc-tsserver awkward. coc-extension/ in this repo is a
coc.nvim extension that answers the same question out of process: it registers
a second hover provider, and coc runs every registered provider and
concatenates them, so your language server's hover stands and the contract is
appended beneath it.
:CocInstall coc-gustaveDetails in coc-extension/README.md.
Its lookup is syntactic rather than type-directed: it follows imports with
ts.resolveModuleName (so paths and baseUrl are honored), parses the file
declaring the spec, and reads the chain — or, when resolution lands in a
published package's .d.ts, reads the condition names off the Contracted
brand instead. No Program and no type checker, so it doesn't hand back the
speed you moved to tsgo for. It also reads factory-form condition names, which
the brand can't carry; what it can't recover across a package boundary is
.named("..."), so those contracts hover without their heading.
The runtime entry points (. and ./check) ship both ESM and CommonJS
builds, so require()-based setups (CommonJS-compiled monorepos, Jest
without ESM) work out of the box.
Failure as part of the contract: rescue
Half of what a TypeScript function's contract says is about failure. rescue
states it. Conditions receive { error, args, old } and run when the
implementation throws or rejects:
export const loadRow = spec<(id: string) => Promise<Row>>()
.named("loadRow")
.rescue({
"a missing row fails with NotFound": ({ error }) => error instanceof NotFound,
"the id is never leaked in the message": ({ error, args: [id] }) =>
!(error instanceof Error) || !error.message.includes(id),
})
.ensure({ "a loaded row has the id asked for": ({ result, args: [id] }) => result.id === id })
.do(async (id) => { /* ... */ });The error is always rethrown, whether the conditions hold or not: a rescue
condition describes a failure, it never absorbs one. (This is not Eiffel's
rescue, which is a handler that retries — the name is borrowed for the
clause that talks about the same moment.) When a condition rejects the way the
call failed, that is a ContractViolation with kind: "rescue condition",
carrying the implementation's own error as cause so the original stack
survives. Postconditions and yield invariants don't run for a failed call —
there is no result to check — so the two clauses partition the exits.
rescue reads old, which is what makes rollback conditions expressible:
.old(async () => ({ balance: await readBalance(id) }))
.rescue({
"a failed debit leaves the balance untouched": async ({ old }) =>
old.balance === (await readBalance(id)),
})This also unblocks generative testing. gustave/check used to fail any
property run whose implementation threw, which made a spec over a throwing
function untestable. A spec with rescue conditions declares that failure is
in scope, so check enforces the conditions and counts the run as passing;
without one, a throw is still a failed property, because nothing in the
contract sanctioned it. A ContractViolation always fails the run. Spec
exposes this as checksFailure if you need to branch on it yourself.
Weakening a precondition: require else
A second require conjoins — both groups must hold. Eiffel's require else
disjoins, which is the form a redeclaration is obliged to use, and it had no
expression here:
export const withdraw = spec<(cents: number, override: boolean) => number>()
.named("withdraw")
.require({ "amount is within the daily limit": (cents) => cents <= DAILY_LIMIT })
.requireElse({ "an operator authorized the override": (_cents, o) => o })
.do((cents) => cents);The call is admissible when the require group holds or the alternative does.
A require written after a requireElse conjoins into that alternative rather
than reopening the original group, so each group reads as one Eiffel clause.
requireElse must follow a require — an alternative to nothing is not a
weakening — and, as everywhere, a condition name may not repeat, including
across alternatives.
Nothing is reported until every alternative fails, and then one violation names the whole disjunction and shows each refuted predicate:
precondition violated in withdraw: amount is within the daily limit else an operator authorized the override
predicate: (cents) => cents <= DAILY_LIMIT
predicate: (_cents, o) => o
args[0]: 5000
args[1]: falsesatisfiesPre follows the same rule, so check generates against the weakened
precondition. So do the ESLint rule and gustave check: they refuse to report
a call unless they can refute every alternative, which keeps their promise that
a report means the call is wrong for every value its arguments can hold.
Transition contracts: async old-state capture
The highest-value contracts on a service's mutation core are state
transitions on persisted data: "an invoice's billedSuccessfully never
goes true to false", "a claimed row is never re-claimed with a different
id". These need a before-snapshot from an async read. There are two capture
modes. Prefer the first.
Implementation-supplied evidence. Most repo methods already read the row
before writing it. Declare the old-state shape with .evidence<Old>() and
the implementation receives a context as one extra trailing argument. Hand
that read to the contract and there is no second query and no separate read
path to keep in sync:
export const claim = spec<(input: ClaimInput) => Promise<Bucket>>()
.evidence<{ existing: Bucket | null }>()
.ensure({
"a claimed bucket is never replaced": ({ result, old }) =>
old.existing === null ||
old.existing.externalDiscountId === result.externalDiscountId,
})
.do(async (input, ctx) => {
const existing = await findBucket(input);
ctx.old({ existing }); // evidence the implementation already had
// ... write ...
return bucket;
});
claim(input); // public signature: (input: ClaimInput) => Promise<Bucket>The evidence clause is what opts a contract into the context. Any other
contract invokes its implementation with exactly the caller's arguments,
and old is plain undefined. The context arrives after however many
arguments the caller actually passed, so evidence contracts need
fixed-arity signatures (an omitted optional argument's slot would receive
the context). If the implementation never calls ctx.old, conditions that
read old are skipped at runtime but violate in exercise/check; a
transition condition a test never evaluated is a silently green test.
onMissingOld: "skip" | "violate" overrides either default. Conditions
that don't read old run regardless. After-state the return value doesn't
carry (the written row, say) can be included in the same evidence record.
Async old callback. .old(async (...args) => snapshot) is awaited before
the implementation runs. Simpler to adopt, but it doubles reads per
contracted call, and since snapshot, call, and check are not atomic, it is
only sound inside a lock or transaction. Under concurrent writers a stale
snapshot fires spurious violations. So exercise/check evaluate
async-old conditions always (tests are serial), while runtime enforcement
is opt-in via .options({ enforceAsyncOld: true }). Without the opt-in the
snapshot read is never issued and old-reading conditions fall under
onMissingOld. If an enforced async capture rejects at runtime, the call
proceeds with old uncaptured, because a write must not be blocked by its
own assertion. Set onOldError: "fail" to rethrow instead. exercise and
check always rethrow.
Cross-call invariants (sagas, multi-write workflows) are out of scope. This is single-call before/after only.
Async generator signatures work too. yield predicates run against every
yielded value, and postconditions run against the final return value once
the consumer drains the generator. An early consumer return() supplies
its own value, so nothing is checked for it.
const Countdown = spec<(from: number) => AsyncGenerator<number, number>>()
.named("Countdown")
.require({ "from is non-negative": (from) => from >= 0 })
.yield({ "counts stay non-negative": ({ value }) => value >= 0 })
.ensure({ "returns zero": ({ result }) => result === 0 });Spec tests without boilerplate: gustave/autocheck
One line property-tests every contracted export of a module. No arbitraries, no argument plumbing:
// bank.autocheck.test.ts
import { autocheck } from "gustave/autocheck";
await autocheck("./bank.ts");A relative specifier resolves against the calling file, exactly like an
import (a new URL(..., import.meta.url) or absolute path also works). A
glob makes the whole repo one file:
// contracts.autocheck.test.ts, the only spec-test file in the package
await autocheck("./src/**/*.ts", { params: { numRuns: 300 } });A sweep skips declaration and test files, passes over modules with no
contracts, and checks a function re-exported through barrels exactly once.
It does import every matched module, so anything with import-time side
effects (network clients, env validation) belongs outside the pattern. An
array can mix globs and plain paths to shape that. Under vitest, pass the
runner's own import so swept modules load through its transform pipeline
(native import can't resolve ./x.js specifiers to .ts sources):
await autocheck("./src/**/*.ts", { importer: (url) => import(url) });Types are erased at runtime, so autocheck derives the generators at test
time with the TypeScript compiler API. It loads the module's source into a
program, resolves each contracted export's parameter types with the
checker, and maps them structurally to fast-check arbitraries. Primitives,
literals, unions, arrays, tuples, objects with optional properties,
records, Date, Map/Set, and rest parameters are covered. The runtime
pairing of export to spec to raw implementation comes from the registry
do() populates (also exposed as contractOf(fn)). Preconditions filter
the generated inputs. Postconditions, yield invariants, and old-state
conditions are the properties.
A parameter type with no sound mapping (branded types, template literals, class instances, recursion) fails loudly with the export and parameter named. Two escape hatches: a per-call override for one slot, or a named-type registration that teaches the type once, everywhere it appears:
await autocheck("./ids.ts", {
overrides: { mint: { 0: arbUserId } }, // or a whole args-tuple arbitrary
exclude: ["writeToDb"], // needs a faked store; test via check()
params: { numRuns: 500 },
});
// Domains the type system can't express, declared once. Brands especially:
type PageSize = number & { readonly __brand: unique symbol };
registerArbitrary("PageSize", fc.integer({ min: 1, max: 500 }));An array of modules runs through one shared, incrementally grown program
(cached across calls), so a suite of spec tests pays program construction
once, not per file. The report also carries warnings: a spec whose
preconditions reject most generated inputs is flagged as starved. Assert
report.warnings is empty so a too-tight filter can't silently hollow out
the property. For environments where the type-checked source isn't the
executable module (compiled output, no TS loader), runtime: splits the
two: autocheck("./mod.ts", { runtime: "./dist/mod.js" }).
npm run example:autocheck runs a tutorial in five lessons under
examples/autocheck/:
- Write properties, not examples: bounds and conservation rules over a discriminated-union discount type.
- Encode a payment-status transition table as data and let literal unions
fuzz every
(status, event)cell. Includes the story of autocheck catching a bug in the spec itself. - Shape generators instead of starving preconditions: one override for an
integer-domain parameter, which also flushes out a planted pagination
bug and shrinks it to
[[""], 2]. - The mutation core, where autocheck stops: an evidence-mode claim-once
contract judged through
exerciseagainst a constructed in-memory store. specs.test.ts, the artifact to copy into a real project: a node:test spec file (run by this repo's ownnpm test) with one autocheck line per module and report assertions that turn a silently un-contracted export into a test failure.
autocheck needs a TS-capable test runtime (tsx, vitest) so the same source
file both type-resolves and imports. It only makes sense for
implementations callable with fabricated inputs. Functions that touch
external state need that state faked and injected, which no generator can
invent; exclude them and drive them through check/exercise with a
constructed environment.
Generative testing: gustave/check
A spec's postconditions are properties and its preconditions are input
filters. The check entry point runs an implementation against its spec on
fast-check-generated inputs,
shrinking failures to a minimal counterexample. fast-check is an optional
peer dependency; the core stays dependency-free unless you import
./check.
import * as fc from "fast-check";
import { check } from "gustave/check";
const arbBid = fc.record({ bidder: fc.string(), amount: fc.integer({ min: 1 }), timestamp: fc.nat() });
test("selectHighestBidder honors HighestBidder", async () => {
await check(HighestBidder, selectHighestBidder, [fc.array(arbBid)]);
});check uses spec.satisfiesPre() to discard generated inputs that fail
the preconditions and spec.exercise() to enforce the contract on each
run. exercise ignores the global disable switch and the onViolation
handler, so tests can't silently pass in log-only mode.
Does this project respect its contracts? gustave check
One pass over a whole project, reporting every call site that provably violates a precondition. No ESLint config required.
npx gustave check # nearest tsconfig.json
npx gustave check packages/square/tsconfig.json
npx gustave check --json # for editors and scriptssrc/billing.ts:41:12 divisor is nonzero — Division
divide(total, remaining)
src/catalog.ts:88:3 amount is positive — Transfer
transfer(from, to, -1)
2 provable violation(s) across 316 contracted call site(s) in 74 file(s).It exits 1 when anything is provable, so CI can gate on it, and builds a TypeScript program on demand rather than keeping one resident.
Install it in the project you want to audit — npm i -D gustave typescript —
rather than reaching for it through a bare npx. The compiler is an optional
peer dependency, since the runtime checks don't need it, and npx without a
local install resolves imports against its own cache instead of your project.
Read the summary as a ratio, not a verdict. The denominator is the point: provable is a much narrower claim than correct, and most call sites pass arguments no static analysis can pin down. Zero violations across 316 call sites means nothing was proven wrong — not that 316 calls were verified. The runtime checks are what cover the rest.
auditProject(tsconfigPath) from gustave/audit is the same thing as a
function, returning structured violations.
Coc.nvim users get it as :CocCommand gustave.checkProject, which audits the
nearest tsconfig above the current buffer and drops the hits in the quickfix
list. It runs in a subprocess, so the editor stays responsive on a large
project.
Writing contracts with an agent
The package ships a Claude Code skill covering spec<Sig>() — the clause
grammar, and the judgement around it: postconditions must be properties rather
than the implementation restated, a predicate repeated up a call chain wants to
be a branded type, predicates that capture module state silently opt out of
static checking.
Link it into a project once, and it tracks the installed version:
mkdir -p .claude/skills
ln -s ../../node_modules/gustave/skills/gustave-contracts .claude/skills/gustave-contractsAgents don't load instructions out of node_modules on their own, and
shouldn't — that would let any dependency inject directives. The link is the
opt-in.
Call-site enforcement: the ESLint rule
gustave/eslint ships a typed ESLint rule that evaluates preconditions
against statically-known arguments (literals, as const values,
const-initialized locals) and reports calls that provably violate them,
citing the condition by name:
divide(10, 0)
~~~~~~~~~~~~~ Precondition "divisor is nonzero" of divide fails for this call.// eslint.config.js (flat config, typed linting required)
import contracts from "gustave/eslint";
import tseslint from "typescript-eslint";
export default [
...tseslint.configs.recommendedTypeChecked,
contracts.configs.recommended,
];Arguments are known from literals and consts in the source, and also from types, which reaches calls with no literal anywhere:
function f(x: 0) { divide(10, x); } // the type says 0
if (b === 0) divide(10, b); // narrowed to 0
declare function nonPositive(): -1 | 0;
scaleBy(4, nonPositive()); // every member violatesA literal type may be a union, so the predicate is evaluated against every
value the argument can hold and reported only if it fails for all of them:
divide(10, zeroOrOne()) with 0 | 1 stays silent, because 1 is fine.
Reading types does mean trusting them — a lying as 0 will be believed.
The rule is sound by silence. It only fires when every argument a predicate
reads is statically evident and the predicate, run in an isolated sandbox,
returns false for exactly those values. Runtime-computed arguments and
predicates that capture anything beyond their own parameters are skipped,
so there are no false positives, and the runtime check still covers
everything the rule can't see. Contracted calls are detected from the
Contracted type brand, so the rule needs no configuration and works
across files and imports. The rule follows a spec chain back to its
require clauses, including a chain split across bindings or a shared
Spec artifact .do()'d elsewhere. Anonymous contracts are reported under
the callee's binding name, which the rule sees statically.
Classes: contracted()
A class's contract as one sheet: invariant for the class, one entry per
method. No decorators, so it works in plain JavaScript, under bundlers
without the transform, and on class expressions.
import { contracted } from "gustave";
export const Account = contracted(
class Account {
constructor(public balance: number) {}
withdraw(amount: number): number {
this.balance -= amount;
return this.balance;
}
},
{
invariant: { "balance is never negative": (a) => a.balance >= 0 },
withdraw: (m) =>
m
.require({
"amount is positive": (amount) => amount > 0,
// regular functions get `this` bound, so predicates read instance state
"sufficient funds": function (amount) { return amount <= this.balance; },
})
.old(function () { return { balance: this.balance }; })
.ensure({
"balance decreased by amount": ({ result, args: [amount], old }) =>
result === old.balance - amount,
}),
},
);
new Account(-5); // ContractViolation: invariant violated in Account: balance is never negativeEvery clause is inferred from the class: argument types, this, the result,
and the snapshot type old declares. Nothing needs an annotation, which is
the practical difference from the decorators — compare
@ensures<Account, [number], number, { balance: number }> below. Method
keys are checked against the class too, so a misspelled name is a type error
rather than a clause that silently guards nothing.
The clauses within a method stay a chain because their order is meaningful:
old returns a builder carrying the snapshot type, so writing ensure
before old is a type error, the same rule spec() enforces. The class
level is a sheet because its keys have no order. invariant is reserved —
a method of that name would be shadowed.
result is the settled value: string for an async method, not
Promise<string>. Async generator methods take .yield() for per-yield
checks alongside .ensure() for the final return value, and a method
declaring both reads old from a single snapshot.
Only prototype methods can be contracted. A class field holding a function
is per-instance, so there is nothing to wrap; contracted throws naming the
member rather than silently contracting nothing.
The same contract as decorators
Standard TS 5 / TC39 decorators (not experimentalDecorators). Equivalent
in every respect; pick whichever reads better.
import { invariant, requires, ensures } from "gustave";
@invariant<Account>({
"balance is never negative": (a) => a.balance >= 0,
})
class Account {
constructor(public balance: number) {}
@requires<Account, [number]>({
"amount is positive": (amount) => amount > 0,
// regular functions get `this` bound, so predicates can read instance state
"sufficient funds": function (amount) { return amount <= this.balance; },
})
@ensures<Account, [number], number, { balance: number }>(
{
"balance decreased by amount": function ({ old, args: [amount] }) {
return this.balance === old.balance - amount;
},
},
function () { return { balance: this.balance }; }, // old-state capture
)
withdraw(amount: number): number {
this.balance -= amount;
return this.balance;
}
}
new Account(-5); // ContractViolation: invariant violated in Account: balance is never negativeThe decorators keep their plural names because require, do, and yield
are reserved words as exported identifiers; only methods get the pure Eiffel
keywords. invariant alone also works without decorator syntax, being a
plain higher-order function: const Account = invariant<Account>({ ... })(class
{ ... }). For methods as well as the class, reach for contracted().
Async generator methods take @yields for per-yield checks and @ensures
for the final return value. Checks fire as the consumer drains the
generator:
class Meter {
@yields<Meter, [Range], Reading>({
"readings are in range": ({ value, args: [range] }) => range.contains(value.timestamp),
})
@ensures<Meter, [Range], AsyncGenerator<Reading, Summary>>({
"summary covers the range": ({ result, args: [range] }) => result.covers(range),
})
async *stream(range: Range): AsyncGenerator<Reading, Summary> { /* ... */ }
}Invariant semantics:
- Checked after construction and after every public method call or setter assignment, once the instance is quiescent. Nested and overlapping calls (sync or async) are ref-counted, and only the call that brings the depth back to zero checks. Intermediate states inside an operation are allowed to break the invariant.
- Violations name the trigger:
invariant violated in Account after withdraw(): ...(also on the error asviolation.trigger). - Inheritance composes. Methods and setters inherited from undecorated bases are wrapped, and when base and subclass are both decorated, every invariant in the chain is enforced on subclass instances. A base method that breaks a subclass invariant is caught (checked base-first).
- Each class's invariants first apply when its own constructor completes, so a base check never sees uninitialized subclass fields, and methods called from inside a constructor run unchecked.
- For async methods, the check runs when the returned promise fulfills.
- Not checked when a method throws or rejects. The exception already signals failure, and the instance may legitimately be mid-repair.
One contract, many implementations
When an interface has many class implementations (a provider/strategy
pattern), hoist the predicate record next to the interface and reuse it at
every attach point. Nothing forces @requires/@ensures predicates to be
written inline:
// next to the interface, written once
export const lineItemsEnsures = {
"every line item price is non-negative": (
{ result }: PostContext<[LineItemsParams<BaseSettings>], LineItem[], undefined>,
) => result.every((li) => li.price >= 0),
};
// in each implementation, one line
class StripeProvider implements BillingProvider {
@ensures(lineItemsEnsures)
generateLineItems(params: LineItemsParams<StripeSettings>) { /* ... */ }
}Two things make this work for generic interfaces. Contravariance: a
predicate typed over the base params is assignable where an
implementation's narrower params are expected, so one record typed against
the base instantiation covers every implementation. Decorator subjects: the
receiver is resolved per call, so violations report the concrete class
(postcondition violated in StripeProvider.generateLineItems: ...) even
though every implementation shares one predicate record.
The same record can feed a spec used purely as a property-test oracle, so
check() exercises all implementations against one contract:
const GenerateLineItems = spec<(p: LineItemsParams<BaseSettings>) => LineItem[]>()
.named("GenerateLineItems")
.ensure(lineItemsEnsures);
for (const provider of allProviders) {
test(`${provider.constructor.name} honors GenerateLineItems`, () =>
check(GenerateLineItems, (p) => provider.generateLineItems(p), [arbParams]));
}Turning it off in production
import { Contracts } from "gustave";
if (process.env.NODE_ENV === "production") Contracts.disable();When disabled, every wrapper short-circuits to the original function. The remaining overhead is one boolean check per call.
Performance notes: predicates are compiled into a flat list once at
declaration time, so the checks themselves allocate nothing per call. The
clause objects are snapshotted, so mutating them later has no effect. old
runs only when there are postconditions to consume it. An empty spec
returns the original function untouched. Wrapped implementations take the
contract's name and keep their own length.
Between all and nothing there is a sample rate, for contracts too expensive to
run on every call — the async-old transition contracts above all, which
double reads per checked call:
Contracts.sample(0.01); // check 1% of calls; 1 is the default, 0 checks noneThe rate is a coverage decision, not a correctness one: a violated contract
still reports, sampling only changes how long it takes to see one. That is what
makes enforceAsyncOld affordable in production — at 1% the doubled read is
1% of one read. One draw is made per call and governs that call's whole
contract, so a postcondition never finds the old snapshot missing because the
two halves disagreed. Individual specs can override the global rate:
.options({ sample: 0.001 }) // this contract is the expensive oneexercise and check ignore both rates — a sampled-out test is a silently
green test. Sampling covers the decorators, contracted() and class
invariants too.
For a softer rollout, report violations instead of throwing:
Contracts.onViolation((v) => logger.warn(v.message)); // null restores throwingIn log-only mode execution continues past a failed check, so one call can
report several violations. If a predicate itself throws, that is wrapped in
a ContractViolation too, with the original error as cause.
Limits
- Contracts are runtime checks, not static proofs. The compiler won't reject a call that violates a precondition; it fails when executed. TypeScript has no refinement types, so this is the ceiling for "in the language". The ESLint rule closes part of the gap for statically-known arguments.
- Invariants only trigger on method calls and setters. Direct field
mutation from outside (
acct.balance = -1) is not intercepted. Keep contracted state behind methods and accessors, or useprivate/#fields. - The prototype chain is snapshotted at decoration time, so a method override defined by a later, undecorated subclass is not wrapped. Decorate that subclass too.
- Anonymous specs stay anonymous. Nothing guesses names from stack traces
or source files; use
.named()ornameContracts(). oldcaptures whatever the callback returns, and no more. Like Eiffel'sold, which snapshots a reference rather than the structure behind it, a captured object is the live one:.old((arr) => ({ items: arr }))will be mutated by the call it is meant to be compared against. Capture scalars, or copy at the boundary — Eiffel spells thisold x.twin.- A duplicate condition name in a
requireElsealternative written as a factory is found on the first call that reaches that alternative, not necessarily the first call overall — the names are read from the records evaluation already builds, and no factory is run an extra time to find them sooner. The types reject it either way. rescueconditions describe a failure; they cannot handle or retry it. Eiffel'srescue/retryhas no equivalent here.
Development
npm install
npm test
npm run example # runnable tour in examples/bank.ts
npm run example:autocheck # the five-lesson autocheck tutorial