惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

大猫的无限游戏
大猫的无限游戏
博客园 - 【当耐特】
Cloudbric
Cloudbric
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Attack and Defense Labs
Attack and Defense Labs
爱范儿
爱范儿
The Cloudflare Blog
腾讯CDC
Security Archives - TechRepublic
Security Archives - TechRepublic
TaoSecurity Blog
TaoSecurity Blog
云风的 BLOG
云风的 BLOG
Recent Announcements
Recent Announcements
C
Check Point Blog
Schneier on Security
Schneier on Security
S
Schneier on Security
J
Java Code Geeks
B
Blog RSS Feed
Cisco Talos Blog
Cisco Talos Blog
Vercel News
Vercel News
Stack Overflow Blog
Stack Overflow Blog
博客园_首页
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
A
About on SuperTechFans
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Google DeepMind News
Google DeepMind News
阮一峰的网络日志
阮一峰的网络日志
罗磊的独立博客
A
Arctic Wolf
S
Secure Thoughts
P
Palo Alto Networks Blog
The Last Watchdog
The Last Watchdog
SecWiki News
SecWiki News
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
博客园 - 三生石上(FineUI控件)
D
Darknet – Hacking Tools, Hacker News & Cyber Security
量子位
U
Unit 42
I
InfoQ
D
DataBreaches.Net
P
Privacy International News Feed
T
Troy Hunt's Blog
博客园 - 叶小钗
T
Threatpost
博客园 - Franky
K
Kaspersky official blog
Hugging Face - Blog
Hugging Face - Blog
IT之家
IT之家
www.infosecurity-magazine.com
www.infosecurity-magazine.com
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
C
Cisco Blogs

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
React to Rust, no try/catch
Sarkis M · 2026-05-09 · via DEV Community

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  raw `lambertRequestSchema` endraw  has a one-to-one render — the vec3 tuples become three inputs,  raw `stringEnum` endraw  becomes a select,  raw `emptyAsOptional` endraw  becomes

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  raw `CellOk` endraw  payload (Δv, TOF, solver iterations)

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.