OpenAPI, discriminated unions, and catching mistakes before mainnet does


Introduction

Most blockchain APIs are just JSON with strong opinions.

On one side you have Ethereum JSON‑RPC, Cardano backend services, Cosmos indexers, your own microservices; on the other, a TypeScript frontend trying to make sense of it all. If you treat everything as any, the compiler can’t help you when you mix testnet and mainnet, swap the direction of a transfer, or silently ignore a new transaction type.

TypeScript is good at describing rich shapes. The trick is to let API contracts drive your types, and to model “this might be one of N transaction flavours” in a way the compiler understands.

Here I’ll walk through how I approach type‑safe blockchain APIs in TypeScript:

  • generating API clients from OpenAPI / JSON‑RPC specs
  • using discriminated unions to model transaction and response variants
  • combining compile‑time types with runtime validation so untrusted chain data never flows unchecked through the app

The focus is on how you structure types and flows rather than on specific libraries.


Prerequisites

I’m assuming:

  • you’re comfortable with TypeScript’s basic type system
  • your backend exposes either REST/GraphQL with OpenAPI, or JSON‑RPC (Ethereum‑style) with a reasonably stable schema
  • you’re willing to treat the API description as the source of truth, not ad‑hoc types in the frontend

You don’t need to be a language theorist; we’ll stay at the “useful for real projects” level.


1. Contract‑driven types instead of hand‑written guesses

The first decision is philosophical: does your frontend invent types by hand, or does it derive them from the API contract

For REST and GraphQL backends, the usual contract is OpenAPI. Tools like Swagger Codegen, OpenAPI Generator, openapi‑typescript, and openapi‑typescript‑codegen will read an OpenAPI document and emit a TypeScript client plus strongly‑typed request/response models.(Swagger)

For JSON‑RPC (Ethereum, L2s, some Cosmos services), you either:

  • treat the JSON‑RPC spec itself (OpenRPC / JSON schema) as the contract and generate a client, or
  • adopt a library that already bakes in a type‑safe mapping of that API (for example, Viem for Ethereum).(ethereum.org)

The key property is the same in both cases:

[ API spec ] --> [ generated TS types + client ]
                          |
                          v
                 [ rest of your code ]

You don’t write TxResponse by hand; it falls out of the spec. When the backend adds a field or changes a type, your generated types change, and the compiler tells you exactly where you’re out of sync.

Frontends that skip this and hand‑roll types tend to drift over time. You get “ghost fields” that no longer exist server‑side, or you forget to handle new error variants. The whole point of letting a generator do the boring work is to make that drift impossible.


2. Discriminated unions: modelling “one of many” safely

Blockchain data is full of “one of several shapes”:

  • transaction types: payments, delegations, swaps, mint/burn, governance votes
  • outputs: UTxO vs account‑based records, single‑asset vs multi‑asset
  • API responses: success vs rate‑limit vs validation error vs “chain not synced”

TypeScript’s union types already express “this value can be X or Y”; discriminated unions add a tag field that makes narrowing safe and ergonomic. The TypeScript handbook and a lot of community articles show this pattern as the right way to model variant data structures.(TypeScript)

Conceptually:

Transaction =
  | PaymentTx     { kind = "payment";    ... }
  | DelegationTx  { kind = "delegation"; ... }
  | SwapTx        { kind = "swap";       ... }

The kind field is the discriminant. Everywhere in your code you can switch on kind and the compiler knows which fields exist in each branch. You don’t need to sprinkle optional chaining and runtime type checks; the type system guarantees you handled all cases.

For blockchain APIs this is ideal for:

  • transaction decoders: once you decode raw on‑chain data into a discriminated union, rendering logic can be type‑driven
  • activity feeds mixing swaps, LP actions, staking, and NFT transfers
  • error handling where you want to be explicit about “retryable”, “user error”, “infrastructure error”, and “chain error”

Used well, discriminated unions become your informal spec for “what can actually happen” in the system.


3. Compile‑time vs runtime safety: you need both

There’s a hard truth with blockchain APIs: the data is untrusted.

TypeScript’s types exist only at compile time. At runtime you still receive opaque JSON from nodes, indexers, and third‑party services. If you never validate it, you’re just telling the compiler a comforting story.

Libraries like Zod sit exactly in that gap: you define a schema once, use it to validate data at runtime, and TypeScript infers the corresponding static type from that schema.(GitHub)

Abstractly:

          ┌──────────────┐
          │   Schema     │  (Zod)
          └──────────────┘
             ▲        ▲
   runtime   │        │   compile-time
validation   │        │   type inference
             │        │
       [ parsed, validated data ]

That gives you three layers of defence:

  • contract → generated client types (your “shape”)
  • Zod (or similar) → runtime check that data matches the shape
  • discriminated unions → safe branching over variants

For blockchain, where a single malformed response can represent millions in value, that belt‑and‑braces approach is worth the extra ceremony.


4. Implementation blueprint

I usually wire things together as a simple pipeline.

4.1 From spec to TypeScript client

For REST / GraphQL:

OpenAPI document
      |
      v
[ TS client generator ]
      |
      v
Type-safe client + models

Generators like Swagger Codegen, OpenAPI Generator, openapi‑typescript‑codegen, Speakeasy’s SDK generator, or pure‑TS clients like feTS all exist in this space; they differ in ergonomics, but the underlying idea is the same: generate TypeScript clients from OpenAPI to enforce contracts.(Swagger)

For JSON‑RPC:

JSON-RPC spec (Ethereum, L2, custom)
      |
      v
Either:
  - own generator (OpenRPC/OpenAPI → TS),
  - or a type-safe library (e.g. Viem for Ethereum).

Ethereum’s JSON‑RPC and OpenRPC documents are explicit enough that you can generate clients and types; projects like Viem already provide a typed interface to Ethereum RPC in TypeScript.(ethereum.org)

The important part: your code calls a generated client, not fetch with hand‑written shapes.

4.2 Mapping low‑level results into domain types

Generated types are often low‑level: they reflect whatever the backend exports. For productive application code you want domain‑level types that speak your language: WalletActivity, DexSwap, StakeOperation, BridgeTransfer.

This is where discriminated unions come in.

Shape it mentally like this:

[ Raw API models ]
      |
      v
[ Mapping layer ]
      |
      v
[ Domain types (discriminated unions) ]
      |
      v
[ UI / business logic ]

For example, you might get separate REST endpoints or JSON‑RPC calls for transfers, swaps, LP adds, and staking actions. The mapping layer converts them to a single WalletActivity union tagged by activityType. Downstream code never manipulates raw JSON; it works with well‑named fields and a small, finite set of variants.

4.3 Runtime validation at the edges

Any time untrusted data crosses a boundary (node → backend, backend → frontend, third‑party indexer → your service), I like to validate it once and then treat everything behind that point as trusted.

In a TypeScript service that’s often:

[ JSON from node/indexer ]
      |
      v
[ Zod schema parse ]
      |
  success / failure
      |
      v
[ strongly typed value ]

Zod and similar libraries are optimised for this pattern: definitions live alongside your TypeScript code, and types are inferred from the schemas so you don’t duplicate them.(Zod)

The combination looks like this:

spec-driven TS client   +   runtime schema   +   discriminated unions
       (shape)                (trust)                 (behaviour)

Each layer reinforces the others.


5. Testing type safety like you test logic

You don’t “run” TypeScript types in production, but you can still test that your type layer behaves as intended.

Three levels I care about:

Contract tests. Your CI generates clients from the latest spec and compiles the code. If a backend change breaks you, that compilation step fails immediately. Teams using OpenAPI + TypeScript specifically recommend this pattern as a way to catch contract drift early.(HackerOne)

Narrowing tests. For important discriminated unions (like transaction types), it’s worth having small “type tests”: switch over the kind field and ensure the compiler forces you to handle all variants. If you add a new transaction kind and forget to update some branch, the compiler tells you.

Validation tests. For Zod (or whichever validator you use), you can feed sample responses and ensure:

  • valid data parses to the right inferred type
  • invalid data fails with clear errors and doesn’t leak into the rest of the codebase

Articles on building robust API clients with TypeScript and Zod lean heavily on this pattern: schemas as both runtime guardrail and compile‑time documentation.(Leapcell)


6. Production considerations

A few things become important once this hits mainnet.

6.1 Versioning and backwards compatibility

OpenAPI or OpenRPC documents are versioned too. You need a strategy for:

  • backend adds a field → generated types gain that field; frontend ignores it until needed
  • backend deprecates a field → generated types mark it optional; discriminated unions evolve
  • major shape change → versioned endpoints or separate clients

People building large OpenAPI‑driven systems often solve this by coupling backend and frontend releases or by enforcing strict backwards compatibility at the API layer.(Speakeasy)

In blockchain, where you might talk to third‑party nodes and indexers you don’t control, you should assume they can change underneath you and keep validation at the boundary.

6.2 Performance

Static types are free at runtime; Zod is not.

Most blockchain UIs and services can afford the overhead of validating external responses once. But you don’t want to re‑validate the same payload in every layer.

A common pattern:

- validate once at the edge (node → service, service → frontend),
- store and propagate the validated result internally,
- rely on TypeScript from there on.

This matches how Zod is positioned: schema‑driven validation at the boundary, TypeScript types inferred once and reused everywhere.(Zod)

6.3 Multi‑chain complexity

If you support Ethereum, Cardano, and Cosmos, each API ecosystem has its own quirks:

  • Ethereum JSON‑RPC with hex strings and positional params(ethereum.org)
  • Cardano REST / custom indexer APIs
  • Cosmos gRPC/REST and Tendermint RPC

Try to keep one internal model per concept (e.g. Balance, TxRef) and treat chain‑specific models as implementation details at the edge. That way, most of your TypeScript code doesn’t care whether something came from eth_getBalance or a Cardano indexer; it just works with a consistent, discriminated union or record type.


7. Experience callouts

Production note – Ethereum JSON‑RPC. On one EVM project we started with a weakly‑typed JSON‑RPC client. Everything was “string in, string out”. When we switched to a type‑safe client and discriminated unions for eth_call results, we immediately found several places where we were mis‑interpreting hex fields or mixing up network IDs. These bugs had never surfaced in tests because the shapes “looked right” as plain JSON.

Production note – Cardano activity feed. For a Cardano wallet UI, modelling on‑chain actions as a single WalletActivity discriminated union made the rendering code dramatically simpler. The mapping layer did the hard work of decoding UTxOs and Plutus data into Payment, Stake, Liquidity, and Governance variants; the rest of the code never touched raw chain data again.

Production note – Zod at the edge. Adding Zod validation on responses from a third‑party indexer caught a subtle breaking change: they’d altered a field from a number to a string in one release. Without validation we would have seen it only as “some charts look wrong”; with validation it failed fast and loudly in staging.


Conclusion

TypeScript on its own doesn’t make blockchain APIs safe.

What makes the difference is how you structure the contract between your services and your code:

- Specs (OpenAPI / JSON-RPC) as the single source of truth.
- Generated TypeScript clients instead of hand-written request/response types.
- Discriminated unions for transaction types, activities, and errors.
- Runtime validation (Zod or similar) at the boundaries.
- CI that treats “type mismatch with the spec” as a failing test.

Once that skeleton is in place, adding a new endpoint, a new transaction flavour, or even a new chain becomes a type‑driven exercise. The compiler stops you from shipping half‑implemented variants, and the runtime validator stops the chain from surprising you with malformed data.

In a space where a one‑line mistake can leak real value, that combination of compile‑time and runtime guarantees is one of the cheapest forms of risk reduction you can buy.

Completion scope and production contract

This completion review turns the earlier conceptual treatment into a release-oriented engineering contract. It treats TypeScript Type Safety for Blockchain APIs as a typed web, wallet, or blockchain API component, follows a user intent, API representation, wallet request, stream update, or transaction lifecycle event through validation and durable state, and separates normative requirements from implementation policy. The normative baseline is the published API or wallet specification, TypeScript model, and chain confirmation rules; deployment defaults, caching, retry limits, and operator thresholds are explicitly local policy. 9

The intended audience is experienced developers and architects. Readers should understand the surrounding chain or application model, typed data structures, persistence, and basic security engineering. The scope includes correctness, implementation boundaries, deterministic tests, failure recovery, security, performance, and observability. It does not claim that the educational companion is a drop-in replacement for a maintained protocol or cryptographic library. Production adoption requires an independent threat model, compatibility testing against the authoritative implementation, and operational ownership. 10

The mental model used throughout is deliberately strict: untrusted input crosses browser, wallet extension, transport, API, cache, and accessibility boundaries; a validator derives facts under the published API or wallet specification, TypeScript model, and chain confirmation rules; accepted transitions update server-derived data, wallet session, UI intent, and explicit transaction state; and observers consume committed facts, never optimistic intermediate mutations. A guarantee is stated only when it follows from those rules and assumptions. Heuristics such as fee selection, caching, peer scoring, timeouts, user messaging, or alert thresholds remain policy and may be tuned without redefining validity. 11

Reader contract and scope

For TypeScript Type Safety for Blockchain APIs, this review makes the exact user decision and the prerequisites needed to make it safely explicit. Start from one user intent, API representation, wallet request, stream update, or transaction lifecycle event and write down its origin, canonical representation, validation context, authority, and durable outcome. The typed web, wallet, or blockchain API component must not infer a stronger fact from transport success, cache presence, or an upstream acknowledgement. Its authoritative state is server-derived data, wallet session, UI intent, and explicit transaction state, and every projection must remain rebuildable or reconcilable from committed facts. This framing distinguishes a protocol guarantee from an operational convenience and gives reviewers a concrete place to challenge an assumption. 9

The principal failure to design against is an attractive example being mistaken for a complete production design. Address it before optimizing by defining a narrow ownership boundary, stable identities, bounded resource use, and a machine-readable outcome for every rejected transition. Record a scope statement, excluded concerns, and a reviewable acceptance criterion. A reviewer should be able to trace each accepted result to input bytes, rule or policy version, prior state, and commit identity without relying on prose logs. When the authority cannot be reached or context is incomplete, return an explicit unavailable or pending state; do not convert uncertainty into acceptance.

Precise vocabulary and authority

Treat precise vocabulary and authority as part of the executable design of TypeScript Type Safety for Blockchain APIs, not as documentation added after coding. The relevant operating envelope includes initial load, pagination, reconnect, chain switch, signing, pending confirmation, and partial API failure. For each mode, identify which state is authoritative, which work may be retried, what is bounded, and which observation proves progress. This is especially important across browser, wallet extension, transport, API, cache, and accessibility boundaries, where delivery and processing are different events and where local time or arrival order may not reflect authoritative order. 10

A useful review asks how the design behaves under stale closure, duplicate update, wallet rejection, chain mismatch, schema drift, reconnect storm, and misleading confirmation. The unsafe outcome is teams using the same word for incompatible states or guarantees. Prevent it with explicit preconditions and postconditions, and retain a glossary tied to the normative authority for every overloaded term as release evidence. Use stable codes rather than exception text, keep policy configuration versioned, and attach the accepted policy or rule version to durable results. An frontend or API-platform operator must be able to stop intake, drain or quarantine work, compare local state with authority, and resume without inventing a second side effect.

Trust assumptions

The implementation of TypeScript Type Safety for Blockchain APIs should expose which actors, clocks, stores, libraries, and upstream systems may fail or act maliciously through types and module boundaries. Parse external representations once, preserve the original identity when audit or replay needs it, and pass validated domain values inward. Mutations of server-derived data, wallet session, UI intent, and explicit transaction state belong behind one authoritative transition function or transaction boundary. Network clients, storage adapters, user interfaces, and telemetry exporters must not duplicate consensus or business rules. That separation keeps deterministic logic testable and prevents a library upgrade from silently redefining validity. 11

Assume that an implicit trusted component invalidating the claimed guarantee will eventually occur in a staging fault test or production incident. The control is not a catch-all retry: classify the outcome, decide whether the identical operation is safe to repeat, bound attempts and elapsed time, and surface terminal work for reconciliation. The minimum evidence is a trust-boundary diagram and an assumption register with owners. Keep addresses, wallet permissions, access tokens, KYC data, transaction intent, and browser storage out of ordinary logs, and prefer hashes, version identifiers, counts, and sanitized reason codes. Any emergency bypass must be narrow, time-limited, approved, and observable.

Architecture and ownership

Verification for TypeScript Type Safety for Blockchain APIs must demonstrate component responsibilities and the direction in which facts and commands move at several layers. Small tests cover deterministic transforms and boundary values; contract tests pin serialized forms; integration tests exercise real adapters; replay tests compare state roots or projections; and fault tests interrupt work at every commit boundary. The dataset must include normal history, malformed input, duplicates, reordering, maximum-size values, and changes of rule or schema version. A passing example is evidence about that environment and dataset, not a universal performance claim. 9

Make two components both believing they own the same transition a named negative test. The release packet should retain a context diagram, ownership table, and dependency rule, exact dependency and tool versions, the deterministic command, and its result. For performance work, report warm-up, repetitions, concurrency, percentiles, resource limits, and the point where backpressure begins. For security work, include abuse cases and independent review. A change is ready only when failures leave server-derived data, wallet session, UI intent, and explicit transaction state safe, recovery is rehearsed, and telemetry explains both user-visible outcome and operator action.

Canonical representation

For TypeScript Type Safety for Blockchain APIs, this review makes the byte-level or schema-level representation used for hashing, comparison, storage, and transport explicit. Start from one user intent, API representation, wallet request, stream update, or transaction lifecycle event and write down its origin, canonical representation, validation context, authority, and durable outcome. The typed web, wallet, or blockchain API component must not infer a stronger fact from transport success, cache presence, or an upstream acknowledgement. Its authoritative state is server-derived data, wallet session, UI intent, and explicit transaction state, and every projection must remain rebuildable or reconcilable from committed facts. This framing distinguishes a protocol guarantee from an operational convenience and gives reviewers a concrete place to challenge an assumption. 10

The principal failure to design against is semantically equal values producing different identifiers or verification outcomes. Address it before optimizing by defining a narrow ownership boundary, stable identities, bounded resource use, and a machine-readable outcome for every rejected transition. Record golden encodings, round-trip tests, and rejection of non-canonical forms. A reviewer should be able to trace each accepted result to input bytes, rule or policy version, prior state, and commit identity without relying on prose logs. When the authority cannot be reached or context is incomplete, return an explicit unavailable or pending state; do not convert uncertainty into acceptance.

State-machine model

Treat state-machine model as part of the executable design of TypeScript Type Safety for Blockchain APIs, not as documentation added after coding. The relevant operating envelope includes initial load, pagination, reconnect, chain switch, signing, pending confirmation, and partial API failure. For each mode, identify which state is authoritative, which work may be retried, what is bounded, and which observation proves progress. This is especially important across browser, wallet extension, transport, API, cache, and accessibility boundaries, where delivery and processing are different events and where local time or arrival order may not reflect authoritative order. 11

A useful review asks how the design behaves under stale closure, duplicate update, wallet rejection, chain mismatch, schema drift, reconnect storm, and misleading confirmation. The unsafe outcome is an impossible intermediate state becoming durable after interruption. Prevent it with explicit preconditions and postconditions, and retain a transition table exercised by positive, negative, and replay tests as release evidence. Use stable codes rather than exception text, keep policy configuration versioned, and attach the accepted policy or rule version to durable results. An frontend or API-platform operator must be able to stop intake, drain or quarantine work, compare local state with authority, and resume without inventing a second side effect.

Invariants

The implementation of TypeScript Type Safety for Blockchain APIs should expose properties that must hold before and after every accepted operation through types and module boundaries. Parse external representations once, preserve the original identity when audit or replay needs it, and pass validated domain values inward. Mutations of server-derived data, wallet session, UI intent, and explicit transaction state belong behind one authoritative transition function or transaction boundary. Network clients, storage adapters, user interfaces, and telemetry exporters must not duplicate consensus or business rules. That separation keeps deterministic logic testable and prevents a library upgrade from silently redefining validity. 9

Assume that local success concealing corruption in a related aggregate or index will eventually occur in a staging fault test or production incident. The control is not a catch-all retry: classify the outcome, decide whether the identical operation is safe to repeat, bound attempts and elapsed time, and surface terminal work for reconciliation. The minimum evidence is executable assertions at the narrowest authoritative boundary. Keep addresses, wallet permissions, access tokens, KYC data, transaction intent, and browser storage out of ordinary logs, and prefer hashes, version identifiers, counts, and sanitized reason codes. Any emergency bypass must be narrow, time-limited, approved, and observable.

Validation pipeline

Verification for TypeScript Type Safety for Blockchain APIs must demonstrate cheap structural checks, contextual checks, authoritative verification, and commit order at several layers. Small tests cover deterministic transforms and boundary values; contract tests pin serialized forms; integration tests exercise real adapters; replay tests compare state roots or projections; and fault tests interrupt work at every commit boundary. The dataset must include normal history, malformed input, duplicates, reordering, maximum-size values, and changes of rule or schema version. A passing example is evidence about that environment and dataset, not a universal performance claim. 10

Make expensive or stateful work running before malformed input is rejected a named negative test. The release packet should retain ordered validation stages with stable machine-readable rejection codes, exact dependency and tool versions, the deterministic command, and its result. For performance work, report warm-up, repetitions, concurrency, percentiles, resource limits, and the point where backpressure begins. For security work, include abuse cases and independent review. A change is ready only when failures leave server-derived data, wallet session, UI intent, and explicit transaction state safe, recovery is rehearsed, and telemetry explains both user-visible outcome and operator action.

Error semantics

For TypeScript Type Safety for Blockchain APIs, this review makes the distinction between invalid input, conflict, unavailable dependency, retryable interruption, and internal defect explicit. Start from one user intent, API representation, wallet request, stream update, or transaction lifecycle event and write down its origin, canonical representation, validation context, authority, and durable outcome. The typed web, wallet, or blockchain API component must not infer a stronger fact from transport success, cache presence, or an upstream acknowledgement. Its authoritative state is server-derived data, wallet session, UI intent, and explicit transaction state, and every projection must remain rebuildable or reconcilable from committed facts. This framing distinguishes a protocol guarantee from an operational convenience and gives reviewers a concrete place to challenge an assumption. 11

The principal failure to design against is blind retries amplifying a permanent failure or changing user intent. Address it before optimizing by defining a narrow ownership boundary, stable identities, bounded resource use, and a machine-readable outcome for every rejected transition. Record typed errors mapped consistently across logs, metrics, APIs, and queues. A reviewer should be able to trace each accepted result to input bytes, rule or policy version, prior state, and commit identity without relying on prose logs. When the authority cannot be reached or context is incomplete, return an explicit unavailable or pending state; do not convert uncertainty into acceptance.

Concurrency control

Treat concurrency control as part of the executable design of TypeScript Type Safety for Blockchain APIs, not as documentation added after coding. The relevant operating envelope includes initial load, pagination, reconnect, chain switch, signing, pending confirmation, and partial API failure. For each mode, identify which state is authoritative, which work may be retried, what is bounded, and which observation proves progress. This is especially important across browser, wallet extension, transport, API, cache, and accessibility boundaries, where delivery and processing are different events and where local time or arrival order may not reflect authoritative order. 9

A useful review asks how the design behaves under stale closure, duplicate update, wallet rejection, chain mismatch, schema drift, reconnect storm, and misleading confirmation. The unsafe outcome is a check-then-act race accepting two individually plausible operations. Prevent it with explicit preconditions and postconditions, and retain a linearization argument plus stress tests at the chosen contention boundary as release evidence. Use stable codes rather than exception text, keep policy configuration versioned, and attach the accepted policy or rule version to durable results. An frontend or API-platform operator must be able to stop intake, drain or quarantine work, compare local state with authority, and resume without inventing a second side effect.

Idempotency and replay

The implementation of TypeScript Type Safety for Blockchain APIs should expose how duplicate delivery, process restart, and historical backfill preserve the same result through types and module boundaries. Parse external representations once, preserve the original identity when audit or replay needs it, and pass validated domain values inward. Mutations of server-derived data, wallet session, UI intent, and explicit transaction state belong behind one authoritative transition function or transaction boundary. Network clients, storage adapters, user interfaces, and telemetry exporters must not duplicate consensus or business rules. That separation keeps deterministic logic testable and prevents a library upgrade from silently redefining validity. 10

Assume that at-least-once delivery creating a second side effect will eventually occur in a staging fault test or production incident. The control is not a catch-all retry: classify the outcome, decide whether the identical operation is safe to repeat, bound attempts and elapsed time, and surface terminal work for reconciliation. The minimum evidence is stable operation identities, deduplication state, and deterministic replay fixtures. Keep addresses, wallet permissions, access tokens, KYC data, transaction intent, and browser storage out of ordinary logs, and prefer hashes, version identifiers, counts, and sanitized reason codes. Any emergency bypass must be narrow, time-limited, approved, and observable.

References