Type-safe HTTP APIs for Go with docs that never drift from your code. Declare a struct once — get validation, OpenAPI docs, and a TypeScript client for free.

Early development. tack is pre-1.0. The API may change between minor versions until v1.0. See the roadmap.

Writing an API in Go usually means doing the same work three times:

- Parse and validate the request by hand.

- Write the OpenAPI spec (or comments that generate it) separately — and watch it drift out of date.

- Write TypeScript types for the frontend — again by hand.

tack makes your Go types the single source of truth. You write a handler that takes a struct and returns a struct. tack does the rest.

package main

import (

"context"

"net/http"

"github.com/raven-clown/tack"

)

type GetPetInput struct {

ID int `path:"id" validate:"min=1" doc:"Pet ID"`

}

type Pet struct {

ID int `json:"id"`

Name string `json:"name" validate:"required,max=50"`

}

func main() {

app := tack.New(tack.Config{Title: "Pet API", Version: "1.0.0"})

tack.Get(app, "/pets/{id}", func(ctx context.Context, in GetPetInput) (Pet, error) {

if in.ID == 404 {

return Pet{}, tack.NewError(http.StatusNotFound, "pet not found")

}

return Pet{ID: in.ID, Name: "Milo"}, nil

})

http.ListenAndServe(":8080", app) // open http://localhost:8080/docs

}That's it. You now have:

- Automatic binding from path, query, header, cookie, and JSON body

- Validation with clear, field-level error messages

- Standard errors in RFC 9457 application/problem+jsonformat

- OpenAPI 3.1 at /openapi.json— always in sync with your code

- Interactive docs at /docs, including the auth schemes you declare

- Typed errors declared per route and listed in the docs by code

- TypeScript client generated from the same types, where the errors a route declares form a typed union

- Zod schemas with the same validation rules, for forms that check before they send

- tack devto rebuild the app and the client on every save, and- tack mockto serve example responses before the API exists

- File uploads from multipart forms and server-sent events from an iterator, both typed, documented, and in the client

- Graceful shutdown with app.Run(ctx, addr), andtack newfor a project that has all of this on day one

go get github.com/raven-clown/tack

go install github.com/raven-clown/tack/cmd/tack@latest # the command: dev, mock, gen, check, newRequires one of the two most recent Go releases. Prebuilt binaries of the tack command for Linux, macOS, and Windows are attached to each release. Start a project with tack new myapi.

/docs and /openapi.json are generated from the same structs that bind and validate requests, so they can't fall out of date. Describe routes with options and fields with tags:

tack.Get(app, "/pets", listPets, tack.Summary("List pets"), tack.Tags("pets"))

type ListPetsInput struct {

Limit int `query:"limit" default:"20" validate:"min=1,max=100" doc:"Maximum number of pets"`

}Change the paths with Config.SpecPath and Config.DocsPath, or set either to "-" to turn it off.

Send a bad request and get every problem at once — not just the first one:

{

"type": "about:blank",

"title": "Unprocessable Entity",

"status": 422,

"detail": "validation failed",

"errors": [

{ "location": "body.name", "message": "is required" },

{ "location": "path.id", "message": "must be at least 1" }

]

}Define an error once, return it from handlers, and declare it on the routes that can produce it. It shows up in /docs under its status with a stable code, which the TypeScript client turns into a typed union:

var ErrPetNotFound = tack.DefineError(http.StatusNotFound, "pet_not_found", "pet not found")

tack.Get(app, "/pets/{id}", func(ctx context.Context, in GetPetInput) (Pet, error) {

return Pet{}, ErrPetNotFound.WithDetail(fmt.Sprintf("pet %d not found", in.ID))

}, tack.Errors(ErrPetNotFound)){ "type": "about:blank", "title": "Not Found", "status": 404, "code": "pet_not_found", "detail": "pet 7 not found" }Every route also documents validation_failed (422) and internal (500). Replace the whole error format with Config.ErrorHandler.

tsgen turns the OpenAPI document into one dependency-free TypeScript file built on fetch. Each route is a method whose result is either the typed data or one of the problems the route declares, so the frontend switches on code and the compiler knows which codes exist:

import { Client } from "./api";

const api = new Client({ baseUrl: "https://api.example.com", credentials: { bearer: () => token } });

const res = await api.getPetsById({ id: 7 });

if (!res.ok) {

switch (res.error.code) {

case "pet_not_found": // only the codes this route declares compile here

return showNotFound(res.error.detail);

case "validation_failed":

return showErrors(res.error.errors);

}

}

res.data.name; // PetGenerate the client where the routes are, and let the same test fail CI when the committed file is older than the code:

func TestClient(t *testing.T) {

if err := tsgen.Check(newApp().OpenAPI(), "client/api.ts"); err != nil {

t.Fatal(err) // regenerate with tsgen.Generate, or go test . -update

}

}The tack command does the same from a running server or an exported document:

go install github.com/raven-clown/tack/cmd/tack@latest

tack gen ts -spec http://localhost:8080/openapi.json -o client/api.ts

tack check -spec openapi.json client/api.ts # exit 1 when staleResponses the document does not describe, such as a proxy's 502, are thrown as ApiError; unwrap(res) throws the same class for code that prefers exceptions. See examples/petstore/client/api.ts for a generated file.

tack gen zod (or tsgen.GenerateZod) writes one Zod schema per type and per route input, carrying the validate rules the server enforces, so a form can reject bad input before the request and show the same errors the API would:

import { PostPetsInput } from "./schemas";

const result = PostPetsInput.safeParse({ body: form }); // name: max 50, kind: cat | dog | birdThe file imports zod and works with Zod 3.23 and later, Zod 4 included. tsgen.CheckZod and tack check -zod keep it fresh the same way as the client. See examples/petstore/client/schemas.ts.

tack dev builds the app, runs it, and regenerates the client from the running app's document. Save any Go file and it does it all again; a build error keeps the current app running and waits for the next save:

tack dev -o client/api.ts -zod client/schemas.ts # builds and runs the current directory

tack dev -spec http://localhost:3000/openapi.json -- go run ./cmd/serverThe stale window between a Go change and a correct client is the length of one build, and the frontend's type checker sees the change in the editor.

tack mock serves every operation in a document with example responses built from the schemas, including the declared errors on request, so a frontend can be built against the contract first:

tack mock -spec openapi.json # http://127.0.0.1:8081

curl -H "Prefer: code=404" localhost:8081/pets/7 # {"code":"pet_not_found", ...}Groups share a path prefix, middleware, and options. Middleware is plain func(http.Handler) http.Handler at the app, group, and route level, so anything written for net/http or chi works as is:

app.Use(middleware.RequestID, middleware.Logger)

v1 := app.Group("/v1")

admin := v1.Group("/admin", requireAdmin).With(tack.Security("bearer"))

tack.Get(admin, "/users", listUsers, tack.Middleware(audit))Middleware stores values in the request context; handlers read them back typed with tack.ContextValue[User](ctx, userKey{}).

Declare the schemes in Config.Security and bind them with tack.Security. /docs gets a token field and sends the credentials; your middleware checks them and answers with app.WriteError(w, r, tack.ErrUnauthorized). See examples/auth for a JWT login.

app := tack.New(tack.Config{Security: map[string]openapi.SecurityScheme{"bearer": tack.Bearer("JWT")}})An output struct with a Body field can set response headers, which are documented in the spec, and override the status set with tack.Status:

type CreatePetOutput struct {

Status int

Location string `header:"Location"`

Body Pet

}A form tag reads a multipart or URL-encoded form, and a *multipart.FileHeader field is an uploaded file. Validation, the docs, and the client know about all of it: the client sends FormData, and a missing required file is a validation_failed at form.photo.

type photoInput struct {

ID int `path:"id"`

Photo *multipart.FileHeader `form:"photo" validate:"required"`

Caption string `form:"caption" validate:"max=100"`

}await api.postPetsByIdPhoto({ id: 7, body: { photo: file, caption: "asleep" } });Bodies are limited by Config.MaxBodyBytes (1 MiB unless set), so raise it for uploads.

Return a tack.Stream[T], an iterator, and every value becomes one event with its JSON on the data line. The context ends when the client disconnects or the server shuts down. The docs show text/event-stream with the schema of T, and the client reads it as an async iterable:

tack.Get(app, "/pets/events", func(ctx context.Context, _ struct{}) (tack.Stream[tack.Event[Change]], error) {

return func(yield func(tack.Event[Change]) bool) {

for {

select {

case <-ctx.Done():

return

case c := <-changes:

if !yield(tack.Event[Change]{Name: c.Action, Data: c}) {

return

}

}

}

}, nil

})const res = await api.getPetsEvents();

if (res.ok) for await (const e of res.data) console.log(e.event, e.data.pet.name);tack.Event adds the event name and ID; a plain tack.Stream[T] sends data lines only.

app.Run(ctx, addr) serves until the context is done, then stops accepting connections, ends open streams, and waits up to ten seconds for handlers to finish. app.Serve(ctx, srv) does the same with an http.Server you configured.

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)

defer stop()

log.Fatal(app.Run(ctx, ":8080"))A *tack.App is an http.Handler. Use it with http.Server, mount it inside another router, and use any standard middleware (func(http.Handler) http.Handler). No lock-in.

All reflection happens once, when routes are registered. At request time tack only runs a precomputed binding plan. See BENCHMARKS.md.

Declare a route with {id} in the path but no matching path:"id" field? tack panics at registration with a message telling you exactly what to fix — not at 3 a.m. in production.

The same field rename, six approaches. Source: how-tack-works.html.

Every piece of tack exists somewhere already. What tack adds is putting them in one place: code-first on net/http, validation, OpenAPI, a TypeScript client with typed errors, Zod schemas from the same rules, and a dev loop, with no special toolchain. These projects showed the way.

If you know of a project that already covers all of this, please open an issue.

As of v0.5 the parts that set tack apart are all in place; the public launch follows.

After v1.0, add-ons ship as separate modules:

tack will not ship its own router, database driver, ORM, Redis client, sign-up system, HTML templating, or infrastructure tooling. Existing libraries do those well.

Every push and pull request runs:

Dependabot keeps dependencies and actions current, and GitHub secret scanning with push protection is on. Findings land in the repository's Security tab. To report a vulnerability, follow SECURITY.md.

Run the same checks locally:

go test -race -coverprofile=coverage.out ./... && go tool cover -func=coverage.out | tail -1go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run && go run golang.org/x/vuln/cmd/govulncheck@latest ./...- Architecture — how tack works inside

- Examples — runnable example projects

- API reference

Contributions are welcome! Please read CONTRIBUTING.md and our Code of Conduct first. Looking for a place to start? Try an issue labeled good first issue.

Found a security issue? Please follow SECURITY.md instead of opening a public issue.