A port of the official Val Town TypeScript SDK that is generated with Hey API's openapi-ts instead of Stainless. The call style is kept as close to the original as the generator allows, so migrating between the two is mostly a drop-in.
- Live spec: https://api.val.town/openapi.json
- Generator:
@hey-api/openapi-tswith the Fetch client (@hey-api/client-fetch)
npm install @valtown/sdk-openapi-tsRequires Node.js >= 20 (any runtime with a global fetch).
import ValTown from '@valtown/sdk-openapi-ts';
const client = new ValTown({
bearerToken: process.env['VAL_TOWN_API_KEY'], // default: VAL_TOWN_API_KEY env var
});
const response = await client.emails.send({ body: 'hi' });
console.log(response.message);Operation IDs in the source spec are rewritten (see
scripts/transform-spec.mjs) to dotted paths that
mirror the original SDK's structure, and openapi-ts groups them into nested
containers. The following call styles match the original exactly:
| Original SDK | This port |
|---|---|
client.me.profile.retrieve() |
client.me.profile.retrieve() |
client.me.vals.list({ offset, limit }) |
client.me.vals.list({ offset, limit }) |
client.emails.send({ ... }) |
client.emails.send({ ... }) |
client.vals.retrieve(valID) |
client.vals.retrieve({ val_id }) |
client.vals.branches.create(valID, {...}) |
client.vals.branches.create({ val_id, ... }) |
client.alias.username.retrieve(username) |
client.alias.username.retrieve({ username }) |
client.orgs.memberships.list(orgID) |
client.orgs.memberships.list({ org_id }) |
client.blobs.store(key, {...}) |
client.blobs.store({ key, ... }) |
client.sqlite.execute({ ... }) |
client.sqlite.execute({ ... }) |
client.token.slack({ ... }) |
client.token.slack({ ... }) |
Positions vs. options objects. The original SDK passes path parameters
positionally (retrieve(valID)); openapi-ts always takes a flat options object
(retrieve({ val_id })). This is the one unavoidable call-style difference
across every open-source generator we evaluated — see
Why openapi-ts below.
Non-2xx responses throw the same typed errors as the original:
try {
await client.emails.send({ body: 'hi' });
} catch (err) {
if (err instanceof ValTown.APIError) {
console.log(err.status); // e.g. 400
console.log(err.name); // e.g. BadRequestError
console.log(err.headers); // Headers
}
}Status → class: 400 BadRequestError, 401 AuthenticationError,
403 PermissionDeniedError, 404 NotFoundError, 422
UnprocessableEntityError, 429 RateLimitError, >=500
InternalServerError. Network failures become APIConnectionError /
APIConnectionTimeoutError.
Requests are retried 2× by default (exponential backoff with jitter) on
connection errors and 408/409/429/5xx, respecting Retry-After.
Requests time out after 60s by default.
const client = new ValTown({ maxRetries: 0, timeout: 20_000 });Pass a token via bearerToken or the VAL_TOWN_API_KEY environment variable.
Only endpoints that the spec marks as authenticated send
Authorization: Bearer <token> — public endpoints (e.g. aliases, val lookup)
do not, which is faithful to the API.
The generated list methods return the raw { data, links } result set. To get
the original SDK's auto-pagination call style, wrap the list call with
client.paginate(...) — the returned value is a PagePromise that is itself
async-iterable, exactly like the original's unawaited for await:
// Walks every page via `links.next`, yielding individual items:
for await (const val of client.paginate(client.vals.list, { limit: 20 })) {
console.log(val.id);
}
// Or work with pages explicitly:
const page = await client.paginate(client.vals.list, { limit: 20 });
for (const val of page.data) console.log(val.id);
while (page.hasNextPage()) {
page = await page.getNextPage();
}hasNextPage() is true only while there are items and a links.next
(terminal detection matches the original), and follow-up page requests reuse
the same client — auth headers, retries, timeouts, and error mapping all apply.
const data = await client.get('/v1/some/path', { query: { limit: 10 } });
// client.post / put / patch / delete also availableGenerated response/param types are exported with <Operation>Response /
<Operation>Data names (e.g. EmailsSendResponse), plus shared schemas
(Val, User, Org, ResultSet, …):
import ValTown, { type Val, type ResultSet } from '@valtown/sdk-openapi-ts';
const val: Val = await client.vals.retrieve({ val_id: '1' });The SDK is fully generated from the pinned spec in openapi.json.
npm run spec:fetch # download the latest spec from api.val.town (updates openapi.json)
npm run spec:transform # rewrite operationIds into the parity dotted paths
npm run generate # run openapi-ts -> src/client/
npm run verify # typecheck + test + buildsrc/client/ is generated and checked in (so consumers don't need the
generator), but should not be edited by hand. New operations added upstream by
Val Town will fall back to a deterministic auto-name until someone adds an
explicit entry to the OPERATION_IDS table in
scripts/transform-spec.mjs.
.github/workflows/regenerate.yml runs on
a weekly schedule (and via manual dispatch):
- downloads the live spec,
- regenerates
src/client/, - verifies (typecheck, tests, build),
- commits + pushes any changes to
main, and - runs release-please which
queues a
release: vX.Y.Zpull request incorporating the regeneration.
Merging that PR creates the release tag; .github/workflows/publish.yml
publishes the package to npm.
The
.github/directory lives at this repository's root:regenerate.ymlpushes regenerated code tomainand then runs release-please from here.
We evaluated the main open-source TypeScript generators against the goal of
matching the Stainless call style (nested client.me.vals.list() tree, flat
data returns, typed errors):
- openapi-ts is the only one that emits a class-per-nesting-segment client
(
client.me.vals.list()) directly from the spec, withstrategy: 'single'+nesting: 'operationId'— no handwritten resource shell. - openapi-typescript + openapi-fetch generates types only and is URL-centric
(
client.GET('/v2/me/vals')); reproducing the resource tree would mean handwriting all 52 methods. - orval is React Query–centric; its plain-fetch mode emits per-endpoint functions and needs a custom plugin for class-style output.
- swagger-typescript-api can do per-tag classes via custom templates, but is less actively maintained and harder to bend to the exact tree.
The one gap common to all of them — positional path params like retrieve(valID)
— we accept and document rather than hand-maintain wrappers over the whole
generated surface.
| Area | Original (Stainless) | This port (openapi-ts) |
|---|---|---|
| Path params | positional (retrieve(valID)) |
options object ({ val_id }) |
| Pagination | Page w/ hasNextPage()/getNextPage() |
raw { data, links } + client.paginate() for-await |
| Response type names | ValTown.EmailSendResponse |
EmailsSendResponse (+ alias) |
.asResponse() |
available on APIPromise |
via client.raw / raw verbs |
| Runtime | dual CJS/ESM across many runtimes | ESM + CJS, Node >= 20 |
MIT