TypeScript
truewire generate typescript renders a project's plan (docs/plan.md) as an ESM
TypeScript package with the same guarantees as the Python one: typed requests and
responses, runtime validation on by default with a per-call override, and generated
pagination walkers. The runtime it depends on is @truewire/core (packages/core-ts).
Status: in the repository, not on npm. @truewire/core is linked with a relative file:
dependency until it is published. HTTP rpc endpoints are generated; stream endpoints
(WebSocket subscriptions, examples/kraken) are not yet, and neither are composite cores
(forward/params/children in truewire.toml). See the end of this page for the list.
Using it
# truewire.toml
[typescript]
package = "github" # the package lives at src/github/
src = "src"
name = "GitHub" # the root classtruewire generate typescript # writes src/github/**/*.ts and .truewire/typescript-files.json
truewire generate typescript --check # CI: every owned file exists and is what the plan rendersThen write src/github/core/index.ts (below), add @truewire/core to package.json, and
use the client:
import { GitHub } from 'github'
import { Core } from 'github/core'
const client = new GitHub(new Core({ token: process.env.GITHUB_TOKEN }))
const repo = await client.repos.get({ owner: 'truewire-dev', repo: 'truewire' })
repo.created_at // a Date
const commits = await client.repos.listCommitsPaged({ owner: 'truewire-dev', repo: 'truewire', per_page: 50 })
for await (const page of client.issues.listPaged({ owner: 'truewire-dev', repo: 'truewire', state: 'all' })) ...
const raw = await client.repos.get({ owner: 'truewire-dev', repo: 'truewire' }, { validate: false })examples/github is the reference: its truewire.toml, src/github/core/index.ts,
package.json, tsconfig.json and test/ are what a project copies.
What is generated
One file per spec node, beside the Python package when both are declared:
| file | contents |
|---|---|
types/index.ts, types/<scope>.ts |
the shared schemas.json types, one module per scope |
<router>/<endpoint>.ts |
the endpoint's Request, its response types, their codecs, and a class with the method |
<router>/index.ts |
a router class delegating to its endpoints and holding its child routers |
main.ts |
the root class, new GitHub(core) |
meta.ts |
one interface per [cores.<name>] with a meta schema (DefaultMeta) |
index.ts |
re-exports the root class, the shared types and meta.ts |
Names Truewire invents are camelCase/PascalCase (list_commits becomes listCommits,
listCommitsPaged, class ListCommits); names the API invented stay verbatim
(per_page, html_url), so the request object is the wire object and a recorded
request.json is a valid argument as it stands. Every method takes the request as its
first parameter and an options object as its second: { validate?: boolean; signal?: AbortSignal }. validate: false returns the raw JSON.parse value, typed unknown: each
method is declared twice, an overload for { validate: false } returning unknown and one
for every other call returning the declared type, as Python's @overloads return Any and
the record (docs/generated.md). The output
is printed by the generator itself (two-space indent, sorted imports, JSDoc from the spec's
descriptions); no formatter runs over it.
A type is an interface (or a type alias) and, beside it, a codec of the same name:
export interface Label {
id: number
name: string
description?: string | null
}
export const Label: Codec<Label> = t.object({
id: t.integer,
name: t.string,
description: t.optional(t.nullable(t.string)),
})Codec<Label> on the constant is what makes tsc prove the codec and the interface agree;
the two cannot drift. parse turns a decoded wire value into the typed one (decimal-string
into the branded Decimal, every timestamp format into a Date behind its alias, date
into DateIso, integer-string/boolean-string into number/boolean) and names the
JSON pointer of the first failure in a ValidationError; dump renders it back to the wire.
Objects keep keys they were not told about, unions try their variants in order, tuples are
readonly. The combinators (t.object, t.array, t.tuple, t.union, t.literal,
t.record, t.nullable, t.optional, t.lazy, the formats) are the whole validator: no
schema interpretation, no dependency, no eval.
The core contract
The generated code never imports the project's core. Each generated class takes its core as
a constructor argument typed by @truewire/core/contract, the TypeScript half of
truewire_core.contract:
interface CallOptions { validate?: boolean; signal?: AbortSignal }
interface HttpCall<Req, Res, Meta> extends CallOptions {
method: string | undefined // the wire HTTP method; undefined when the spec leaves it to the core
path: string // the wire path template; {name} placeholders are filled from request
request: Req | undefined // the generated Request value (wire keys), or undefined
requestCodec: Codec<Req> | undefined
responseCodec: Codec<Res> | undefined
meta: Meta // the endpoint's declared meta, in the shape the core's schema states
}
interface HttpEndpoint<Meta = Record<string, never>> {
request<Req, Res>(call: HttpCall<Req, Res, Meta>): Promise<Res>
}A generated endpoint under the default core is class Get { constructor(readonly core: HttpEndpoint<DefaultMeta>) {} } and calls this.core.request({ method: 'GET', path: '/repos/{owner}/{repo}', request, requestCodec: Request, responseCodec: Repository, meta: { public: true }, ...options }). A router's constructor takes the intersection of its
endpoints' core types and hands the same object to every child; the root class is the same
shape under the project's name. CommandEndpoint<Meta> (a WebSocket command: request({ path, ... })) and StreamEndpoint<Meta> (subscribe({ channel, parameters, ... })
returning a Subscription) are declared for the shapes the backend will emit next.
The hand-written core is an object satisfying that interface by shape. examples/github's
does four things in request: requestCodec.dump(request) to get the wire values, fill
the {placeholders} and send the rest as the query (or as a JSON body for POST/PUT/PATCH)
through HttpClient, map a non-2xx reply to ApiError/AuthError/BadRequest/
RateLimited, and parseJson(responseCodec, text) when validation is on. Envelope
unwrapping (envelope.payload), signing and headers are the core's business, as in Python;
the plan's meta tells it what each endpoint needs.
Pagination
<method>Paged is rendered from the plan's pagination decisions:
walker: paginated(apagewalk ended byshort_page,emptyortotal; atokenwalk ended byabsent_cursor; a plainseekwalk): a plain method returningPaginatedResponse<Row, State>, awaitable (every row, flattened) and async-iterable (one page at a time), withpages(),resume(state)andvia(invoker). Itsnext(state)is pure instate, so a page can be retried and a walk resumed. The request type is the endpoint'sRequestwithout the driver parameter (ListPagedRequest = Omit<Request, 'page'>), unless the cursor is required and seeds the walk.walker: generator(offset, andpage/tokenshapes the resumable form does not cover):async *<method>Pagedyielding every page's response.- A
totalterminator is checked on every page: a missing total, or one that disagrees with an earlier page of the same walk, throwsLogicError.
Testing
examples/github/test is the pattern. setup.ts is a vitest globalSetup that spawns
truewire mock --http-port 0 and provides its base URL to every test through
inject('httpBaseUrl'); replay.test.ts walks spec/endpoints/**/examples/*.request.json
and calls, for each, the method its function path names with the recorded request
(validation on, so the codec accepts the recorded response); paging.test.ts walks the same
recorded multi-page captures as test/test_paging.py; codecs.test.ts round-trips recorded
bodies through parse and dump without the mock. CI (examples-ts) builds
@truewire/core, installs the example, runs truewire generate typescript --check, tsc --noEmit and vitest run.
After a change to packages/core-ts, rebuild it (yarn build) and reinstall the example
(yarn install --force): a file: dependency is copied at install time.
Not generated yet
- Stream endpoints (
kind: stream): reported as skipped. The WebSocket runtime (@truewire/core/ws) andStreamEndpointexist; the emitter andexamples/kraken's core are the next step, and what proves the WebSocket half of roadmap item 10. - Composite cores: a core declaring
forward,paramsorchildrenin[python.cores.<name>]is skipped (examples/kraken'sroot/streams). windowwalks,seekwalks withoverlap, and theunchangedterminator: the plain method is generated with a note; no walker.- A
rpcendpoint with bothhttpandwstransports is generated for HTTP only. truewire docs checkfor ```ts blocks, andtruewire surfacefor the camelCase rule.- Publishing
@truewire/core(and a@truewire/testingwith the replay helpers) to npm.