smart-pact
v0.2.3
Published
Point at your typed API routes. Get pact files. No wrappers. No manual contracts.
Maintainers
Readme
smart-pact
Point at your typed routes. Get pact files. Zero manual matchers.
smart-pact reads your TypeScript types directly via the compiler API and generates Pact v2 contract files — including request matchers, response matchers, error scenarios, and broker integration — without any hand-written matcher code.
The problem with standard Pact
Standard Pact requires you to write matchers by hand:
// Standard Pact — you write this for every field
.withRequest({
body: {
id: integer(),
name: like("Alice"),
status: term({ generate: "ACTIVE", matcher: "^(ACTIVE|INACTIVE)$" })
}
})Your types already say id: number, name: string, status: "ACTIVE" | "INACTIVE". You're writing the same information twice.
smart-pact reads your TypeScript types directly and generates the matchers for you.
Install
npm install smart-pactsmart-pact uses your project's own typescript installation — no version conflicts.
How it works
1. Write a routes file — the only file you write:
// pact.routes.ts
import { defineRoute, defineScenario } from 'smart-pact';
import type { User, CreateUserBody, ApiError } from './your-existing-types';
export default [
defineRoute<{ id: number }, User>({
description: "fetch user by ID",
method: "GET",
path: "/users/:id",
service: "user-service",
providerState: "user with ID 1 exists",
inputExample: { id: 42 },
outputExample: { id: 42, name: "Alice", status: "ACTIVE" },
}),
defineScenario<{ id: number }, ApiError>({
description: "fetch user — not found",
method: "GET",
path: "/users/:id",
service: "user-service",
status: 404,
providerState: "user with ID 999 does not exist",
inputExample: { id: 999 },
errorExample: { code: "USER_NOT_FOUND", message: "User 999 was not found" },
}),
defineRoute<CreateUserBody, User>({
description: "create a user",
method: "POST",
path: "/users",
service: "user-service",
successStatus: 201,
inputExample: { name: "Bob Smith", email: "[email protected]" },
outputExample: { id: 1, name: "Bob Smith", status: "ACTIVE" },
}),
];2. Run the CLI:
npx smart-pact generate \
--routes ./pact.routes.ts \
--consumer frontend \
--out ./pacts3. Get pact files with realistic example values:
{
"request": { "method": "GET", "path": "/users/42" },
"response": {
"body": {
"id": { "match": "integer", "value": 42 },
"name": { "match": "type", "value": "Alice" },
"status": { "match": "regex", "regex": "^(ACTIVE|INACTIVE)$", "value": "ACTIVE" },
"createdAt": { "match": "timestamp", "format": "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "value": "2026-01-01T00:00:00.000Z" }
}
}
}No hand-written matchers. No duplicate effort. Types drive the contract, examples make it readable.
Type → Matcher mapping
| TypeScript type | Pact matcher |
|------------------------------------|---------------------------------------|
| number (integer) | integer |
| number (decimal) | decimal |
| string | type (like) |
| boolean | boolean |
| null | null |
| Date | timestamp (ISO 8601) |
| "ACTIVE" \| "INACTIVE" | regex ^(ACTIVE\|INACTIVE)$ |
| T \| null | T's matcher (null stripped) |
| T[] / Array<T> | eachLike (min: 1) |
| Nested object / interface | Recursive body matchers |
| field?: T (optional) | Included, skipped if unresolvable |
| Record<string, V> | One example key with V's matcher |
| any | ❌ compile error |
| unknown | ❌ scan error |
| undefined (explicit) | ❌ scan error (use field?: T) |
any is banned at both compile time (via a generic guard in defineRoute) and at scan time. This ensures every field in your contract has a deterministic matcher.
defineRoute config
defineRoute<TInput, TOutput>({
description: string, // required — human label for this interaction
method: HttpMethod, // required — GET | POST | PUT | PATCH | DELETE
path: string, // required — "/users/:id" (:params from TInput)
service: string, // required — must match provider name in verify
providerState?: string, // Pact "Given" clause
successStatus?: HttpStatusCode, // default 200
requestHeaders?: Record<string, string>,
responseHeaders?: Record<string, string>,
liveUrl?: string, // optional live URL for response examples
inputExample?: Record<string, unknown>, // example values for request path params + body
outputExample?: Record<string, unknown>, // example values for response body matchers
})Path parameters and inputExample
Path parameters (:param placeholders in path) are interpolated from inputExample. When a matching key exists, it replaces the placeholder in the pact's example request path; without it, numeric params fall back to "1".
defineRoute<{ id: number }, User>({
path: "/users/:id",
inputExample: { id: 42 }, // → request path: /users/42
// no inputExample // → request path: /users/1
})For POST/PUT/PATCH routes, non-path-param fields from inputExample are used as the value inside request body matchers. For GET/DELETE routes they populate example query string values.
Response body and outputExample
outputExample sets the value field inside each generated response matcher. The matcher type always comes from TOutput via the TypeScript compiler — outputExample only controls what concrete value appears in the pact file.
defineRoute<{ id: number }, User>({
outputExample: {
id: 42, // integer matcher, value: 42 (not the default 1)
name: "Alice", // type matcher, value: "Alice" (not "example")
status: "ACTIVE", // regex matcher, value: "ACTIVE" (picked from the enum)
},
})Without outputExample, values fall back to generic defaults: 1 for numbers, "example" for strings, true for booleans, and the first member for string literal unions.
defineScenario — error and edge-case paths
defineScenario defines an error or edge-case response for an existing route. It is matched to a parent route by path + method + service and emitted as an additional Pact interaction in the same file.
defineScenario<TInput, TError>({
description: string, // required
method: HttpMethod, // required
path: string, // required — must match a defineRoute
service: string, // required
status: HttpStatusCode, // required — typically 4xx/5xx
providerState?: string,
requestHeaders?: Record<string, string>,
inputExample?: Record<string, unknown>, // example values for request path params + body
errorExample?: Record<string, unknown>, // example values for error response body matchers
})TError is scanned exactly like TOutput in defineRoute — full recursive matcher generation from your error response types.
inputExample and errorExample on a scenario are independent from the parent route's examples. Each scenario controls its own path interpolation and response values separately:
defineRoute<{ id: number }, User>({
path: "/users/:id",
inputExample: { id: 10 },
outputExample: { id: 10, name: "Alice" },
}),
defineScenario<{ id: number }, ApiError>({
path: "/users/:id",
status: 404,
inputExample: { id: 999 }, // scenario path: /users/999 (independent of parent)
errorExample: { code: "USER_NOT_FOUND", message: "User 999 was not found" },
}),Provider verification
Run this against a live provider to confirm it satisfies every interaction in the pact files:
npx smart-pact verify \
--provider user-service \
--provider-url http://localhost:3001 \
--pact-dir ./pactsOutput:
[smart-pact] Verifying 3 interactions — frontend → user-service
• "fetch user by ID" ... ✅
• "fetch user — not found" ... ✅
• "create a user" ... ✅
─────────────────────────────────────────
[smart-pact] Verification Summary
─────────────────────────────────────────
✅ frontend → user-service: 3/3 passed
─────────────────────────────────────────
Total: 3 passed, 0 failedProvider states
When a providerState is set on a route or scenario, the verifier POSTs to the --state-url endpoint before each interaction so the provider can seed the right fixture:
// Express example
app.post("/_pact/state", async (req, res) => {
const { state } = req.body;
await seedDatabase(state);
res.json({ ok: true });
});npx smart-pact verify \
--provider user-service \
--provider-url http://localhost:3001 \
--state-url http://localhost:3001/_pact/state \
--pact-dir ./pactsPact Broker integration
Publish pacts
npx smart-pact publish \
--pact-dir ./pacts \
--consumer-version 1.2.3 \
--broker-url https://broker.example.com \
--broker-token $PACT_BROKER_TOKENBasic auth is also supported via --broker-username and --broker-password.
Can-I-Deploy
npx smart-pact can-i-deploy \
--pacticipant frontend \
--version 1.2.3 \
--broker-url https://broker.example.com \
--broker-token $PACT_BROKER_TOKENProgrammatic broker usage
import { PactBrokerClient, printPublishSummary, printCanIDeployResult } from 'smart-pact';
const client = new PactBrokerClient({
brokerBaseUrl: "https://broker.example.com",
brokerToken: process.env.PACT_BROKER_TOKEN,
// Or: brokerUsername / brokerPassword for basic auth
});
const results = await client.publishPacts("./pacts", "1.2.3");
printPublishSummary(results);
const deploy = await client.canIDeploy("frontend", "1.2.3");
printCanIDeployResult(deploy);
// Fetch latest pacts for a provider from the broker (optionally write to disk)
const pacts = await client.fetchPacts("user-service", "./pacts");Inspect pact files
npx smart-pact summary --pact-dir ./pactsCLI reference
smart-pact — Type-driven consumer contract testing
Commands:
generate Scan a routes file and write pact files
verify Verify pact files against a live provider
publish Publish pact files to a Pact Broker
can-i-deploy Check whether a version is safe to deploy
summary Inspect pact files
Generate options:
--routes Path to pact.routes.ts (required)
--consumer Consumer service name (required)
--out Output directory, default: ./pacts
--tsconfig Path to tsconfig.json (auto-detected if omitted)
Verify options:
--pact-dir Directory of pact files, default: ./pacts
--provider Provider service name (required)
--provider-url Base URL of running provider (required)
--state-url Provider state setup endpoint (optional)
--broker-url Pull pacts from broker instead of disk (optional)
Publish options:
--pact-dir Directory of pact files, default: ./pacts
--consumer-version Semver version of the consumer (required)
--broker-url Base URL of Pact Broker (required)
--broker-token Bearer token for broker auth (optional)
--broker-username / --broker-password (optional)
Can-I-Deploy options:
--pacticipant Consumer or provider name (required)
--version Version to check (required)
--broker-url Base URL of Pact Broker (required)
--broker-token Bearer token for broker auth (optional)
--broker-username / --broker-password (optional)
Summary options:
--pact-dir Directory of pact files, default: ./pactsCI integration
# .github/workflows/contracts.yml
- name: Generate pacts
run: |
npx smart-pact generate \
--routes ./src/pact.routes.ts \
--consumer frontend \
--out ./pacts
- name: Publish pacts to broker
run: |
npx smart-pact publish \
--consumer-version ${{ github.sha }} \
--broker-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}
- name: Can I deploy?
run: |
npx smart-pact can-i-deploy \
--pacticipant frontend \
--version ${{ github.sha }} \
--broker-url ${{ secrets.PACT_BROKER_URL }} \
--broker-token ${{ secrets.PACT_BROKER_TOKEN }}
# Provider side:
- name: Verify contracts
run: |
npx smart-pact verify \
--provider user-service \
--provider-url ${{ env.API_URL }} \
--state-url ${{ env.API_URL }}/_pact/stateProgrammatic API
import {
defineRoute, defineScenario,
generatePacts, scanRoutesFile,
verifyPacts, printVerificationSummary,
shapeToPactBody,
PactBrokerClient, printPublishSummary, printCanIDeployResult,
} from 'smart-pact';Key exported types
import type {
RouteDefinition, ScenarioDefinition,
ResolvedRoute, ResolvedScenario,
TypeShape, PactFile, PactInteraction, PactBody, PactMatcher,
SmartPactConfig, VerifyConfig, VerificationResult, InteractionResult,
PactBrokerConfig, PublishResult, CanIDeployResult, BrokerVerification,
HttpMethod, HttpStatusCode,
} from 'smart-pact';How the scanner works
smart-pact uses the TypeScript compiler API to read your types at scan time — not at runtime. This is the only approach that produces correct matchers.
Runtime inference from example values is wrong: a string field that happens to contain a UUID would get a regex UUID matcher, but the type says string, so the correct matcher is type. Types reflect developer intent; example values reflect one specific instance. inputExample, outputExample, and errorExample exist only to populate the value fields inside matchers — they have no effect on which matcher type is chosen.
The scanner resolves TInput and TOutput from your defineRoute calls, walks the compiler's type graph recursively, and builds an intermediate TypeShape representation. TypeShape is then converted to Pact matchers by shapeToPactBody, with the example values threaded in per field. The full type graph — including interfaces from other files, generic expansions, and intersections — is resolved, provided all files are reachable from your tsconfig.json.
tsconfig.json is auto-detected by walking up the directory tree from the routes file. Pass --tsconfig to override.
Known limitations (v0.2.x)
| Limitation | Notes |
|---|---|
| Pact spec v2 only | Matchers are inline in the body; v3 matchingRules not yet supported |
| Response headers written but not verified | Headers appear in pact files but are not evaluated during verify |
| Record<string, V> produces one example key | Safe but imprecise for open-ended maps |
| Types from node_modules may not resolve | Requires the package to be included in your tsconfig compilation unit |
| Circular type references capped at depth 20 | Very deep recursive types are truncated |
License
MIT
