DEV Community

Frank
Frank

Posted on

End-to-end type safety between Go and TypeScript

Go gopher holding TypeScript logo

You change a field on a Go struct. Three days later a TypeScript component reads that field, still expecting the old shape, and the bug ships. The compiler on each side was happy. The two compilers just never talked to each other.

This is the gap every Go plus TypeScript codebase has to close somehow. There are five common ways to close it. This post builds the same small app five times, one per approach, and compares what each one actually buys you. Every code sample comes from a companion repo where all five run.

What "type-safe" has to mean

"Generate types from the backend" is the easy 80 percent. The interesting part is the 20 percent where the two type systems disagree about what a value even is. So the demo app, a task tracker, is built around the cases that break pipelines:

  • A closed enum, TaskStatus, whose wire zero value is not a valid state.
  • A nullable field, assignee, sitting next to an optional field, dueDate. One can be present and null, the other can be absent. Most pipelines collapse the two.
  • An identifier that is a string on the wire but must not be mixed up with any other string in the program.
  • Cursor pagination, where "no more pages" is a different value from "empty page".
  • A typed not-found error, so the client branches on a known error and not on an HTTP status code it pattern-matched by hand.

Any approach can serialize a struct. The question is what happens to these five cases when they cross the language boundary.

The reframe: transport types are not domain types

Here is the idea the rest of the post hangs on, and the one most comparisons skip.

Code generators give you transport types. A generated ID is a plain string. A generated enum carries a zero value that means nothing. A generated optional field cannot tell you whether the value was null or simply absent. That is correct for a transport: it describes bytes on the wire. It is not what you want to write business logic against.

What you want is domain types. An ID that the compiler refuses to confuse with a different ID. An enum with no invalid member. A value that has already been parsed and can be trusted without re-checking.

So in the repo the domain lives in exactly one place, with no transport in it at all, and each approach maps its generated types onto that domain through a thin adapter. The cost and shape of that adapter, per approach, is the actual comparison.

The shared Go domain

Go has nominal typing already, so branded identifiers are just distinct types with a single sanctioned constructor.

type (
 UserID    string
 ProjectID string
 TaskID    string
)

// ParseUserID is the only way in. A raw string will not compile where UserID is
// expected, and a malformed one never becomes a UserID at all.
func ParseUserID(raw string) (UserID, error) {
 s, err := parseID("user id", raw)
 return UserID(s), err
}
Enter fullscreen mode Exit fullscreen mode

The enum makes its unknown value explicit instead of leaning on the Go zero value.

type TaskStatus int

const (
 StatusUnknown TaskStatus = iota // zero value is not a real state, on purpose
 StatusTodo
 StatusInProgress
 StatusDone
)

func ParseTaskStatus(raw string) (TaskStatus, error) {
 if s, ok := wireToStatus[raw]; ok {
  return s, nil
 }
 return StatusUnknown, fmt.Errorf("task status: unknown %q", raw)
}
Enter fullscreen mode Exit fullscreen mode

That StatusUnknown at position zero is the whole game. If todo were the zero value, any field that was never set would read as todo, and nothing would tell you. The nullable versus optional distinction is carried by pointers, where a nil *UserID is "no assignee" and a nil *time.Time is "no due date set".

The shared TypeScript domain

On the TypeScript side the same domain is expressed with zod, and the schema is the parser. This is parse don't validate: you parse untrusted input into a precise type once, at the edge, and downstream code receives the narrow type and never re-checks it.

const idPattern = /^[a-z]{2,5}_[0-9A-HJKMNP-TV-Z]{26}$/;

export const UserId = z.string().regex(idPattern).brand<"UserId">();
export type UserId = z.infer<typeof UserId>;

export const Task = z.object({
  id: TaskId,
  projectId: ProjectId,
  title: z.string().min(1),
  status: TaskStatus, // z.enum(["todo", "in_progress", "done"])
  assignee: UserId.nullable(), // present, value may be null
  dueDate: z.iso.datetime().optional(), // key may be absent entirely
});
export type Task = z.infer<typeof Task>;
Enter fullscreen mode Exit fullscreen mode

.brand<"UserId">() is the TypeScript equivalent of Go's distinct type. At runtime it is still a string. At compile time, a plain string no longer fits where a UserId is wanted, so you cannot pass a project id to a function expecting a user id.
With exactOptionalPropertyTypes on, nullable() and optional() are genuinely different types, and the schema keeps them apart instead of melting both into undefined.

The page envelope carries the fifth case, cursor pagination, and it is the same shape everywhere:

export const TaskPage = z.object({
  items: z.array(Task),
  nextCursor: z.string().nullable(), // null means there is no next page
});
Enter fullscreen mode Exit fullscreen mode

The typing point is the null. A nextCursor of null is "there are no more pages", which is a different state from an empty items array, and different again from the cursor being absent on the request. If nextCursor were typed as a plain string, the end-of-list case would be an empty string the client has to remember
to test for, which is exactly the kind of in-band signal that leaks. Each approach has to preserve that nullable on the way across: Connect uses an optional proto field the adapter maps to null, the OpenAPI document marks it nullable, GraphQL makes it a nullable String. The store returns a *string cursor for the same
reason, so "no more pages" is a nil pointer rather than a sentinel value.

Branding and runtime validation are two different decisions

It is worth separating two things the rest of this post keeps distinct, because conflating them leads to bad conclusions later.

A brand is a compile-time idea. A runtime check is a runtime cost. There is one way to attach a brand with zero runtime cost, and that is a cast:

const id = pt.id as UserId; // do not do this
Enter fullscreen mode Exit fullscreen mode

This post never does that, and you should not either. A cast is an unchecked assertion. It tells the compiler "trust me" about a value that came off a wire, which is exactly the drift the brand was supposed to prevent. The brand now lies: the type says UserId, the value was never checked to be one.

So the only honest way to obtain a branded value is to parse it through the schema, which validates and brands in the same step:

const id = parseUserId(pt.id); // validates the format, returns a UserId, or throws
Enter fullscreen mode Exit fullscreen mode

That means runtime validation is not redundant overhead bolted onto branding. It is the price of earning the brand without cheating.

Do you own both ends?

Before any tool, there is one architectural decision that changes the whole answer:
do you trust the producer? If the same team ships the Go server and the TS client, deploys them together, and the contract is generated from one source, then the wire is effectively trusted and the generated types already guarantee the structure.
Re-parsing every response with a second schema is mostly belt and suspenders, and the interesting move is to keep the validation in the source of truth (proto with protovalidate, a struct tag, a schema constraint) rather than hand maintaining a second one. If the producer is outside your control, a public API, a third party,
an older deployed version, then the wire is untrusted, the generated type is just a claim, and parsing at the edge earns its keep. The same five approaches below sit differently depending on which world you are in, so decide this first. The brand is not the question; the trust is.

Keep these two files in mind. Every approach below is judged on how much work it takes to get from its generated output back to these types.

Approach 1: ConnectRPC

A .proto file is the source of truth. buf generate produces a Go server stub and TypeScript message types from it, so the contract cannot drift between the two languages.

The proto bakes in two of our hard cases on purpose:

enum TaskStatus {
  TASK_STATUS_UNSPECIFIED = 0; // the proto analogue of StatusUnknown
  TASK_STATUS_TODO = 1;
  TASK_STATUS_IN_PROGRESS = 2;
  TASK_STATUS_DONE = 3;
}

message Task {
  string id = 1;
  string project_id = 2;
  string title = 3;
  TaskStatus status = 4;
  optional string assignee = 5;                 // null in the domain
  optional google.protobuf.Timestamp due_date = 6; // absent in the domain
}
Enter fullscreen mode Exit fullscreen mode

Notice that proto3 has one presence mechanism, so assignee and due_date are both optional. The wire format cannot tell null from absent. That limitation becomes the adapter's job on both ends.

On the server, the generated handler interface is satisfied by a struct, and a compile-time assertion guarantees it. Rename an rpc and the build breaks before anything ships:

var _ tasktrackerv1connect.TaskTrackerServiceHandler = (*service)(nil)
Enter fullscreen mode Exit fullscreen mode

Every request field is parsed into the domain before the store sees it, and the domain not-found error is mapped to a Connect code:

func (s *service) GetTask(ctx context.Context, req *connect.Request[tasktrackerv1.GetTaskRequest]) (*connect.Response[tasktrackerv1.GetTaskResponse], error) {
 id, err := domain.ParseTaskID(req.Msg.GetId())
 if err != nil {
  return nil, invalidArg(err) // CodeInvalidArgument
 }
 t, err := s.store.GetTask(ctx, id)
 if err != nil {
  return nil, toConnectError(err) // ErrNotFound -> CodeNotFound
 }
 return connect.NewResponse(&tasktrackerv1.GetTaskResponse{Task: toProtoTask(t)}), nil
}
Enter fullscreen mode Exit fullscreen mode

The generated TypeScript types show the limitation directly. The proto compiler emits assignee?: string and dueDate?: Timestamp, both optional, because that is all proto presence can say. The client adapter reconstructs the domain distinction and validates the result through the branded schema:

export function toDomainTask(pt: ProtoTask): Task {
  const base = {
    id: pt.id,
    projectId: pt.projectId,
    title: pt.title,
    status: wireStatus(pt.status), // throws on UNSPECIFIED
    assignee: pt.assignee ?? null, // absent becomes null
  };
  const payload =
    pt.dueDate !== undefined
      ? { ...base, dueDate: timestampDate(pt.dueDate).toISOString() }
      : base; // absent stays absent
  return DomainTask.parse(payload); // untrusted wire -> branded domain
}
Enter fullscreen mode Exit fullscreen mode

And the Connect error code is translated back into the same domain error the server raised, so the caller writes catch (e) { if (isNotFound(e)) ... } and never touches a status code:

function notFoundOr(kind: string, id: string, err: unknown): never {
  if (err instanceof ConnectError && err.code === Code.NotFound) {
    throw new NotFoundError(kind, id);
  }
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

The DomainTask.parse call above is the hand-written zod option, and it makes sense here only if you treat the server as untrusted. When you own both ends and deploy them together, proto already guarantees the structure, so re-parsing the whole payload with a second schema you maintain by hand is mostly wasted work. But recall the earlier point: you still cannot brand with a cast. So what carries the brand if not a hand-written schema?

The schema-driven answer is protovalidate. You put the constraints in the proto, next to the field they describe, and generate validators for both languages from that single source:

string id = 1 [(buf.validate.field).string.pattern = "^[a-z]{2,5}_[0-9A-HJKMNP-TV-Z]{26}$"];
Enter fullscreen mode Exit fullscreen mode

protovalidate-go enforces it server side and protovalidate-es enforces it in the browser, both from the same rule. The constraint lives in the source of truth rather than in a zod file that can drift from the proto. This is the cleaner way to satisfy parse don't validate in the Connect world: validate from the schema, then brand the validated value, and keep the hand-written zod for the cases where the producer is genuinely outside your control.

What Connect gets right: one contract for two languages, generated service interfaces with compile-time enforcement, first-class typed errors through status codes, schema-driven runtime validation through protovalidate, and streaming when you need it. What it costs: a proto toolchain (buf), a build step, and an adapter layer to turn transport types into domain types, because the generated IDs are plain strings and the generated enum has that unspecified zero value. For greenfield RPC between a Go service and a TS client, this is the strongest default.

Approach 2: OpenAPI, spec first

Here an OpenAPI document is the source of truth, written by hand or assembled from components, and both sides generate from it: oapi-codegen for the Go server, openapi-typescript plus openapi-fetch for the TS client.

The defining trait is that the spec is a real artifact you own and review, not a byproduct of code. You can express nullable and required precisely, which maps better onto our null versus absent case than proto does:

Task:
  type: object
  required: [id, projectId, title, status, assignee]
  properties:
    assignee:
      type: string
      nullable: true # present, may be null
    dueDate:
      type: string
      format: date-time # not in `required`, so may be absent
Enter fullscreen mode Exit fullscreen mode

openapi-typescript turns that into assignee: string | null and dueDate?: string, so the distinction survives generation, which is one fewer thing the adapter has to rebuild than in the proto case. The enum still generates as a bare string union and the IDs still generate as string, so the branded domain layer is the same idea: parse the generated response through the zod schemas at the boundary.

One version gotcha worth knowing in 2026: oapi-codegen does not yet support the OpenAPI 3.1 nullable union (type: [string, "null"]), so the document above is pinned to 3.0.3 and uses nullable: true. openapi-typescript handles 3.1 fine, so the limitation is on the Go side, and the snippet above is exactly what the companion repo ships.

What it gets right: the spec is the contract, it is readable, and it doubles as documentation and as the thing your public API consumers generate their own clients from. What it costs: writing and maintaining the document, and trusting that the server actually conforms to it, which oapi-codegen helps with by generating a strict server interface from the same spec, so a handler that returns the wrong shape fails to compile. Best fit when the API is REST shaped or public.

Approach 3: OpenAPI, code first

Same destination, opposite direction. The Go code is the source of truth and the OpenAPI document is derived from it, then the TypeScript side generates from that document exactly as before. There are two generations of tooling here, and they are not close in quality, so it is worth treating them separately.

The old way is comment driven. A tool like swaggo reads annotations above the handler and assembles a spec from them:

// @Summary Get a task
// @Param   id  path     string true "Task ID"
// @Success 200 {object} TaskResponse
// @Failure 404 {object} ErrorResponse
// @Router  /tasks/{id} [get]
func (h *Handler) GetTask(c echo.Context) error { ... }
Enter fullscreen mode Exit fullscreen mode

This is the version that earns code first its bad reputation. The spec is only as precise as the comments, the type information survives a round trip through free text, and nullable versus optional comes down to annotation discipline rather than anything the compiler checks. The comments drift from the types they describe and nothing fails when they do. In fairness, a team that fails CI when the regenerated spec no longer matches the handlers can keep this honest, and some run it that way for years. But then it is the pipeline holding the contract, not the compiler, which is the same fragility the manual approach has.

The modern way, in 2026, does not use comments at all. Libraries like Huma and Fuego derive the OpenAPI document from real Go structs and generics, so the input and output types of a handler are the spec:

type idInput struct {
 ID string `path:"id"`
}

// Assignee is a pointer with no omitempty, so Huma marks it required and nullable.
// DueDate uses omitempty, so Huma marks it optional. The struct is the schema.
type taskBody struct {
 ID        string     `json:"id"`
 ProjectID string     `json:"projectId"`
 Title     string     `json:"title"`
 Status    string     `json:"status" enum:"todo,in_progress,done"`
 Assignee  *string    `json:"assignee"`
 DueDate   *time.Time `json:"dueDate,omitempty"`
}

type taskOutput struct {
 Body taskBody
}

huma.Register(api, huma.Operation{
 OperationID: "getTask",
 Method:      http.MethodGet,
 Path:        "/tasks/{id}",
}, s.getTask) // func(ctx, *idInput) (*taskOutput, error), parses the id into the domain
Enter fullscreen mode Exit fullscreen mode

The spec cannot lag the implementation, because there is no separate document and no comment layer: the spec is a projection of the Go type system. Request validation comes from struct tags and runs before the handler, and errors follow RFC 7807 by default (the handler returns huma.Error404NotFound for the domain not-found sentinel), which gives the TypeScript side a typed error shape to generate against. Nullable versus optional is now expressed in Go types and field tags rather than prose, so it is as precise as your structs are.

What it gets right, with Huma or Fuego: a single source of truth that is real Go code, a spec that is generated rather than written, runtime validation derived from the same types, and typed errors out of the box. What it costs: you are coupled to the framework's way of describing handlers, and you have less direct control over the emitted schema than when you author the document by hand. Pick the modern libraries over the comment based tools without hesitation. Code first stopped meaning magic comments a while ago.

Approach 4: GraphQL

A GraphQL schema is the source of truth. gqlgen generates Go resolvers from it, graphql-codegen generates TypeScript types and typed operations from it and from your queries.

GraphQL is the one approach with native support for branded types end to end, through custom scalars. You declare a scalar in the schema and map it to a branded type on each side, so the generated client already hands you a UserId rather than a string:

scalar UserId
scalar TaskId
scalar DateTime

type Task {
  id: TaskId!
  assignee: UserId # nullable by default
  dueDate: DateTime # nullable by default
  status: TaskStatus!
}
Enter fullscreen mode Exit fullscreen mode
// graphql-codegen config
scalars: {
  UserId: "@gotstypes/domain#UserId",
  TaskId: "@gotstypes/domain#TaskId",
  DateTime: "string",
}
Enter fullscreen mode Exit fullscreen mode

That is genuinely less adapter code than the RPC and REST approaches, because the nominal types come straight out of generation: getTask().id is typed as a TaskId. The honest caveat is that this branding is a compile-time assertion the codegen makes from the scalar config, not a runtime check, which is the same trust gap as a cast. So the companion repo still parses each response through the domain schema for runtime safety. The win is less boilerplate and branded types by default, not validation. It also rests on the scalars block staying in sync with the domain types: nothing stops that mapping from drifting, so the safety is only as good as the config is maintained.

The cost is the rest of GraphQL: nullability is per field and defaults to nullable, so you spend ! discipline everywhere; the runtime is heavier; and you take on resolver wiring, n plus one concerns, and a query language your frontend now has to learn. The null versus absent case bends too: GraphQL has only null in its output, no notion of absent, so the optional dueDate comes back as string | null and the client maps a null back to absent to line up with the domain. Worth it when the data is genuinely graph shaped and many clients want different slices of it.

Approach 5: manual types with a runtime parser

No code generation. The Go side writes its JSON DTOs by hand, and the TypeScript side reuses the shared zod schema as both the type and the validator, parsing every response at the boundary. This is the baseline the others are measured against.

const data = await fetch(`/tasks/${id}`).then((r) => r.json());
const task = Task.parse(data); // the shared domain schema: branded, validated, or it throws
Enter fullscreen mode Exit fullscreen mode

What it gets right: zero toolchain, zero build step, full control, and .brand() gives you branded types natively with no adapter at all, because the schema is already the domain. Runtime validation is built in rather than bolted on. What it costs: the contract is a promise, not a guarantee. Nothing connects the Go struct to the zod schema, so the two drift the moment someone forgets. It works for a small surface or a spike, and stops scaling the day the API grows faster than your discipline.

The TypeScript-only tools: tRPC and Pothos

Two names come up constantly in this space, and both assume a TypeScript backend, so neither fits a Go one. They are worth a direct answer.

People search for "tRPC with Go" constantly, and the answer is you cannot. tRPC achieves its type safety by sharing the actual TypeScript types between client and server through a TypeScript import. There is no schema and no code generation,
which is exactly why it is frictionless, and exactly why it is TypeScript on both ends only. A Go backend has no TypeScript types to import. If you reached for this post hoping to wire tRPC to Go, the closest thing in spirit is ConnectRPC: schema first, generated clients, typed errors, and a similar end-to-end feel.

Pothos is the same story from the GraphQL side. It is a code-first GraphQL schema builder with excellent type inference, but it builds the schema in TypeScript, so it fills the role gqlgen fills in this post, just for a Node backend instead of a Go one. If your backend were TypeScript, Pothos would be a strong way to define the
schema, and the client would still generate from it with graphql-codegen exactly as shown above. With a Go backend the schema comes from gqlgen, and Pothos does not enter the picture.

Scorecard

Code first below means the modern struct based libraries (Huma, Fuego), not the
comment based tools.

Connect OpenAPI spec-first OpenAPI code-first GraphQL Manual
Source of truth proto the document Go structs schema nothing shared
Contract enforced compile time generation Go type system generation by hand
Null vs absent adapter rebuilds it expressed in spec Go types and tags per field, subtle you decide
Branded types adapter layer adapter layer adapter layer native scalars native zod
Runtime validation protovalidate or zod spec then zod struct tags, then zod add zod at edge built in
Typed errors status codes convention RFC 7807 native error extensions by hand
Toolchain weight buf, codegen codegen one library two codegens, runtime none
Maintenance at scale proto evolves under buf breaking checks the document can grow into a burden on large or public APIs low, falls out of Go types schema plus codegen config plus resolvers discipline bound, drifts silently
Streaming yes no no subscriptions no

A note on payload size

People ask about performance, so here is the one number that is honest to give.
The companion repo serializes the same task into each wire format and measures the bytes, raw and after gzip. This is serialization only, not latency or throughput, and in a real service the database and network dominate, not a few hundred bytes.

single task (raw / gzip) 50 tasks (raw / gzip)
Protobuf (binary) 128 / 134 B 6550 / 193 B
Connect (proto JSON) 232 / 208 B 11710 / 286 B
OpenAPI / REST JSON 215 / 197 B 10829 / 281 B
GraphQL JSON 233 / 209 B 10848 / 296 B

Protobuf binary is about half the raw size, and the gap widens with list length.
After gzip it shrinks: for a single small object the formats land within tens of bytes of each other, and the binary can even be larger than its raw form because gzip has fixed overhead on tiny inputs. At list scale the binary stays around a third smaller gzipped, which matters if you ship large responses and is a rounding error if you do not. The three JSON formats are within a few percent of each other, because they are all just JSON with different envelopes. None of this is a reason to pick an approach. The source of truth is.

Verdict

There is no single winner, but the choice is not a coin flip either.

  • Reach for ConnectRPC for greenfield typed RPC between a Go service and a TS client. One contract, generated both sides, typed errors, streaming. The adapter to branded domain types is small and worth it.
  • Reach for OpenAPI spec first when the API is REST shaped or public, so external consumers can generate their own clients from a document you own.
  • Reach for GraphQL when the data is graph shaped and many clients want different slices. The custom scalar support for branded types is the best of the five, but you pay for it with the rest of GraphQL.
  • Reach for OpenAPI code first with Huma or Fuego when the Go service is the sole producer and you want the spec to fall out of real Go types rather than be maintained by hand. This is a genuinely strong default now, not the weak option it was in the comment driven era. Skip the comment based tools.
  • Reach for manual plus zod for a spike or a tiny surface, and plan to migrate before discipline becomes the only thing holding the contract together.

The thing none of them do for free is hand you domain types. They all hand you transport types. Branded ids, exhaustive enums, and the null versus absent distinction live in an adapter you write once and keep thin. Decide where your source of truth lives first. The rest is the cost of getting from there back to a type you can actually trust.

The runnable code for all five is in the companion repo, one directory each, sharing one domain.

Top comments (0)