typespec-asyncapi
v0.2.0
Published
TypeSpec emitter for AsyncAPI 3.0 specifications
Maintainers
Readme
typespec-asyncapi
A TypeSpec emitter for AsyncAPI 3.0 specifications.
Status: MVP working. The emitter maps
@service/@servernamespaces,@channel/@publish/@subscribeoperations, and message models (including nested/array/enum/optional properties,@header,@correlationId, and@messagename overrides) to a full AsyncAPI 3.0 document. Request/reply (@replyChannel) and Kafka bindings (theAsyncAPI.Kafkadecorators:@Kafka.key,@Kafka.groupId,@Kafka.clientId,@Kafka.topicConfig,@Kafka.schemaRegistry) have since landed — seeexamples/kafka-ordersfor a worked, parser-validated example exercising all of it. Pre-1.0: the decorator vocabulary may still change based on community feedback.
Why
TypeSpec has first-class emitters for OpenAPI, JSON Schema, and Protobuf — but not AsyncAPI. The request has been open since 2023 (microsoft/typespec#2463) with no roadmap commitment. This project fills that gap: author your event-driven API in TypeSpec, emit a valid AsyncAPI 3.0 document, and feed it to the AsyncAPI toolchain (Studio, Generator, Modelina) for docs and code.
The full viability assessment behind this project — TypeSpec emitter API stability, the concept mapping table, prior art post-mortem, risks, and the phased plan — lives in docs/engagements/typespec-asyncapi-viability/.
Usage
Install
npm install typespec-asyncapiThen in your TypeSpec project's files: import "typespec-asyncapi"; and add typespec-asyncapi to your tspconfig.yaml emitters.
To work on the emitter itself from source:
git clone https://github.com/milehimikey/typespec-asyncapi.git
cd typespec-asyncapi
npm install
npm run build # required before compiling any TypeSpec against the libraryA TypeSpec example
import "typespec-asyncapi";
using AsyncAPI;
@service(#{ title: "Order Events API" })
@server("main", "broker.example.com:9092", "kafka", "Primary Kafka broker for order events")
namespace OrderEvents {
enum OrderStatus { Placed, Confirmed, Shipped, Delivered, Cancelled }
model OrderPlaced {
@header @correlationId orderId: string;
customerId: string;
placedAt: utcDateTime;
notes?: string;
}
model OrderStatusUpdated {
@header @correlationId orderId: string;
status: OrderStatus;
updatedAt: utcDateTime;
}
@channel("orders.lifecycle")
@publish
op publishOrderPlaced(payload: OrderPlaced): void;
@channel("orders.lifecycle")
@subscribe
op consumeOrderStatusUpdated(payload: OrderStatusUpdated): void;
}This is a trimmed version of examples/kafka-orders/main.tsp, which also covers nested models, arrays, a parametrized channel address, and a @message name override — see that directory's README for how to compile it.
The emitted document (abridged)
{
"asyncapi": "3.0.0",
"info": { "title": "Order Events API", "version": "0.0.0" },
"servers": {
"main": {
"host": "broker.example.com:9092",
"protocol": "kafka",
"description": "..."
}
},
"channels": {
"orders.lifecycle": {
"address": "orders.lifecycle",
"messages": {
"OrderPlaced": { "$ref": "#/components/messages/OrderPlaced" },
"OrderStatusUpdated": {
"$ref": "#/components/messages/OrderStatusUpdated"
}
}
}
},
"operations": {
"publishOrderPlaced": {
"action": "send",
"channel": { "$ref": "#/channels/orders.lifecycle" },
"messages": [{ "$ref": "#/channels/orders.lifecycle/messages/OrderPlaced" }]
},
"consumeOrderStatusUpdated": {
"action": "receive",
"channel": { "$ref": "#/channels/orders.lifecycle" },
"messages": [{ "$ref": "#/channels/orders.lifecycle/messages/OrderStatusUpdated" }]
}
},
"components": {
"schemas": {
"OrderStatus": {
"type": "string",
"enum": ["Placed", "Confirmed", "..."]
}
},
"messages": {
"OrderPlaced": {
"name": "OrderPlaced",
"payload": { "type": "object", "properties": { "...": "..." } },
"headers": {
"type": "object",
"properties": { "orderId": { "type": "string" } },
"required": ["orderId"]
},
"correlationId": { "location": "$message.header#/orderId" }
}
}
}
}The full, real output for examples/kafka-orders is checked into test/kafka-orders.golden.json and asserted byte-for-byte in test/e2e.test.ts.
Decorators
| Decorator | Target | Meaning |
| --------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| @channel(address) | Operation | Channel address, e.g. "orders.placed" or "orders.{orderId}". |
| @publish | Operation | This operation sends to its channel (AsyncAPI action: "send"). |
| @subscribe | Operation | This operation receives from its channel (AsyncAPI action: "receive"). |
| @server(name, host, protocol, description?) | Namespace | Declares a broker server. |
| @message(name?) | Model | Optional message-name override (default: the model name). |
| @header | ModelProperty | Property is a message header, not payload. |
| @correlationId | ModelProperty | Property is the message correlation id. |
| @replyChannel(address) | Operation | Required whenever the op has a non-void Model return type: turns the return model into a reply message and adds an AsyncAPI reply object. |
| @securityScheme(name, config) | Namespace | Registers a named security scheme (components.securitySchemes.<name>), validated per its config.type. |
| @serverSecurity(server, schemes) | Namespace | Attaches a security ref array (to registered @securitySchemes) to the named @server. |
| @security(scheme) | Operation | Attaches a security ref array (to a registered @securityScheme) to the operation. |
Kafka bindings
using AsyncAPI; also brings the nested AsyncAPI.Kafka namespace into scope as Kafka, so its decorators are written @Kafka.<name>:
| Decorator | Target | Meaning |
| ------------------------------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @Kafka.key | ModelProperty | The property's own schema becomes the message binding's key (the property stays in the payload too). |
| @Kafka.groupId(value) | Operation | Operation binding groupId: {type:"string", enum:[value]}. |
| @Kafka.clientId(value) | Operation | Operation binding clientId: {type:"string", enum:[value]}. |
| @Kafka.topicConfig(partitions, replicas) | Operation | Channel binding {partitions, replicas} on the op's channel. Conflicting values across ops sharing a channel: first wins + a kafka-config-conflict warning. |
| @Kafka.schemaRegistry(url, vendor?) | Namespace | Server binding on every declared @server whose protocol is "kafka" (never on non-Kafka servers). |
A binding object only appears when a Kafka decorator actually produced content for it — never an empty bindings: {}. Every emitted binding carries bindingVersion: "0.5.0".
@service(#{ title: "Order Events API" })
@server("main", "broker.example.com:9092", "kafka")
@Kafka.schemaRegistry("https://schema-registry.example.com", "confluent")
namespace OrderEvents {
model OrderPlaced {
@Kafka.key
customerId: string;
placedAt: utcDateTime;
}
@channel("orders.lifecycle")
@publish
@Kafka.topicConfig(6, 3)
op publishOrderPlaced(payload: OrderPlaced): void;
}See examples/kafka-orders/main.tsp for the full worked example (which also adds @Kafka.groupId to a @subscribe operation) and test/kafka-orders.golden.json for its emitted bindings.
Security
@securityScheme(name, config) registers a named AsyncAPI 3.0 Security Scheme Object (emitted to components.securitySchemes); @serverSecurity(server, schemes) and @security(scheme) attach security reference arrays to a @server or an operation, respectively. Both servers.<n>.security and operations.<id>.security are plain arrays of {"$ref": "#/components/securitySchemes/<name>"} — never the 2.x named-requirement-map shape, which fails the AsyncAPI 3.0 JSON Schema despite appearing in the spec's own example.
Each of the 13 concrete type values has its own required fields, validated by the emitter (invalid-security-scheme on violation — the scheme is dropped, never emitted): userPassword, X509, symmetricEncryption, asymmetricEncryption, plain, scramSha256, scramSha512, and gssapi need nothing beyond type; apiKey needs in: "user" | "password"; httpApiKey needs name + in: "header" | "query" | "cookie"; http needs scheme (with optional bearerFormat when scheme is "bearer"); oauth2 needs a flows object (implicit/password/clientCredentials/authorizationCode, each with its own required-field combination of authorizationUrl/tokenUrl/availableScopes); openIdConnect needs openIdConnectUrl. A @serverSecurity/@security reference to an unregistered (or invalid) scheme name reports unknown-security-scheme; a @serverSecurity naming an undeclared server reports unknown-server.
@service(#{ title: "Order Events API" })
@server("main", "broker.example.com:9092", "kafka-secure")
@securityScheme("saslScram", #{ type: "scramSha256", description: "SASL/SCRAM-SHA-256 auth" })
@serverSecurity("main", #["saslScram"])
namespace OrderEvents {
model OrderPlaced {
@header @correlationId orderId: string;
customerId: string;
}
@channel("orders.lifecycle")
@publish
op publishOrderPlaced(payload: OrderPlaced): void;
}examples/kafka-orders/main.tsp uses exactly this pattern (Kafka bindings and security are orthogonal: protocol: "kafka-secure" still gets the @Kafka.schemaRegistry server binding) — see test/kafka-orders.golden.json for the emitted security ref array and securitySchemes entry.
Versioned specs
Install @typespec/versioning (an optional peer dependency -- the emitter works fine without it) and @versioned your service namespace; the emitter then produces one AsyncAPI document per version instead of one document overall:
npm install @typespec/versioningimport "typespec-asyncapi";
import "@typespec/versioning";
using AsyncAPI;
using Versioning;
@versioned(Versions)
@service(#{ title: "Order Events API" })
namespace OrderEvents {
enum Versions {
v1,
v2,
}
model OrderPlaced {
orderId: string;
@added(Versions.v2)
priority?: string;
}
@channel("orders.lifecycle")
@publish
op publishOrderPlaced(payload: OrderPlaced): void;
}This compiles to asyncapi.v1.json and asyncapi.v2.json -- each a complete, independently valid AsyncAPI document scoped to that version's view of the service (so v1's OrderPlaced schema has no priority property, and any operation/model/field added or removed at a later version only shows up in the documents for the versions where it exists). Unversioned services are unaffected and still emit a single asyncapi.json.
The output-file emitter option controls the file name (relative to the emitter output dir) and supports a {version} interpolation token -- e.g. "output-file": "specs/{version}/asyncapi.json" in tspconfig.yaml emitter options. Left unset, the default is asyncapi.json when unversioned and asyncapi.{version}.json per version otherwise.
Validate & render
Every document this emitter produces is asserted against the official @asyncapi/parser with zero errors as part of the test suite — but you can check any output yourself, or feed it into the rest of the AsyncAPI toolchain:
npx @asyncapi/cli validate path/to/asyncapi.jsonOr drop the file into AsyncAPI Studio to render interactive docs, or run it through @asyncapi/generator/Modelina for codegen.
Generate code from the emitted spec
examples/codegen/ has two verified, runnable pipelines built on the doc above: Kotlin/TypeScript model classes via Modelina, and a Node.js service scaffold (routes + handlers) via @asyncapi/generator. It's a standalone package with its own quick-start, plus a "what doesn't work and why" section covering the 3.0→2.x downconversion dead-end and other version-gated dead paths.
Design principles
- AsyncAPI 3.0 first. No 2.x emission — and no downconversion path exists in the ecosystem (converter-js only upgrades), so tooling that caps at 2.x is documented as incompatible rather than papered over (see examples/codegen).
- Validated output, always. Every emitted document must pass the official
@asyncapi/parserwith zero errors — enforced as a test gate in CI. - Object-based emission. Documents are built as typed objects and serialized; never assembled by string concatenation.
- Stable TypeSpec APIs only. Built on
$onEmit+ the compiler's public API (the same surface as@typespec/openapi3), not the experimental emitter framework.
Development
npm install
npm run build
npm test