npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

gustave

v1.3.4

Published

Design by Contract for TypeScript, in Eiffel's own grammar: require, old, ensure, do — plus class invariants

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 gustave
import { 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]: 0

A 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 dividend

The 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_modules and resolves relative paths against its own cwd, so it must be absolute).
  • Other tsserver-based clients work if they pass --allowLocalPluginLoads or 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-gustave

Details 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]: false

satisfiesPre 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/:

  1. Write properties, not examples: bounds and conservation rules over a discriminated-union discount type.
  2. 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.
  3. 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].
  4. The mutation core, where autocheck stops: an evidence-mode claim-once contract judged through exercise against a constructed in-memory store.
  5. specs.test.ts, the artifact to copy into a real project: a node:test spec file (run by this repo's own npm 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 scripts
src/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-contracts

Agents 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 violates

A 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 negative

Every 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 negative

The 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 as violation.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 none

The 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 one

exercise 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 throwing

In 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 use private/# 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() or nameContracts().
  • old captures whatever the callback returns, and no more. Like Eiffel's old, 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 this old x.twin.
  • A duplicate condition name in a requireElse alternative 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.
  • rescue conditions describe a failure; they cannot handle or retry it. Eiffel's rescue/retry has 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