A smithy-build plugin that
turns a Smithy model into a single self-contained TypeScript file: zod
schemas + types for every reachable shape, typed HTTP clients for your
alloy#simpleRestJson services, and typed mock
stubs for driving those services in Storybook.
From one model, into a single generated.ts:
- Data types — a
XxxSchema(zod) +type Xxx = z.infer<typeof XxxSchema>for every reachable structure, union, enum, list, map, and simple-type alias (aliases are.brand<'Xxx'>()-ed for nominal typing). - Error classes — one
XxxError extends Errorper@errorshape, carrying the declared HTTP status and error-type name. - Transport — a small
Transportinterface plus request/response types. The generated clients are transport-agnostic; a ready-madefetchimplementation ships separately in@polyvariant/smithy-ts-runtime. Models with streaming operations also get aStreamTransport(see Streaming). - Service clients — one
XxxClientclass per service, oneasyncmethod per operation. Each method walks the operation's@httptrait to build the request (URI labels, query, headers, body) and parses the response with the generated output schema, dispatching declared errors by status (andX-Error-Typewhen several errors share a status). - Storybook mock stubs — a typed
XxxHandlersinterface +XxxMockdescriptor per service, plus a sharedmockServiceruntime, so a story can implement just the operations it exercises.
Add the artifact to your smithy-build classpath and reference the plugin by name (ts-codegen):
{
"version": "1.0",
"plugins": {
"ts-codegen": {
"outFile": "generated.ts",
"excludeServices": ["myorg.auth#AuthService"]
}
}
}Settings:
outFile— destination filename within the plugin's output dir (defaultgenerated.ts).excludeServices— fully-qualified service shape ids (namespace#Name) to skip. Their referenced data shapes are still emitted; only the operations and the client class are dropped. Use this for services you hand-roll (streaming, custom routing, …).
Add the sbt plugin (project/plugins.sbt):
addSbtPlugin("org.polyvariant" % "sbt-smithy-ts-codegen" % "<version>")Enable it on a project and point it at your smithy sources:
enablePlugins(SmithyTsCodegenPlugin)
tsCodegenSmithyDirs := Seq(baseDirectory.value / "src" / "main" / "smithy")
tsCodegenOutputFile := baseDirectory.value / "src" / "generated.ts"
tsCodegenExcludeServices := Seq("myorg.auth#AuthService")then run tsCodegen. The plugin resolves the smithy-ts-codegen-cli artifact (at the plugin's
own version) with coursier and runs it in a forked JVM, so nothing about your project's Scala
version affects the codegen. Settings:
tsCodegenSmithyDirs— dirs scanned for*.smithy/*.json(defaultsrc/main/smithy).tsCodegenOutputFile— where to write the TypeScript (defaulttarget/generated.ts).tsCodegenExcludeServices— service shape ids to skip (see above).tsCodegenExtensions— artifacts to put on the forked codegen's classpath, for extension implementations (default none).%%resolves against the codegen's Scala version, not your project's.tsCodegenVersion— override the codegen version to resolve (defaults to the plugin's own).
The core is a pure function from a loaded software.amazon.smithy.model.Model to a String:
import org.polyvariant.smithy.ts.TsCodegenPlugin
import software.amazon.smithy.model.Model
val model: Model = ???
val ts: String = TsCodegenPlugin.generate(model, excludeServices = Set.empty)The smithy-ts-codegen-cli artifact's org.polyvariant.smithy.ts.cli.Main assembles a model
from .smithy/.json sources and writes the output file. Run it with the CLI (and its deps) on
the classpath:
Main <smithyDirs (path-separator-joined)> <outFile> [<excludeServices (comma-joined)>]
This is what the sbt plugin forks; the smithy-build plugin is discovered via the SPI.
Some codegen decisions depend on conventions the model does not describe. The most common is a service mounted under a path prefix that appears nowhere in the model — a server framework that derives one from a trait, or a reverse proxy — after which every generated request misses the prefix and 404s.
That prefix cannot be a plain setting: one codegen run can emit several services, and they need
not share one. And it should not be a trait the generator knows about, because that bakes one
organization's conventions into it. So it is an interface you implement instead. Depend on
smithy-ts-codegen-api (just smithy-model, not the generator):
libraryDependencies += "org.polyvariant" %% "smithy-ts-codegen-api" % "<version>"import org.polyvariant.smithy.ts.api.{PathSegment, TsCodegenExtension}
import software.amazon.smithy.model.shapes.{OperationShape, ServiceShape}
class InternalPrefix extends TsCodegenExtension {
override def transformPath(
service: ServiceShape,
operation: OperationShape,
path: List[PathSegment],
): List[PathSegment] =
if (service.hasTrait(classOf[ApiInternalTrait]))
PathSegment.Literal("internal") :: PathSegment.Literal(service.getVersion) :: path
else
path
}Extensions are discovered with java.util.ServiceLoader, so list the implementation in
META-INF/services/org.polyvariant.smithy.ts.api.TsCodegenExtension and put its artifact on the
codegen's classpath. From sbt that is tsCodegenExtensions:
tsCodegenExtensions := Seq("myorg" %% "my-extension" % "1.0.0")which resolves the artifact with coursier and adds it to the forked codegen's classpath. Under smithy-build, add it to the same classpath as the plugin itself.
Notes:
transformPathreplaces the whole path, so an extension can prepend, reorder or drop segments — not only prefix. ReturningNilmeans/.- A path is segments, not a string. The client interpolates a
Labelas${encodeURIComponent(…)}while the Storybook mock router matches it as a wildcard, so the two have to stay distinguishable. ALiteralmay not contain/— return several segments. - Both consumers resolve through the same call, so the generated client and the generated mocks cannot drift apart on a route.
- A
Labelmust name a member bound with@httpLabel. Inventing one fails codegen with an error naming it, rather than emitting a client that references a field that isn't there. - Every method has a no-op default, so an implementation overrides only what it needs and keeps compiling as methods are added.
- Several extensions apply in an unspecified order, each seeing the previous one's result. Put competing rules in one extension rather than relying on classpath order.
Operations of an org.polyvariant.ndjson#ndjsonRestJson
service may stream their request body, their response body, or both. Smithy restricts
@streaming to blob and union, and the protocol requires such a member to carry
@httpPayload, which gives exactly two framings — applied identically in both directions:
| Smithy | Wire | TypeScript |
|---|---|---|
@streaming blob |
application/octet-stream, body verbatim |
AsyncIterable<Uint8Array> |
@streaming union |
application/x-ndjson, one JSON value per line |
AsyncIterable<TheUnion> |
A streamed member is surfaced as an AsyncIterable on the generated type itself, so a
streaming operation's signature reads like any other:
// every operation here streams, so only the streaming half is needed
const client = new WatcherClient(streamTransport)
// @streaming union out
const { events } = await client.watch({ id })
for await (const event of events) { /* typed as WatchEvent */ }
// @streaming blob in
await client.upload({ id, body: chunks /* AsyncIterable<Uint8Array> */ })The promise resolves as soon as the response status is known; elements are pulled lazily,
and ndjson elements are validated against the union's schema one at a time (a bad element
throws StreamDecodeError at that element rather than truncating the stream). Because a
streamed response commits its status before the first element, a mid-stream failure can't
be an HTTP status — model it as a member of the streamed union (the protocol expects a
terminal member such as completed / failed).
Framing itself is the transport's job: @polyvariant/smithy-ts-runtime does it for you, and
a hand-rolled transport implements StreamTransport.requestStream, which
receives requestStreamEncoding / responseStreamEncoding telling it which framing to
apply, so it never has to guess from a content type. The generated code adds the per-element
schema on top. A client takes exactly the halves its operations use: a service that mixes
streaming and unary operations takes both — new XxxClient(transport, streamTransport) — one
whose operations all stream takes new XxxClient(streamTransport), and one that streams
nothing is unchanged at new XxxClient(transport). A model with no streaming anywhere emits
no streaming code at all.
Mocks mirror the client, so a story can implement a streaming operation as an async generator:
mockService(WatcherMock, {
watch: async (input) => ({
events: (async function* () {
yield { item: { name: 'one' } }
yield { completed: {} }
})(),
}),
})Implementations of both halves ship in
@polyvariant/smithy-ts-runtime — see Transports.
The codegen emits the Transport / StreamTransport interfaces; the
implementation lives in @polyvariant/smithy-ts-runtime, published
separately so a generated file stays dependency-free.
pnpm add @polyvariant/smithy-ts-runtimeimport { chain, fetchTransport, withHeaders } from '@polyvariant/smithy-ts-runtime'
import { DirectoryClient, FeedClient } from './generated.js'
const transport = chain(
fetchTransport({ baseUrl: '/api' }),
withHeaders(() => ({ authorization: `Bearer ${token()}` })),
)
const directory = new DirectoryClient(transport)
const feed = new FeedClient(transport, transport) // streaming ops take bothIt covers the whole contract — unary requests, ndjson and binary framing in both
directions, 401 handling, and a middleware seam (chain / around / tap /
interceptorStack) for tracing, auth headers and error reporting. The framing
primitives are exported on their own for transports it doesn't ship.
The library imports nothing from generated code: it declares structural copies of
the transport types, which TypeScript matches by shape. typecheck/src/runtimeUsage.ts
compiles the two against each other, so the pairing can't drift silently.
See runtime/README.md for the full API.
Both of alloy's open-type traits are honored, so a model can evolve without breaking clients compiled against an older copy of it.
@openEnum widens the schema to accept any string. The type is written out rather than inferred
from it, so the known values survive as editor completions:
export const CategorySchema = z.union([z.enum(['book', 'film']), z.string()])
export type Category = "book" | "film" | (string & {})A union member tagged @jsonUnknown makes the union open. On the wire, any discriminator key the
model does not know activates that member, carrying the whole { <unknownKey>: <payload> }
object — so it is not a variant of its own, but a catch-all arm, emitted last (z.union tries its
arms in order, and a permissive record placed earlier would swallow every known variant):
export const FigureSchema = z.union([
z.object({ circle: CircleSchema }),
z.object({ square: SquareSchema }),
z.record(z.string(), z.unknown()),
])Branch over the known keys with in, and treat anything left as the unknown case.
@discriminated changes the encoding: instead of a single-key envelope, the variant is flattened
into the object and labelled with a discriminator property. A closed one becomes a
z.discriminatedUnion, which dispatches on that property in one step and reports errors against the
selected arm rather than against every arm:
export const RegionSchema = z.discriminatedUnion('kind', [
CircleSchema.extend({ 'kind': z.literal('circle') }),
SquareSchema.extend({ 'kind': z.literal('square') }),
])Adding @jsonUnknown makes it open, and the schema falls back to a plain z.union.
z.discriminatedUnion builds its dispatch map from the arms' literal discriminator values, and
throws when constructed with an arm whose discriminator is a plain z.string() — so it cannot
express the catch-all. As in the tagged case the catch-all comes last, since z.union dispatches by
trial and it would otherwise match every known variant:
export const ZoneSchema = z.union([
CircleSchema.extend({ 'kind': z.literal('circle') }),
SquareSchema.extend({ 'kind': z.literal('square') }),
z.object({ 'kind': z.string() }).catchall(z.unknown()),
])One consequence is worth knowing: a known discriminator carrying a payload that does not validate lands in the catch-all rather than failing. That follows from what an open union asks for — a client built against an older model cannot tell a malformed variant from a newer one it does not know — but it does mean an open discriminated union validates its known variants less strictly than a closed one. Keep the union closed where you want the stricter errors.
Members are flattened into the encoded object, so they have to target structures; a member pointing at a string or a list fails codegen with an error naming it.
JavaScript numbers are IEEE-754 doubles, so an integer outside ±(2^53 - 1) cannot be represented
exactly. JSON.parse rounds such a value on the way in, before any schema can see it, and the
original is unrecoverable — a long or bigInteger whose values reach that far therefore has no
lossless number form on the client.
@lossless, from the smithy-ts-codegen-traits artifact, marks a member whose exact value must
survive:
$version: "2"
namespace example
use org.polyvariant.smithy.ts#lossless
structure Measurement {
sequence: Integer
seed: Long
}
apply Measurement$seed @lossless
export const MeasurementSchema = z.object({
sequence: z.number().int().optional(),
seed: z.union([z.number(), z.string()]).optional(),
})The type is number | string because the representation is decided per value at runtime: anything
that fits exactly arrives as a number, so ordinary values stay ordinary, and only a value that
would lose precision surfaces as its exact decimal string. On the way out the generated client
converts the member to a bigint, which the serializer writes as a bare numeric literal — so
number, string and bigint are all accepted, and all reach the wire unquoted.
This requires a lossless transport. @polyvariant/smithy-ts-runtime provides one: if you use
its fetchTransport, @lossless already works and there is nothing to wire up. The next section is
only for a transport you write yourself.
A transport built on plain JSON.parse / JSON.stringify cannot honor the trait: JSON.parse will
have rounded the value before the schema runs, and JSON.stringify throws outright on the bigint
the generated client hands it. Nothing in the generated code can detect which you used, so getting
this wrong fails silently on the read path — the schema validates, the field is a number, and it
is the wrong number.
The runtime exports the two replacements, so this is a substitution rather than a rewrite:
import { parseLossless, stringifyLossless } from '@polyvariant/smithy-ts-runtime'They are drop-in. parseLossless returns a number for every value that round-trips exactly, so
ordinary fields are untouched and only a value that would lose precision comes back as its exact
decimal string — which is why @lossless members admit both. stringifyLossless writes a bigint
as a bare numeric literal.
Substitute at every JSON boundary the transport has. There are more than the obvious two:
| Boundary | Use |
|---|---|
| Reading a response body | parseLossless(await res.text()) |
| Writing a request body | stringifyLossless(req.body) |
| Reading an ndjson response line | parseLossless(line), per line |
| Writing an ndjson request element | stringifyLossless(element) + '\n', per element |
Do not use res.json(). It is the natural way to read a body and it cannot be fixed from the
outside: the rounding happens inside it, before you ever hold the value. Read text() and parse it
yourself.
The last two rows apply only if the transport implements StreamTransport. Note that
StreamTransportResponse.stream is specified as already-parsed ndjson elements — so parsing them
losslessly is the transport's job, not something the generated client can do afterwards.
Reading a body has two cases that are not about precision but are easy to get wrong, and the generated client depends on both:
const readBody = async (res: Response): Promise<unknown> => {
const text = await res.text()
if (text.length === 0) return undefined // 204, or an operation with no output members
try {
return parseLossless(text)
} catch {
return text // a proxy's HTML error page: let the status reach the caller
}
}An empty body is not valid JSON, and a non-2xx body is not necessarily JSON at all; throwing on either turns a useful status code into a parse error.
Nothing else needs special handling — in particular, a @lossless member bound to a path, query or
header parameter is passed through as-is by the generated client, since those are strings on the wire
and were never lossy.
For a complete reference, runtime/src/fetch.ts and runtime/src/ndjson.ts in this repository are
these substitutions over an otherwise ordinary fetch transport.
The trait is member-scoped, not shape-scoped: whether a field can exceed the safe range is a
property of that field, and the same numeric shape is usually reused for values that stay well
inside it. Applying it to Measurement$seed above leaves every other Long in the model a
number. It applies in HTTP bindings too — a @lossless member bound to a label, query parameter
or header is passed through as-is rather than coerced with Number(...), since those are strings on
the wire and were never lossy.
It is restricted to integral shapes (byte through long, and bigInteger). The exact value
travels as a bigint, which has no fractional form, so float, double and bigDecimal would
need a different carrier and the selector rejects them rather than promising something it cannot
deliver.
Depend on the traits artifact to apply it:
libraryDependencies += "org.polyvariant" % "smithy-ts-codegen-traits" % "<version>"It is a plain smithy model under META-INF/smithy, kept out of smithy-ts-codegen so a model
can depend on the trait definitions without pulling the generator and its dependencies onto the
model's classpath.
Note this changes only the TypeScript representation — the wire format is still a JSON number, so a client sending such a member is responsible for serializing it back as an unquoted numeric literal.
- Only
alloy#simpleRestJsonservices get clients; every operation needs an@httptrait. - Shapes in
smithy.api,smithy4s.*, andalloy.*namespaces, mixins, and trait definitions are not emitted as data types. - Recursive shapes are not supported — the generator topologically sorts shapes into one file and fails on cycles.
- Timestamps become
z.coerce.date(); blobs becomez.string()(a@streamingblob instead becomesAsyncIterable<Uint8Array>, with no zod schema — there is nothing to validate). - A
@streamingmember is left out of its structure's zod schema (validating it would mean consuming the stream); the generated type still carries it, as anAsyncIterable.
sbt test # unit tests
sbt sbtPlugin/scripted # the sbt plugin, end to end
sbt tsCodegenSample # regenerate typecheck/src/generated.ts
nix flake check # type-check + test the TypeScript side
pnpm check # the same, without nix (needs `pnpm install` first)
The TypeScript lives in a pnpm workspace of two packages:
runtime/— the published transport library.pnpm --filter @polyvariant/smithy-ts-runtime run checkbuilds it, type-checks it and runs itsnode:testsuite (framing round-trips, the transport against afetchdouble, middleware ordering).typecheck/— a model (model.smithy) exercising every construct the codegen emits, its committed output (src/generated.ts), a consumer-sidesrc/usage.tsthat uses the clients, streams and mocks the way a caller would, andsrc/runtimeUsage.ts, which drives those same clients with the library's transport. That last file is what pins the library's structural transport types to the ones the codegen emits — change one without the other and it stops compiling.
nix flake check runs both under strict + erasableSyntaxOnly.
This matters because the Scala tests assert on substrings of the emitted file, which cannot
catch a type error — a generator declared as AsyncIterable, an intersection with an empty
z.object, a Date cast to a query value. After changing the generator, run
sbt tsCodegenSample and commit the result; CI fails if it drifts.
Changing anything under runtime/ or typecheck/ that moves the lockfile means updating
pnpmDeps.hash in nix/typecheck.nix — build once, and nix prints the hash it wanted.
Note that nix build only sees git-tracked files, so git add new files before running it.
A nix develop shell provides node, pnpm, sbt and a JDK.
A v* tag ships both halves at the same version: sbt-typelevel publishes the JVM artifacts
from the generated ci.yml, and .github/workflows/npm-publish.yml publishes
@polyvariant/smithy-ts-runtime to npm. The tag is the only source of version truth —
runtime/package.json keeps a placeholder 0.0.0 that the workflow overwrites, so there is no
version to bump by hand.
ci.yml is generated (sbt githubWorkflowGenerate) and CI fails if it drifts; the npm
workflow is hand-written for that reason. Publishing needs an NPM_TOKEN secret with publish
rights on the @polyvariant scope.
smithy-build, smithy-codegen-core, smithy-model, alloy-core, smithy4s-protocol,
smithy4s-ndjson-protocol (the trait definition only — nothing Scala-specific from
smithy4s-ndjson is needed, since the codegen keys off @streaming members), smithy-ts-codegen-traits (this project's own codegen-controlling traits), and
smithy-ts-codegen-api (the extension interface — see Extensions; it depends on
smithy-model alone, so an implementor does not take the generator).
Apache 2.0.