Skip to content
TypeWire

@tahanabavi/typewire-cli

documents v0.0.0

Command-line tools for TypeWire contracts — contract test runner, scaffolding, and reports.

Config

typewire.config.ts in your project root:

ts
import { defineConfig } from "@tahanabavi/typewire-cli";
import { ApiClient } from "@tahanabavi/typefetch";
import { contracts } from "./src/contracts";

export default defineConfig({
typefetch: {
contracts,
createClient: ({ baseUrl, token }) => {
const client = new ApiClient({ baseUrl: baseUrl!, token }, contracts);
client.init();
return client;
},
},
});

One section per package, so nothing has to be renamed when a package is added and a project that only uses typefetch writes only that section. lint and diff sit at the top level and a section may override them.

Prefer createClient over client: it is what lets --base-url and --token reach the client, so one config serves local, staging and CI.

transports

ts
typefetch: {
contracts,
transports: [graphqlTransport(), grpcTransport()],
}

For the commands that never build a client. method and path exist only on http endpoints, so anything printing a route has to ask the adapter — and list has no client to ask. Declaring the adapters here is what lets a gRPC route print unary user.v1.UserService/GetUser instead of ?, while list stays runnable against an API that is not up. The built-in http adapter is always present; only add the extras.

contracts is the only always-required key. A client is required by the commands that actually make requests, and by nothing else — so list (and later lint, diff, explain) run against a contract file with no API reachable at all. That is the difference between a check that runs on every commit and one that never gets wired up.

Several APIs in one repo

A repo with a dashboard API, an admin API and a landing API declares them as projects — each with its own contracts, client, middleware and base URL:

ts
export default defineConfig({
// Shared defaults; a project may override any of them.
lint: { rules: { "path-params-declared": "error" } },

projects: {
dashboard: {
typefetch: { contracts: dashboardContracts, createClient: createDashboardClient },
},
admin: {
typefetch: { contracts: adminContracts, createClient: createAdminClient },
lint: { rules: { "duplicate-id": "off" } }, // merged over the shared rules
diff: { baseline: "admin.lock.json" }, // its own API-surface snapshot
},
landing: {
typefetch: { contracts: landingContracts }, // no client — list/lint only
},
},
});

One file rather than three, because the alternative is three configs, three --config flags in every script, and three CI steps that drift apart.

bash
typewire list                              # every project, labelled
typewire test # every project; any failure fails the run
typewire test --project admin # just one
typewire test --project dashboard,admin # some

Every project runs by default. A CI gate that quietly checked one of three API surfaces and reported green would be worse than no gate. Reports go to a per-project folder for the same reason — sharing one output path means the last project silently overwrites the others and the report describes one API while looking complete.

Commands that need exactly one project refuse to guess: with several declared and no --project, you get an error naming them rather than a run against whichever happened to be first.

Omit projects entirely for the single-API case — one API should never cost the ceremony of naming it. Internally it resolves to one implicit project, so no command branches on which shape you wrote.

Discovery

Walks up from the working directory, so a package inside a monorepo inherits the root config without a --config flag in every script. .ts, .mts, .cts, .js, .mjs and .cjs are all supported; TypeScript ones load through jiti, with your tsconfig.json path aliases applied — a contract file that imports @/schemas resolves the way it does everywhere else in your project.

extends

ts
export default defineConfig({
extends: "../../typewire.base.ts", // or a package name
typefetch: { contracts },
});

Merged key by key, with the extending file winning. Arrays are replaced rather than concatenated, so a base's formats: ["markdown", "json", "html"] can be narrowed to ["json"].

A config as a function

ts
export default defineConfig(({ mode, command, ci }) => ({
typefetch: { contracts },
diff: { baseline: ci ? "typewire.lock.json" : ".typewire/local.lock.json" },
}));

So --mode can change the baseline or the report format without a second file.