·3 min read

Three boundaries, one source of truth: sharing types across Rust and TypeScript

build-in-publicrusttypescriptarchitecturesolana|
PostLinkedIn

Three boundaries, one source of truth: sharing types across Rust and TypeScript

When you have a Rust backend and a TypeScript frontend, the dream is one schema, typed everywhere. I spent a day this week convincing myself the answer was "protobuf all the way down." It wasn't — and the reason turned out to be the whole lesson.

The app has three boundaries that all need to agree on data shapes:

  1. Solana validator → indexer — a firehose of account and transaction updates.
  2. Indexer → web app — the public read API the frontend calls.
  3. On-chain program → web app — account and instruction types coming off Solana RPC.
My instinct was to pick one protocol — gRPC + protobuf — and use it everywhere. One schema, generated bindings on both sides, no drift. Clean, right?

The problem is the three boundaries don't want the same thing.

Ingestion (validator → indexer) genuinely is a gRPC job. It's a high-throughput, server-to-server stream, and the Solana indexing ecosystem has largely converged on Yellowstone/Geyser gRPC (over tonic + prost). Protobuf's compact wire format and backpressure are exactly right here. Keep it. The public API (indexer → web) is where "protobuf everywhere" falls apart. This is a browser client hitting cacheable read endpoints through an edge/CDN. gRPC-Web there makes standard browser/CDN caching less natural, usually needs proxy or server support, and gives me worse debuggability — to save a few bytes on payloads the CDN was going to cache anyway. So this boundary stays REST. The type safety comes from codegen instead: Rust response structs, via JSON Schema/OpenAPI generation (aide + schemars), produce an OpenAPI 3.0 spec → openapi-typescript → the exact types the frontend imports. A CI check fails the build if the generated types ever drift from the Rust source. That's the same kind of no-drift guarantee tRPC gives you — single source of truth — just across a language boundary tRPC can't cross. Bonus: the OpenAPI spec gives me free interactive docs. On-chain types have only one honest source: the Anchor IDL. It isn't a transport at all — it's the program's interface description — and Anchor generates TypeScript types straight from it. Nothing to invent.

So here's what I actually learned: "share the API" is not a transport decision, it's a codegen-discipline decision. The win isn't one wire format everywhere — it's one source of truth per boundary, every TS type generated (never hand-written), and a drift gate in CI. Three boundaries, three generators, one rule.

If I ever do want protobuf reused literally in the browser, the clean path is ConnectRPC rather than raw gRPC-Web. It can support cacheable GETs for side-effect-free reads — so it doesn't necessarily give up edge caching — but I'd still be choosing an RPC/protobuf surface over the simpler REST/OpenAPI surface I want for a public market API.

Reaching for one tool to unify everything felt like good engineering. Matching the tool to each boundary turned out to be better engineering.

Oleksandr Yusypenko

Oleksandr Yusypenko

Senior Full-Stack + AI Engineer. Building in public — AI agents, LangGraph, production systems.