React to Rust, no try/catch

rust dev.to

I built three libraries independently. When I put them in the same project, the boundaries between them disappeared. This is what was left.

The path: a click in a React form, through schema validation, into a Rust solver running in WebAssembly, back through a typed Result, and out to a rendered porkchop heatmap — without using try/catch for control flow anywhere.

The libraries:

  • lambert-izzo — Rust solver for Lambert's problem, exposed to JS via wasm-pack. Returns a typed LambertOutcome; domain failures come back as values, not exceptions.
  • @railway-ts/pipelinesResult<T, E>, Option<T>, schema, and composition. ~4.8 kB brotli, fully tree-shakable.
  • @railway-ts/use-form — schema-first React forms. The schema is the single source of truth for types, validation, field paths, and error placement.

They share an idiom by design: discriminated outcomes, no exceptions for control flow. The interesting question is what happens when you put them in the same project. The short answer: the seams disappear.

The demo lives at stackblitz.com/edit/vitejs-vite-9hmwtvdt. Go run it before reading the rest of this — it's small, and the source is the argument.

A note on audience: if you've used Result/Option idioms before — Rust, fp-ts, neverthrow — the code samples will read naturally. If you haven't, the first few may feel like jargon. The porkchop section earns the syntax; stick with it.


The Lambert problem in 90 seconds

Two position vectors r₁ and r₂. A time of flight tof. A gravitational parameter μ. Find the velocities v₁ and v₂ that connect them under Newtonian gravity. That's a Lambert two-point boundary-value problem, and Dario Izzo published a fast, robust algorithm for it in 2014.

You'd reach for it any time you need to design a transfer orbit: an Earth-to-Mars launch window, a satellite rendezvous, a debris-removal mission. It's the workhorse behind a porkchop plot — a heatmap over (departure date × arrival date) showing total Δv for every transfer in a window. The cheapest cell is the launch window.

The problem also has well-defined failure modes:

  • CollinearGeometryr₁ and r₂ point the same way; the transfer plane is undefined.
  • DegeneratePositionVector — one of the radii is zero.
  • NonPositiveTimeOfFlight, NonPositiveMu, NonFiniteInput — bad numerics.
  • RevsOutOfRange — multi-revolution count outside 1..=32.

These are not exceptions. They're cases.


The thesis: one error model from click to canvas

A small contract, applied consistently:

  • Failure with a reason is a Result<T, E>. Both branches are typed. Expected failures do not travel as thrown exceptions in user code.
  • Plain absence is an Option<T>. There is no null, no undefined, no ?.foo ?? bar chain at boundaries where the question is "do I have a value?"
  • Schema is the source of truth. Types are inferred from it. Cross-field invariants live with the schema, not in handlers.
  • Render once at the boundary. The only place it's acceptable to ask "ok or err?" is when you're about to draw pixels.

The demo actually does this. From the form input that you type into, all the way down to the Rust crate that solves the BVP, the same shape is preserved.


lambert-izzo: the discipline starts in Rust

Most TypeScript codebases that talk to WASM reintroduce exceptions at the FFI boundary — the wrapper catches a JS Error, decides whether it was a panic or a domain failure, and projects something into the app. This is where the railway typically dies. lambert-izzo doesn't have that boundary.

The Rust crate uses Result end-to-end, and the solver does not panic on bad input. Bad geometry, non-positive TOF, non-finite floats, out-of-range revolution counts — all of those return a typed LambertError variant. There is no panic! boundary to wrap because there are no panics on modeled input. The TypeScript wrapper's only job is to project the Rust sum type into a JS tagged union without flattening either branch.

The crate ships two ways: as a pure-Rust crate on crates.io (no_std-friendly, [f64; 3] API, no hard math dep), and as an npm package via wasm-pack under the name lambert-izzo. The WASM payload is small enough for ordinary browser delivery.

import { solveLambert } from "lambert-izzo";

const result = solveLambert({
  r1: [7000, 0, 0],
  r2: [0, 7000, 0],
  tof: 1457,
  mu: 398600.4418,
  way: "short",
  maxRevs: null,
});

if (result.kind === "ok") {
  console.log(result.response.single.v1);
  console.log(result.response.diagnostics.single.iters);
} else {
  console.error(result.error.kind, result.error);
}
Enter fullscreen mode Exit fullscreen mode

result.error.kind is a discriminant: CollinearGeometry, DegeneratePositionVector, NonPositiveTimeOfFlight, etc. Even input validation (maxRevs > 32) returns through the same channel — RevsOutOfRange lands as kind: "err" with structured fields, not as a thrown error.

The deeper point: Rust's type system forces the discipline that TypeScript's culture only suggests. Once the Rust side is right, every TS consumer downstream inherits the property for free. The wrapper is two lines of projection logic. The wrapper can be two lines because the producer was correct.

The demo uses three fields from the success response: response.single.v1, response.single.v2, and response.diagnostics.single.iters. For multi-rev solutions there's also response.multi[]. Everything is [number, number, number] for vectors — the same shape as the input. No BigInt, no proxies, no surprises.


@railway-ts/pipelines: the railway

@railway-ts/pipelines is four independent submodules that can be imported on their own. The demo touches all four.

Result. ok and err constructors. Verbs that operate on the railway without poking at the tag: mapWith (transform the success branch), mapErrWith (transform the error branch), match (pattern-match), partition (split a Result[] into successes and failures in one pass). Failure with a reason.

Option. some and none. mapToOption lifts a Result to an Option (errors become none). match over { some, none }. Plain absence — when the question is "do I have a value?", not "did something go wrong?".

Schema. object, required, optional, chain, parseNumber, tupleOf, stringEnum, refineAt, and friends. Parses unknown input into typed values. Accumulates all errors in one pass by default. Standard Schema v1 compliant — Zod, Valibot, ArkType all interop, but pipelines is the native dialect.

Composition. pipe for immediate application, flow for point-free reusable pipelines, curry/uncurry for shape-shifting. pipeAsync/flowAsync for the same model when steps return promises.

A taste:

import { pipe } from "@railway-ts/pipelines/composition";
import { ok, err, mapWith, match } from "@railway-ts/pipelines/result";

const divide = (a: number, b: number) =>
  b === 0 ? err("div by zero") : ok(a / b);

const result = pipe(
  divide(10, 2),
  mapWith((x) => x * 3),
);

match(result, {
  ok: (value) => console.log(value), // 15
  err: (error) => console.error(error),
});
Enter fullscreen mode Exit fullscreen mode

The bundle math, from the README:

Module Brotli
result 631 B
option 406 B
composition 233 B
schema 3.54 kB
Total ~4.8 kB

Many TypeScript projects pay for these capabilities three times — once for a validator, once for a Result library, once for hand-rolled async wiring between them. Here it's one model.


@railway-ts/use-form: schema as single source of truth

useForm takes a schema and an initial-values object. It gives back a form handle whose getFieldProps(name) is spread directly onto a native <input>. There's no <Controller> component, no register("name"), no resolver factory. The schema is the source of truth for types, validation, field paths, and error placement.

import { useForm } from "@railway-ts/use-form";
import {
  object,
  required,
  chain,
  string,
  nonEmpty,
  email,
  type InferSchemaType,
} from "@railway-ts/pipelines/schema";

const loginSchema = object({
  email: required(chain(string(), nonEmpty(), email())),
  password: required(chain(string(), nonEmpty())),
});

type LoginForm = InferSchemaType<typeof loginSchema>;

export function LoginForm() {
  const form = useForm<LoginForm>(loginSchema, {
    initialValues: { email: "", password: "" },
    onSubmit: async (values) => {
      /* ... */
    },
  });

  return (
    <form onSubmit={(e) => void form.handleSubmit(e)}>
      <input type="email" {...form.getFieldProps("email")} />
      {form.getFieldError("email") && (
        <span>{form.getFieldError("email")}</span>
      )}
      <input type="password" {...form.getFieldProps("password")} />
      {form.getFieldError("password") && (
        <span>{form.getFieldError("password")}</span>
      )}
      <button type="submit" disabled={form.isSubmitting}>
        Log in
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

handleSubmit returns a Result. There is no try/catch wrapper around onSubmit; if validation fails, the failure is a typed return value. Three error layers (client, per-field async, server) compose with deterministic priority. Cross-field validation via refineAt (covered next). Standard Schema v1 means Zod / Valibot / ArkType also work — the demo uses pipelines natively because the cross-field rules are cleaner there.

The interesting move is the absence of a layer. There is no separate "form types" file, no resolver adapter, no useFieldRegister, no schema-to-error translation. The schema is the type. The schema is the validator. The schema is the field path source. Three jobs, one declaration.


The schema in the demo

Here's the actual Lambert request schema, lifted from src/lib/schema.ts:16:

import * as S from "@railway-ts/pipelines/schema";

const vec3Schema = S.tupleOf(S.parseNumber(), 3);

export const lambertRequestSchema = S.chain(
  S.object({
    r1: S.required(vec3Schema),
    r2: S.required(vec3Schema),
    tof: S.required(S.chain(S.parseNumber(), S.positive())),
    mu: S.required(S.chain(S.parseNumber(), S.positive())),
    way: S.required(S.stringEnum<TransferWayInput>(["short", "long"])),
    maxRevs: S.emptyAsOptional(S.parseNumber()),
  }),
  S.refineAt("r2", (v) => !vec3Equal(v.r1, v.r2), "r2 must differ from r1"),
);

export type LambertRequestValues = S.InferSchemaType<
  typeof lambertRequestSchema
>;
Enter fullscreen mode Exit fullscreen mode

The single-solve view rendered from the schema above. Every field in lambertRequestSchema has a one-to-one render — the vec3 tuples become three inputs, stringEnum becomes a select, emptyAsOptional becomes "optional · leave empty for none." The green SOLVED badge is the success branch of the Result; below it is the typed LambertResponse (V₁, V₂, iter count, multi-rev pairs). The transfer-plane sketch in the corner plots r₁, r₂, and the connecting arc.

Each piece does work:

  • vec3Schema = S.tupleOf(S.parseNumber(), 3) — a typed 3-tuple of numbers. Inferred type is exactly [number, number, number], not number[].
  • tof and mu are positive numbers (chain(parseNumber(), positive())). Negative or zero is rejected at the parse boundary, not inside the solver.
  • way is a stringEnum, typed as the Rust crate's TransferWayInput. The enum values flow from the WASM lib's TypeScript definitions through the schema into the form — one source.
  • maxRevs uses emptyAsOptional, so an empty input becomes undefined rather than a "could not parse number" error. Polite UX without giving up types.
  • refineAt("r2", ..., "r2 must differ from r1") is the cross-field invariant. The schema decides where the error lands — in this case, on the r2 field — so the form renders it next to the offending input. The form never knew the rule existed.

The porkchop schema (src/lib/schema.ts:38) does the same on the date windows: a triple refineAt chain enforces departureMax > departureMin, arrivalMax > arrivalMin, and the overlap rule between the two windows. Three rules, three error placements, no handler code.

InferSchemaType<typeof lambertRequestSchema> is the type. Nothing else needs to know.


The solver as the next leg of the railway

This is the bridge from the form to the WASM crate. The whole file is 41 lines (src/lib/solver.ts):

import { ok, err, type Result } from "@railway-ts/pipelines/result";
import { flow } from "@railway-ts/pipelines/composition";
import {
  solveLambert,
  solveLambertBatch,
  type LambertRequest,
  type LambertResponse,
  type LambertErrorOutput,
  type LambertOutcome,
} from "lambert-izzo";
import type { LambertRequestValues } from "./schema";

export type SolveResult = Result<LambertResponse, LambertErrorOutput>;

const toRequest = (values: LambertRequestValues): LambertRequest => ({
  ...values,
  maxRevs: values.maxRevs,
});

const fromOutcome = (o: LambertOutcome): SolveResult =>
  o.kind === "ok" ? ok(o.response) : err(o.error);

const callSolver = (req: LambertRequest) => fromOutcome(solveLambert(req));

export const solve = flow(toRequest, callSolver);

export const solveBatch = (requests: LambertRequest[]): SolveResult[] =>
  solveLambertBatch(requests).map(fromOutcome);
Enter fullscreen mode Exit fullscreen mode

The fromOutcome adapter is two lines: a single ternary that re-tags the discriminated union. No try/catch. Not because we're being clever — because the domain API does not report modeled failures by throwing. The Rust side already handed us a typed sum. A try/catch around solveLambert would be an exception handler with no producer.

solve = flow(toRequest, callSolver) is point-free composition. Two named stages. The form gives you a LambertRequestValues; flow runs it through toRequest (the seam where any future form/WASM-bridge divergence would land) then through callSolver, and you get a SolveResult. No glue code, no intermediate variables, no ifs.

solveBatch is .map(fromOutcome) over the batch result. The in-file comment explains why it doesn't go through flow: it's a single transformation, and ceremony doesn't pay for itself here.

A note on the type. SolveResult = Result<LambertResponse, LambertErrorOutput>. The success branch carries the full WASM response (positions, velocities, multi-rev solutions, diagnostics). The error branch carries the structured LambertErrorOutput directly from the WASM crate. The .kind discriminant is preserved end-to-end. If the user typed a TOF of zero, the form rejects it before the schema even hits the solver; if the user types a tiny but legal TOF that produces a RevsOutOfRange, that lands as a LambertErrorOutput on the error branch, and the kind field is what the renderer uses.

One file, four names, two outward-facing functions. That's the whole bridge.


Porkchop: where Option earns its keep

3,600 Lambert solves in 29 ms — the entire grid recomputes faster than a single React render. The bright diagonal band is the launch window; hovering any cell surfaces the underlying CellOk payload (Δv, TOF, solver iterations).

The porkchop sweep is where the demo gets interesting, because it forces the Result-vs-Option distinction.

A porkchop run computes one Lambert solve for every cell in a (departure date × arrival date) grid. The default grid is 60×60 = 3,600 cells. Some cells succeed; some fail because of bad geometry, non-positive TOF (arrival before departure — non-physical), or solver-internal reasons.

What's the type of one cell? src/lib/porkchop.ts:46:

// A grid cell is the *result* of solving for that (departure, arrival) pair —
// either the trajectory we were after, or a structured reason it didn't work.
// `Result` is the right shape: branches carry different payload types and the
// pipelines library has the verbs (`mapWith`, `mapErrWith`, `partition`,
// `mapToOption`, `match`) to operate on it without poking at the tag.
export type Cell = Result<CellOk, CellReason>;
Enter fullscreen mode Exit fullscreen mode

Failure with a reason. Result.

Now, what's the type of "the best cell across the whole grid"? If every cell errored, there is nothing to report. That's not a failure with a reason — there's no value range to compute extrema over. It's just absence. src/lib/porkchop.ts:61:

// `extrema` is `Option` rather than `Result`: there is no error to report
// when every cell failed — there is just no value range. Absence, not failure.
export type Grid = {
  params: SweepParams;
  cells: Cell[][]; // [iy][ix]
  extrema: Option<Extrema>;
};
Enter fullscreen mode Exit fullscreen mode

This is the rule the design keeps coming back to: model failure-with-reason as Result<T, E>; model plain absence as Option<T>. Don't flatten one into the other. The types tell you, six months later, what the empty case actually means.

The per-cell pipeline is three verbs from the pipelines library (src/lib/porkchop.ts:154):

// `pipe` threads the typed input through both stages so the curried
// `mapWith`/`mapErrWith` generics resolve cleanly. `flow` (point-free) doesn't
// anchor the input type at construction, which leaves `E` unbound here.
const cellFromResult =
  (plan: PhysicalPlan) =>
  (r: SolveResult): Cell =>
    pipe(r, mapWith(responseToOk(plan)), mapErrWith(errorToReason));
Enter fullscreen mode Exit fullscreen mode

SolveResult is Result<LambertResponse, LambertErrorOutput> from the solver. mapWith rewrites the success branch into a CellOk (which carries the Δv we care about plus the trajectory data). mapErrWith rewrites the error branch into a CellReason (which is LambertErrorOutput["kind"] | "non-physical"). The output type is Result<CellOk, CellReason> — exactly Cell. We never asked "ok or err?"; the pipelines verbs operate on both branches simultaneously without poking at the tag.

The extrema fold (src/lib/porkchop.ts:200) is where Option carries its weight:

const stepExtrema =
  (ix: number, iy: number, cell: Cell) =>
  (acc: Option<Extrema>): Option<Extrema> =>
    matchOpt(mapToOption(cell), {
      some: (ok) => mergeExtrema(ok, ix, iy)(acc),
      none: () => acc,
    });

const computeExtrema = (cells: Cell[][]): Option<Extrema> =>
  cells.reduce<Option<Extrema>>(
    (rowAcc, row, iy) =>
      row.reduce<Option<Extrema>>(
        (acc, cell, ix) => stepExtrema(ix, iy, cell)(acc),
        rowAcc,
      ),
    none(),
  );
Enter fullscreen mode Exit fullscreen mode

mapToOption(cell) is the lift: it turns each Cell (a Result) into an Option<CellOk>. Error cells become none() and drop out of the fold. The accumulator is Option<Extrema>none() while we haven't seen a successful cell, some(extrema) once we have. The verb is in the library; user code is the recipe.

Three files, one mental model. The same Result that came out of the WASM crate is still the same Result here, with a transformed payload, ready for the renderer.


Render once at the boundary

The whole point of carrying the railway is that you only get off at the destination. In the demo, "destination" means "we're about to draw pixels."

Single solve view (src/components/single/ResultView.tsx:13):

if (!result.ok) {
  return (
    <div className="readout readout--err">
      <div className="readout__header">
        <span className="readout__badge readout__badge--err">FAULT</span>
        <span className="readout__kind">{result.error.kind}</span>
      </div>
      <pre className="readout__detail">
        {JSON.stringify(result.error, null, 2)}
      </pre>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

One if, at the screen. Below this line, result.value is typed as LambertResponse and TypeScript knows it.

Batch view (src/components/batch/ResultsView.tsx:11):

export function ResultsView({ presets, results }: Props) {
  const { successes, failures } = partition(results);
  const total = results.length;
  // ... render counts and per-row badges
}
Enter fullscreen mode Exit fullscreen mode

partition splits a Result[] into successes: T[] and failures: E[] in one pass. The user code never iterates the array branching on result.ok. The library has the verb.

Porkchop view (src/components/porkchop/index.tsx:60):

const okCount = useMemo(
  () => (grid ? partition(grid.cells.flat()).successes.length : 0),
  [grid],
);

const best: CellOk | null = grid
  ? matchOpt(grid.extrema, {
      some: (e) => e.best,
      none: () => null,
    })
  : null;
Enter fullscreen mode Exit fullscreen mode

partition again on the success count. matchOpt for the best cell — none becomes null only at the React boundary, where null is the natural "nothing to render" value. Inside the data layer, it stays Option.

This is the contract paying off. The if (!result.ok) lives in ResultView, not in a hook, not in the bridge, not in the schema, not in the solver, not in the porkchop math. The renderer is the only piece that is allowed to ask the question.


The bundle math, and what this costs

Production build of the full demo:

dist/index.html                                   0.81 kB │ gzip:  0.44 kB
dist/assets/lambert_izzo_wasm_bg-CPvILBQE.wasm   96.94 kB │ gzip: 44.06 kB
dist/assets/index-...css                         20.99 kB │ gzip:  4.89 kB
dist/assets/index-...js                         288.51 kB │ gzip: 91.54 kB
Enter fullscreen mode Exit fullscreen mode

Of that JS, the railway-ts libraries together account for ~8 kB brotli (per the published READMEs: pipelines ~4.8 kB, use-form ~3.6 kB). The rest is React 19, react-router, and the demo's own code. The WASM blob is 97 kB — about the size of a stock photo — and ships the entire Lambert solver, including the Izzo pipeline, the geometry stage, and structured error types.

What this approach costs:

  • Less maturity than the alternatives. Zod has been around for years. neverthrow is widely deployed. react-hook-form has more docs, more StackOverflow answers, more video tutorials. These libraries are smaller, newer, and more opinionated. That's a real trade.
  • It's a team-level commitment, not an individual one. If half your engineers reach for try/catch and the other half reach for Result, you've doubled the surface area instead of replacing it. The proposition is that the data layer has one shape; that requires that the team has decided it. Hiring TypeScript engineers who already think in Result/Option is harder than hiring TypeScript engineers in general — the talent pool that's spent time in Rust, F#, OCaml, Scala, or Haskell is smaller. The ramp is real.
  • Strictness at the boundaries. You can't half-do this. A single dependency with a thrown Error, a single await that leaks a rejection, a single legacy adapter that returns null on failure — any of these reintroduces the question the railway exists to remove. The discipline is contagious in both directions: a clean data layer makes the next file easier; a leaky one makes every file harder.

What it buys:

  • Every error has a type. error.kind autocompletes to the actual cases. There is no "what could this throw?" guesswork.
  • Every absence has a name. none is none; it is not undefined masquerading as missing-but-actually-present.
  • The schema is one place. Add a field, the type updates, the form binds it, the validation enforces it, the solver consumes it. Four steps used to be four edits; now it's one.
  • The WASM boundary stops being scary. The Rust crate already speaks Result. The TypeScript adapter is two lines. No panics to wrap, no JS Error subclasses to dispatch on.

Closing

The interesting files in the demo are five:

  • src/lib/schema.ts — the schemas (~65 lines)
  • src/lib/solver.ts — the WASM bridge (~40 lines)
  • src/lib/porkchop.ts — the sweep, including Result-to-Option lifting (~270 lines; the cellFromResult and stepExtrema stretch around line 150–210 is the heart)
  • src/components/single/index.tsx — the form, schema-driven (~110 lines)
  • src/components/batch/index.tsx — the batch, partition-driven (~50 lines)

All of them are open. Walk them.

The seams between layers can be removed; when they are, the resulting code is shorter than what it replaced; and a typed Rust solver running in your browser doesn't have to be the scary part of the codebase.

The scary part, when it shows up, will be in the code that decided to throw.

Source: dev.to

arrow_back Back to Tutorials