You shouldn’t be, because the AsyncAPI spec already says exactly what every message looks like and a machine can retype it better than you. I wrote gen-asyncapi so an AsyncAPI 3 document goes in and one file of Go or TypeScript types, strict decoding, validation and a client comes out, straight from go generate.
Below are the questions I’d ask if somebody pitched me yet another codegen tool, answered as bluntly as I can.
What’s actually wrong with writing the structs by hand?
They drift. The other team renames a field, the frame still decodes, and plain json.Unmarshal hands you a struct full of zero values without so much as a fart. I’ve lost whole afternoons finding out why the “room” was an empty string and nothing errored.
And it gets worse:
- Plain decoding accepts missing fields, extra fields and garbage values.
- You end up guessing which message a frame is by sniffing a
typefield in a giantswitchwritten at 2am. - You do it twice, once for the Go server and once for the browser client, and the two drift apart independently. Twice the bugs, zero extra fun.
OpenAPI got the codegen circus years ago. AsyncAPI got a spec, a pretty docs renderer, and a generator ecosystem that mostly wants a Node toolchain, a template, and prayer.
How do I wire it into my build without a Node toolchain?
It’s a Go binary. Install it:
go install github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latestOr as a Go tool in your module, so go tool gen-asyncapi runs whatever version your go.mod pins:
go get -tool github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latestDrop a config.yaml and a gen.go in the directory where the code should live:
# config.yaml
package: chat
output: chat.gen.go// gen.go
package chat
//go:generate go tool gen-asyncapi --config=config.yaml ../../api/asyncapi.yamlRun go generate ./... and chat.gen.go shows up. The spec is the last argument. The flags -config, -language, -package and -o override the config or replace it entirely:
gen-asyncapi -package=chat -o=chat.gen.go asyncapi.yamlHow much config am I signing up for?
This is all of it, which is about as much ceremony as I’m willing to tolerate:
language: go # go or typescript, defaults to go
package: chat # Go package name, required for Go
output: chat.gen.go # defaults to asyncapi.gen.go, or asyncapi.gen.ts
generate:
models: true # types, codecs, validation
client: true # decoding, Handler, Dispatch, Client (needs models)
output-options:
message-matchers:
Notice:
/messages/0/type: [notice, info]Will the generated code actually reject a bad frame, or just shrug?
It rejects it. Required fields are values, optional ones are pointers. A const fills itself in when you marshal, so you never type Type: "join" again like some kind of animal:
type JoinRequest struct {
Type string `json:"type"`
ID string `json:"id"`
Room string `json:"room"`
Nickname *string `json:"nickname,omitempty"`
}
data, _ := json.Marshal(chat.JoinRequest{ID: "1", Room: "lobby"})
// {"type":"join","id":"1","room":"lobby"}Decoding checks everything the schema says: required fields, patterns, lengths, numeric bounds, additionalProperties: false, unique items, the lot.
Do I have to grep error strings to know what failed?
No. The errors wrap the commerr sentinels from ctxerrors, so you branch on them with errors.Is like a civilized person:
err := json.Unmarshal([]byte(`{"type":"join","id":"1"}`), &req)
errors.Is(err, commerr.ErrRequiredFieldNotSet) // true, room is missing
err = json.Unmarshal([]byte(`{"type":"join","id":"1","room":"x","extra":1}`), &req)
errors.Is(err, commerr.ErrValidationFailed) // true, extra propertyEvery type also gets Validate(), so you can check a value you built yourself before you embarrass yourself by sending it.
What happens to oneOf, anyOf and enums?
An enum becomes a named type plus constants. A oneOf or anyOf becomes a union with FromX, AsX, Discriminator and Value. The variant gets picked by the discriminator, or by a property every variant pins with const, or, with neither, by the one variant that decodes. Exactly one, or it’s an error:
value, err := event.Value()
if err != nil {
return err
}
switch e := value.(type) {
case chat.MessagePosted:
fmt.Println(e.Author.ID, "said", e.Text)
case chat.MemberJoined:
fmt.Println(e.Member.ID, "joined")
case chat.Ping:
// keepalive
}allOf merges into one struct, constraints included.
What about the APIs that stuff JSON inside a string field?
Yes, people do this, and yes, it’s as gross as it sounds. contentMediaType plus contentSchema gets you MetaValue() and SetMetaValue(), which decode, validate and re-encode the thing.
How does it know which message a frame even is?
This is the part everyone fucks up by hand. DecodeReceivedMessage works in three passes:
- Messages whose matchers all hold. Matchers are the
constvalues on the payload’s top-level properties, plus anymessage-matchersyou configured. - The rest, decoded strictly. Exactly one has to decode.
- Messages with an opaque payload, like an Avro schema kept as raw JSON.
Nothing matches: asyncapirt.ErrUnknownMessage, with every candidate’s reason joined in. Two match: asyncapirt.ErrAmbiguousMessage. Unknown frames go to HandleUnknownMessage, whose default logs a warning and keeps reading instead of nuking your connection.
And when two messages have the exact same shape?
Point a matcher at the value that tells them apart, with a JSON pointer. A list means any of those values, and an empty list fails generation, because an empty list is a bug you wrote:
output-options:
message-matchers:
Notice:
/messages/0/type: [notice, info]
Alert:
/messages/0/type: alertDo I get a client, or just a pile of types?
A client. Every message of a send operation becomes a method that validates, encodes and writes. Messages from receive operations and replies land in a Handler. Embed UnimplementedHandler and write only the methods you give a shit about:
conn, err := gorillaws.Dial(ctx, chat.ServerProductionURL, gorillaws.WithBearerToken(token))
if err != nil {
return err
}
client := chat.NewClient(conn)
defer client.Close()
err = client.JoinRoom(ctx, chat.JoinRequest{ID: id, Room: "lobby"})
err = client.Run(ctx, handler{}) // reads until ctx ends or the connection dropsThe gorillaws transport also takes WithHeader, WithQueryParam, WithSubprotocols, WithBinaryFrames, WithReadLimit and WithDialer.
What if I don’t want gorilla, or any client at all?
Anything with WriteMessage, ReadMessage and Close works as a transport. Skip the client entirely and DecodeReceivedMessage(frame) plus Dispatch(ctx, h, msg) do the same two steps by hand.
Am I still gluing server URLs together with string concatenation?
Not anymore. Server URLs with variables and channel addresses with parameters become constants and functions, so nobody hand-concatenates "wss://" + region + ".example.com" ever again:
kitchensink.ServerProductionURL // "wss://eu.example.com:443/v2/ws"
kitchensink.ServerProductionURLWith("us", "8443", "1") // "wss://us.example.com:8443/v1/ws"
kitchensink.CommandsAddress("acme") // "tenants/acme/commands"What about the browser side?
Set language: typescript or pass -language=typescript and the same spec turns into one .ts file with zero dependencies. A TypeScript project needs no Go module, just Go installed:
go run github.com/psyb0t/gen-asyncapi/cmd/gen-asyncapi@latest -language=typescript -o=src/chat.gen.ts asyncapi.yamlOr generate both sides from one gen.go, which is the whole point. The server and the web client can’t drift when they come out of the same spec in the same command:
//go:generate go tool gen-asyncapi --config=config.yaml chat.yaml
//go:generate go tool gen-asyncapi --config=config.yaml -language=typescript -o=chat.gen.ts chat.yamlSchemas become interfaces and unions, consts become literal types so a discriminated oneOf narrows in a switch, and every type gets a validate<Type> function that throws a ValidationError naming the bad value and narrows the type when it passes. The client takes anything with send and close, so a plain browser WebSocket works as it is, and every handler method is optional.
Will it survive my paranoid tsconfig?
The file type-checks under strict plus exactOptionalPropertyTypes, noUncheckedIndexedAccess, noUnusedLocals, verbatimModuleSyntax and erasableSyntaxOnly. It sticks to ES2020, so it runs in browsers and straight from Node.js.
How much of AsyncAPI does it really handle?
Pretty much all of it. Servers, variables, channels, parameters, send and receive operations, replies, headers, correlation IDs, traits merged with JSON Merge Patch, local and relative-file $refs, recursive schemas. Payloads in AsyncAPI, JSON Schema and OpenAPI formats generate types, and the OpenAPI 3.0 nullable works too. Avro, RAML and Protobuf payloads arrive as json.RawMessage instead of the generator pretending it understood them.
Was it only ever tested on your own toy specs?
No. The repo carries the 19 examples from the official AsyncAPI spec repository, generated as they are in both languages, and CI builds and type-checks every one of them. There’s also a kitchensink spec that uses every AsyncAPI 3.0 object and validates against the official JSON Schema. If your spec breaks it, it’s probably doing something weirder than Slack, Kraken and Gemini combined.
What if I only want to read a spec, not generate code?
Then grab the sibling, go-asyncapi. Loading and resolving the spec lives there. gen-asyncapi sits on top: it walks the loaded document and writes code, and the generated Go leans on a small runtime package, asyncapirt, for matching, validation and transport.
Can my coding agent set it up so I don’t have to?
Yes. The repo ships a skill that teaches a coding agent how to set up the generator and use what it writes, in both languages. Claude Code and Codex install it from the psyb0t/agents marketplace, and it goes to ClawHub on every release:
claude plugin marketplace add psyb0t/agents
claude plugin install gen-asyncapi@psyb0tSo why was your spec ever optional?
That’s the one nobody asks. If the spec is only documentation, nothing breaks when it lies, so it lies. Make it the thing your code is generated from and it can’t rot without your build noticing. github.com/psyb0t/gen-asyncapi. MIT. Give it a star or whatever, I’m not your boss.