<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[@railway-ts/pipelines]]></title><description><![CDATA[@railway-ts/pipelines]]></description><link>https://railway-ts-pipelines.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69a301640728161017ef7467/5ff526e6-3260-4e39-a550-80e53106cee9.png</url><title>@railway-ts/pipelines</title><link>https://railway-ts-pipelines.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 25 Jun 2026 16:56:31 GMT</lastBuildDate><atom:link href="https://railway-ts-pipelines.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Izzo's Lambert solver, in your browser]]></title><description><![CDATA[If you do mission design, you've solved Lambert's problem more times than you can count. You also know the tooling reality: GMAT, STK, MATLAB scripts your advisor wrote in 2007, PyKEP, poliastro, the ]]></description><link>https://railway-ts-pipelines.hashnode.dev/izzo-s-lambert-solver-in-your-browser</link><guid isPermaLink="true">https://railway-ts-pipelines.hashnode.dev/izzo-s-lambert-solver-in-your-browser</guid><category><![CDATA[orbital-mechanics  ]]></category><category><![CDATA[astrodynamics]]></category><category><![CDATA[Rust]]></category><category><![CDATA[Rust programming]]></category><category><![CDATA[WebAssembly]]></category><category><![CDATA[WebAssembly UI Development]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[scientific-computing]]></category><dc:creator><![CDATA[Sarkis M]]></dc:creator><pubDate>Sat, 09 May 2026 15:26:51 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a301640728161017ef7467/c5516eec-fc58-41d1-896b-7a018081d493.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p>If you do mission design, you've solved Lambert's problem more times than you can count. You also know the tooling reality: GMAT, STK, MATLAB scripts your advisor wrote in 2007, PyKEP, poliastro, the occasional Fortran routine that nobody wants to touch. All fine on a workstation. None of it useful when you want to put a porkchop plot in a web dashboard, run a quick TOF sweep in an Observable notebook, or build an interactive teaching tool that doesn't require a 4GB Python environment to load.</p>
<p>I built <a href="https://www.npmjs.com/package/lambert-izzo"><code>lambert-izzo</code></a> for that gap. It's a Rust implementation of <a href="https://arxiv.org/abs/1403.2705">Izzo's revisited algorithm</a> (CMDA, 2014), compiled to WebAssembly, shipped on npm, with TypeScript types generated straight out of the Rust types. The Rust crate (<a href="https://crates.io/crates/lambert_izzo"><code>lambert_izzo</code></a>) is the same code if you want it from a Rust binary or <code>no_std</code> embedded target.</p>
<p>This post is about why I made the choices I did and how to actually use it for trajectory work.</p>
<h2>What's in the box</h2>
<pre><code class="language-bash">npm install lambert-izzo
</code></pre>
<pre><code class="language-typescript">import init, { solveLambert } from "lambert-izzo";

await init();

const result = solveLambert({
  r1: [7000, 0, 0],         // km, any consistent inertial frame
  r2: [0, 7000, 0],
  tof: 1457,                // s — quarter-period of a 7000 km circular orbit
  mu: 398_600.4418,         // km³/s²
  way: "short",
  maxRevs: null,            // null = single-rev only; 1..=32 for multi-rev
});

if (result.kind === "ok") {
  const { v1, v2 } = result.response.single;
  const iters = result.response.diagnostics.single.iters;
  // ...
} else {
  // result.error.kind: "DegeneratePositionVector" | "CollinearGeometry" | ...
}
</code></pre>
<p>That's the whole API surface. One function (plus a batch variant for sweeps), one input type, one tagged-union output. The published 2.0.0 package is ~97 KB of wasm (42 KB gzipped) plus a small wasm-bindgen JS glue layer, and does no I/O — it's a pure computational kernel.</p>
<h2>Why "in your browser" is a real thing</h2>
<p>The argument I most expect to hear is: <em>why not just use</em> <a href="https://esa.github.io/pykep/"><em>PyKEP</em></a> <em>or</em> <a href="https://docs.poliastro.space/"><em>poliastro</em></a><em>?</em> You should, if your environment can host them. They're excellent. The point isn't to replace them — it's that "your environment can host them" is doing a lot of work in that sentence.</p>
<p>A few cases where it isn't true:</p>
<ul>
<li><p><strong>Interactive web tools.</strong> You want users to drop in <code>(r1, r2, tof)</code> and see a transfer ellipse. A WebAssembly module loads in milliseconds. A Python kernel doesn't load at all.</p>
</li>
<li><p><strong>Notebook frontends.</strong> Observable, Marimo's web mode, anything JS-native. A typed npm package is just an <code>import</code>.</p>
</li>
<li><p><strong>Teaching demos.</strong> Students click a button. They don't <code>pip install</code> their first orbital-mechanics homework.</p>
</li>
<li><p><strong>Visual mission-design sketches.</strong> You're prototyping a porkchop UI before committing to a backend. The iteration loop should be <code>npm run dev</code>, not deploying a Python service.</p>
</li>
</ul>
<p>If your trajectory pipeline already lives in Python, keep it there. This is for the cases where the JS side of the wall is where the work is actually happening.</p>
<h2>The algorithm, briefly</h2>
<p>For readers who haven't met Izzo's formulation: the solver works in a single dimensionless parameter <code>x ∈ (-∞, ∞)</code> (the Lancaster–Blanchard variable), where <code>x = 0</code> is a balanced ellipse, <code>x = ±1</code> is parabolic escape, and <code>|x| &gt; 1</code> is hyperbolic. Time of flight <code>T(x)</code> is smooth-ish, with a single root for any feasible TOF. Multi-rev branches add <code>Mπ</code> periods, splitting <code>T(x)</code> into pieces with their own minima — that's where the long-period and short-period branches per <code>M</code> come from.</p>
<p>The core loop is Householder's third-order method on <code>T(x) - tof = 0</code>, with analytic first/second/third derivatives (Izzo Eq. 22). For the multi-rev <code>T_min(M)</code> feasibility check, it's Halley's method on <code>dT/dx = 0</code>.</p>
<p>The numerical interesting bit is that <code>T(x)</code> is unstable near <code>x = 1</code>. The solver dispatches across three regimes depending on <code>|x − 1|</code>:</p>
<ul>
<li><p><strong>Battin's hypergeometric series</strong> (Izzo Eq. 20) for <code>|x − 1| ≤ 0.01</code>. Well-conditioned in the near-parabolic regime where Lagrange and Lancaster–Blanchard lose precision.</p>
</li>
<li><p><strong>Lancaster–Blanchard form</strong> (Izzo Eq. 18) for <code>0.01 &lt; |x − 1| ≤ 0.2</code>. Clean middle band.</p>
</li>
<li><p><strong>Lagrange form</strong> (Izzo Eq. 9) for <code>|x − 1| &gt; 0.2</code>. Stable far from parabolic.</p>
</li>
</ul>
<p>You don't pick the regime. The dispatcher does. The thresholds are tuned and constant-folded out at compile time.</p>
<p>If you want the full picture, the paper PDF is in the repo at <a href="https://github.com/sakobu/izzos-lambert/tree/main/docs"><code>docs/izzo.pdf</code></a> and there's a <code>concepts.md</code> next to it that walks through the Lancaster <code>x</code>, the three regimes, and what the multi-rev branch structure actually looks like.</p>
<h2>Multi-rev, properly</h2>
<p>Pass <code>maxRevs: 5</code> and you get every feasible <code>(long_period, short_period)</code> pair up to <code>M = 5</code>, in ascending <code>M</code> order:</p>
<pre><code class="language-typescript">const result = solveLambert({
  r1: [8000, 0, 0],
  r2: [5600, 5600, 0],
  tof: 5 * 7158,            // ~5 orbital periods at r ≈ 8000 km
  mu: 398_600.4418,
  way: "short",
  maxRevs: 5,
});

if (result.kind === "ok") {
  const branches = result.response.multi.flatMap((pair) =&gt; [
    { kind: "long" as const,  m: pair.nRevs, ...pair.longPeriod },
    { kind: "short" as const, m: pair.nRevs, ...pair.shortPeriod },
  ]);
  // 0–10 trajectories, depending on which branches were feasible
}
</code></pre>
<p>If <code>T_min(M) &gt; tof</code> for some <code>M</code>, that branch is silently dropped from the returned set — there's no solution to return, and faking one would be worse than honest absence. <code>response.multi.length</code> tells you how many came back; the highest feasible <code>M</code> is <code>response.multi.at(-1)?.nRevs</code>.</p>
<p>Out-of-range <code>maxRevs</code> (zero, or &gt; 32) is rejected as <code>RevsOutOfRange</code> <em>before</em> any solver work runs. The cap of 32 is a real architectural decision — the Rust core uses a stack-allocated <code>ArrayVec</code> so the entire solver does zero heap allocation on the hot path. You don't pay for an allocator on a porkchop sweep.</p>
<h2>Validation</h2>
<p>The Rust crate has a stress example that runs 100,000 random Earth-scale geometries each for single-rev and multi-rev (up to <code>M = 5</code>), checking vis-viva energy and angular-momentum conservation between the returned <code>(v1, v2)</code> pairs:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Convergence</th>
<th>Avg iters</th>
<th>Paper avg</th>
<th>Max iters</th>
<th>Max ΔE/E</th>
<th>Max ΔL/L</th>
</tr>
</thead>
<tbody><tr>
<td>Single-rev</td>
<td>100%</td>
<td>2.083</td>
<td>2.1</td>
<td>3</td>
<td>1.44e-12</td>
<td>4.14e-12</td>
</tr>
<tr>
<td>Multi-rev</td>
<td>100%</td>
<td>2.992</td>
<td>3.3</td>
<td>7</td>
<td>3.02e-14</td>
<td>5.63e-14</td>
</tr>
</tbody></table>
<p>Iteration counts match the paper's figures. Conservation residuals sit at f64 round-off, which is the right answer — there's no algorithmic error left to find at that precision.</p>
<p>There's also a Kepler round-trip test: take the returned <code>v1</code>, propagate forward by <code>tof</code> using a universal-variable Kepler propagator, check that you arrive at <code>r2</code>. Across 500 random multi-rev geometries, max relative position error is <code>&lt; 1e-5</code> per branch.</p>
<h2>Performance</h2>
<p>Numbers from the native Rust crate on an Apple Silicon laptop, release profile, except where noted. The wasm package is sequential — wasm threads are a deployment ordeal (cross-origin isolation headers, SharedArrayBuffer support) that I didn't want to bake into a default install.</p>
<table>
<thead>
<tr>
<th>Workload</th>
<th>Runtime</th>
<th>Throughput</th>
<th>Per call</th>
</tr>
</thead>
<tbody><tr>
<td>Single-rev (random Earth orbits)</td>
<td>native Rust</td>
<td>~3.1 M calls/s</td>
<td>~320 ns</td>
</tr>
<tr>
<td>Multi-rev <code>M=1</code></td>
<td>native Rust</td>
<td>~1.5 M calls/s</td>
<td>~650 ns</td>
</tr>
<tr>
<td>Multi-rev <code>M=5</code></td>
<td>native Rust</td>
<td>~520 K calls/s</td>
<td>~1.9 µs</td>
</tr>
<tr>
<td>Battin near-parabolic (177°, single-rev)</td>
<td>native Rust</td>
<td>~4.2 M calls/s</td>
<td>~240 ns</td>
</tr>
<tr>
<td>Parallel batch via <code>lambert_par</code></td>
<td>native Rust + rayon</td>
<td>~8.8 M calls/s</td>
<td>~114 ns</td>
</tr>
</tbody></table>
<p>The wasm path is single-threaded and runs through the v8/SpiderMonkey wasm engine, so expect a constant-factor slowdown vs native — typically 1.5–3× depending on browser. Several hundred thousand calls per second on a single thread is enough for a 200×200 porkchop in a couple of seconds.</p>
<p>For batch work from JS:</p>
<pre><code class="language-ts">import { solveLambertBatch } from "lambert-izzo";

const requests = tofValues.map((tof) =&gt; ({ r1, r2, tof, mu, way: "short" as const, maxRevs: null }));
const results = solveLambertBatch(requests);

const dvProfile = results
  .map((r, i) =&gt; ({ tof: tofValues[i], result: r }))
  .filter(({ result }) =&gt; result.kind === "ok")
  .map(({ tof, result }) =&gt; ({
    tof,
    dv: norm(sub(result.response.single.v1, vCircular)),
  }));
</code></pre>
<p><code>solveLambertBatch</code> takes an array, returns an array of the same length, preserves order, errors are per-element. No bulk-fail behavior.</p>
<h2>The error model is a tagged union, not exceptions</h2>
<p>The output is <code>{ kind: "ok"; response: ... } | { kind: "err"; error: ... }</code>. <code>solveLambert</code> and <code>solveLambertBatch</code> never throw on solver failure. The errors are themselves a discriminated union — switch on <code>error.kind</code>:</p>
<pre><code class="language-ts">const result = solveLambert(input);

if (result.kind === "err") {
  const { error } = result;
  switch (error.kind) {
    case "NonFiniteInput":
      // error.parameter: "R1X" | "R1Y" | "R1Z" | "R2X" | "R2Y" | "R2Z" | "Tof" | "Mu"
      // error.value:     the NaN / ±Infinity that came in
      break;
    case "NonPositiveTimeOfFlight":
      // error.tof: the rejected value (≤ 0)
      break;
    case "NonPositiveMu":
      // error.mu: the rejected value (≤ 0)
      break;
    case "DegeneratePositionVector":
      // error.position: "R1" | "R2"
      // error.norm:     magnitude of the offending vector (below 1e-15)
      break;
    case "CollinearGeometry":
      // error.sin_angle: |r1 × r2| / (|r1|·|r2|), below 1e-15.
      // Transfer plane is undefined — perturb one endpoint off-plane.
      break;
    case "NoConvergence":
      // error.iterations: Householder iters before giving up
      // error.last_step:  magnitude of the final |Δx|
      // error.n_revs:     0 = single-rev, ≥ 1 = multi-rev branch index
      break;
    case "SingularDenominator":
      // error.n_revs: branch index where the denominator went singular
      break;
    case "RevsOutOfRange":
      // error.requested, error.max — maxRevs outside [1, 32]
      break;
    case "Unknown":
      // error.message: upstream Display text. Surface verbatim.
      break;
  }
}
</code></pre>
<p>There's no string parsing anywhere. Every variant has structured fields. The <code>Unknown { message }</code> variant only fires if the upstream Rust crate adds a new error case the wasm adapter hasn't mirrored yet — exhaustive <code>switch</code> blocks should include it as a safety net.</p>
<p>The reason I'm bringing this up is that "throw on bad input" is the JS default, and it's the wrong default for a numerical kernel. A degenerate Lambert input isn't a runtime crash — it's a perfectly valid signal from the solver that says <em>this geometry has no transfer plane, fix your inputs</em>. Treating it as an exception conflates "the code is broken" with "the math doesn't apply here," and the caller pays for that conflation in <code>try</code>/<code>catch</code> noise.</p>
<h2>Frame invariance and units</h2>
<p>The solver doesn't know what frame you're in. Pass <code>r1</code> and <code>r2</code> in any consistent inertial frame (ECI, HCRS, MCI, or any other inertial frame) and the returned <code>v1</code> / <code>v2</code> come back in the same frame. There are no field names like <code>vEciX</code> or assumptions about J2000.</p>
<p>Same for units. The solver is dimensionally homogeneous. The docs and examples use km / s / km/s / km³/s² because that's what aerospace SI looks like, but if you want canonical units (<code>r</code> in DU, <code>tof</code> in TU, <code>mu = 1</code>), it works without modification. The solver does no unit conversion — that's your problem, and it's the right boundary.</p>
<h2>What this doesn't do</h2>
<p>This is the boundary-value step. It is <em>not</em> a mission-design framework. Specifically:</p>
<ul>
<li><p><strong>No patched conics.</strong> If you want SOI handoffs, you build that on top.</p>
</li>
<li><p><strong>No perturbations.</strong> Two-body only — no J2, no third-body, no SRP. Lambert is two-body by definition.</p>
</li>
<li><p><strong>No low-thrust.</strong> Δv is impulsive at the endpoints.</p>
</li>
<li><p><strong>No outer-loop optimization.</strong> Porkchop minima, primer-vector, multi-shooter convergence — those are things you call Lambert <em>from</em>, not things Lambert does for you.</p>
</li>
<li><p><strong>No ephemerides.</strong> You bring your own SPICE / Skyfield / Horizons output. The solver eats <code>[x, y, z]</code> triples; how you got them is your business.</p>
</li>
</ul>
<p>That scope is deliberate. The crate is the boundary-value step, period. Anything that creeps beyond that turns into a framework, and there are already good frameworks.</p>
<h2>A real example: Δv profile across TOF</h2>
<p>Here's the pattern I actually use it for — sweeping TOF for a fixed boundary condition and looking at where the Δv minimum sits:</p>
<pre><code class="language-typescript">import init, { solveLambertBatch } from "lambert-izzo";

await init();

const sub = (a: number[], b: number[]) =&gt; a.map((x, i) =&gt; x - b[i]);
const norm = (v: number[]) =&gt; Math.hypot(...v);

const r1 = [7000, 0, 0] as const;
const r2 = [-12_000, 1, 0] as const;     // 1 km off-axis to clear collinearity
const mu = 398_600.4418;

const vCirc1 = Math.sqrt(mu / norm(r1));
const vInitial = [0, vCirc1, 0];          // prograde from r1

const tofValues = Array.from({ length: 200 }, (_, i) =&gt; 1000 + i * 50);

const requests = tofValues.map((tof) =&gt; ({
  r1: [...r1], r2: [...r2], tof, mu, way: "short" as const, maxRevs: null,
}));

const profile = solveLambertBatch(requests).flatMap((result, i) =&gt; {
  if (result.kind !== "ok") return [];
  const { v1 } = result.response.single;
  return [{ tof: tofValues[i], dv1: norm(sub(v1, vInitial)) }];
});

const optimal = profile.reduce((a, b) =&gt; (a.dv1 &lt; b.dv1 ? a : b));
console.log(`Min Δv₁ = \({optimal.dv1.toFixed(3)} km/s at tof = \){optimal.tof} s`);
</code></pre>
<p>That's a 200-point sweep. On a recent laptop, the <code>solveLambertBatch</code> call is ~1 ms. You can put that behind a slider in a web UI and update on every drag event without thinking about it.</p>
<p>For a full porkchop, replace the 1D <code>tofValues</code> array with a 2D grid over departure date and arrival date, plug in ephemerides for <code>r1(t1)</code> and <code>r2(t2)</code> from your favorite source, and reduce over the grid. The math is identical.</p>
<h2>Links</h2>
<ul>
<li><p>npm: <a href="https://www.npmjs.com/package/lambert-izzo"><code>lambert-izzo</code></a></p>
</li>
<li><p>crates.io: <a href="https://crates.io/crates/lambert_izzo"><code>lambert_izzo</code></a></p>
</li>
<li><p>GitHub: <a href="https://github.com/sakobu/izzos-lambert"><code>sakobu/izzos-lambert</code></a> — paper PDF, concepts doc, architecture doc, all examples</p>
</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Astrodynamics Deserves Better Than MATLAB: Porting a JGCD Paper to TypeScript]]></title><description><![CDATA[Spacecraft proximity operations math is world-class. Papers in JGCD present elegant, validated formulations — analytical State Transition Matrices, density-model-free drag representations, quasi-nonsi]]></description><link>https://railway-ts-pipelines.hashnode.dev/astrodynamics-deserves-better-than-matlab-porting-a-jgcd-paper-to-typescript</link><guid isPermaLink="true">https://railway-ts-pipelines.hashnode.dev/astrodynamics-deserves-better-than-matlab-porting-a-jgcd-paper-to-typescript</guid><category><![CDATA[TypeScript]]></category><category><![CDATA[Physics]]></category><category><![CDATA[orbital-mechanics  ]]></category><category><![CDATA[astrodynamics]]></category><category><![CDATA[physics simulation]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Sarkis M]]></dc:creator><pubDate>Wed, 04 Mar 2026 19:21:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a301640728161017ef7467/5fc2b81f-bdc9-4eb6-b38c-0119d0496c00.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<hr />
<p>Spacecraft proximity operations math is world-class. Papers in <em>JGCD</em> present elegant, validated formulations — analytical State Transition Matrices, density-model-free drag representations, quasi-nonsingular element sets that avoid coordinate singularities. Decades of theoretical refinement.</p>
<p>The tooling is <code>plot3()</code> in a MATLAB figure window. Scripts with magic numbers. Variable names like <code>dd_lambda</code>. No types, no tests, no version control — collaboration by shared drive. Researchers publish validated math, then every downstream team reimplements the same papers, introduces the same bugs, builds the same terrible UIs.</p>
<p>I decided to bridge this for one specific domain. I took Koenig, Guffanti, and D'Amico's 2017 paper on State Transition Matrices for perturbed relative motion and implemented it as an idiomatic TypeScript library — pure functions, immutable types, framework-agnostic — with a React Three Fiber mission planner on top. Adam Koenig, the paper's lead author and a current colleague, has validated the implementation. This is a production-quality port of peer-reviewed math, not a weekend approximation.</p>
<p><a href="https://koenig-damico-roe-rpo.netlify.app/"><strong>Live demo</strong></a> · <a href="https://github.com/sakobu/koenig-guffanti-damico-roe-stm"><strong>Source</strong></a></p>
<hr />
<h2>Why Analytical STMs Enable Browser-Native Astrodynamics</h2>
<p>This paper was specifically chosen because its mathematical structure maps to a performance envelope that works in the browser.</p>
<p>The alternative — numerical integration of the equations of motion — requires hundreds of RK4/RK8 steps per propagation, each involving trigonometric evaluations. Wrap that in a Newton-Raphson targeting loop (which calls propagation per iteration, plus 6 calls per Jacobian evaluation via central differences), and wrap <em>that</em> in a golden section TOF optimizer (~20 evaluations), and you're looking at tens of thousands of trig calls per user interaction. Possible in JavaScript, but not at interactive framerates with real-time 3D rendering competing for the same thread.</p>
<p>Koenig et al.'s STMs reduce propagation to a matrix-vector multiply. The 6×6 J2 matrix costs 36 multiplies and 30 adds. The targeting solver converges in 5–15 iterations. The entire multi-waypoint planning pipeline — TOF optimization, targeting, trajectory generation — completes within a frame budget. This is what makes dragging a waypoint in 3D space and seeing the trajectory update in real time architecturally feasible, not just a demo trick.</p>
<p>Concretely: drag a waypoint 200 meters radially outward and the planner recomputes departure burns, coast arcs, and arrival burns in under a millisecond. That's not clever caching — that's analytical propagation being inherently fast enough for real-time interaction.</p>
<p>The paper's choice of Relative Orbital Elements over Cartesian state vectors further enables this. In ROE space, perturbations separate analytically — J2 appears as known secular drift rates, drag adds coupling terms with known structure — so each perturbation layer extends the STM without breaking the analytical form. Three fidelity levels (Keplerian 6×6, J2 6×6, J2+Drag 7×7/9×9), each embedding the previous as a subblock.</p>
<h2>The Translation Problem</h2>
<p>Porting a paper like this to TypeScript is a fundamentally different engineering challenge than implementing a well-documented algorithm from a textbook.</p>
<h3>Reading Depth</h3>
<p>You can implement quicksort from a description of the algorithm. You cannot implement a 9×9 drag State Transition Matrix from a description. You need to understand what each matrix element represents physically, because when your output is wrong — and it will be wrong, many times — the failure mode is a trajectory that curves subtly in the wrong direction, not a crash.</p>
<p>Debugging means knowing that row 3 of the STM corresponds to δex (the x-component of the relative eccentricity vector), that a sign error there reverses apsidal precession, and that this would manifest as the trajectory spiraling the wrong way over multiple orbits. The debugger tells you a number is wrong. The paper tells you <em>why</em> it's wrong.</p>
<h3>Notation Mismatch</h3>
<p>Academic papers optimize for typographic density. A single equation might contain subscripts, superscripts, overlines, dots (time derivatives), and Greek letters that each carry specific meaning within the paper's convention system. Mapping this to code requires decisions at every level:</p>
<ul>
<li><p>The paper's <code>Ω̇</code> (RAAN rate due to J2) becomes <code>raanDrift</code> — but is it rad/s or rad/orbit?</p>
</li>
<li><p>The orbital factor η appears as <code>sqrt(1 - e²)</code> in some papers and <code>(1 - e²)</code> in others. Which convention does <em>this</em> paper use, and did you get it right in all 36 elements of the J2 matrix?</p>
</li>
<li><p>The drag model's "density-model-free" parameterization means <code>da/dt</code> isn't atmospheric-density-times-ballistic-coefficient — it's a direct rate input. Every consumer of that value needs to understand this distinction.</p>
</li>
</ul>
<p>Here's what that notation mismatch looks like resolved — the J2 STM translated into TypeScript:</p>
<pre><code class="language-typescript">// From src/orbital/stm/j2.ts — the J2 STM, Equation A6
// Orbital factors (kappa, E, F, G, P, Q, S, T) are precomputed
// from eccentricity, inclination, and J2

return [
  // Row 1: delta-a is constant (no J2 secular effect on semi-major axis)
  [1, 0, 0, 0, 0, 0],

  // Row 2: delta-lambda evolution (Keplerian drift + J2 corrections)
  [
    -(1.5 * n + 3.5 * kappa * E * P) * tau,
    1,
    kappa * ex_i * F * G * P * tau,
    kappa * ey_i * F * G * P * tau,
    -kappa * F * S * tau,
    0,
  ],

  // Row 3: delta-ex evolution (apsidal precession rotates eccentricity vector)
  [
    3.5 * kappa * ey_f * Q * tau,
    0,
    cos_wt - 4 * kappa * ex_i * ey_f * G * Q * tau,
    -sin_wt - 4 * kappa * ey_i * ey_f * G * Q * tau,
    5 * kappa * ey_f * S * tau,
    0,
  ],

  // Row 4: delta-ey evolution (apsidal precession, conjugate to row 3)
  [
    -3.5 * kappa * ex_f * Q * tau,
    0,
    sin_wt + 4 * kappa * ex_i * ex_f * G * Q * tau,
    cos_wt + 4 * kappa * ey_i * ex_f * G * Q * tau,
    -5 * kappa * ex_f * S * tau,
    0,
  ],

  // Row 5: delta-ix is constant
  [0, 0, 0, 0, 1, 0],

  // Row 6: delta-iy evolution (nodal regression)
  [3.5 * kappa * S * tau, 0, -4 * kappa * ex_i * G * S * tau,
   -4 * kappa * ey_i * G * S * tau, 2 * kappa * T * tau, 1],
];
</code></pre>
<p>Each element is a product of orbital factors and time — no numerical integration, no timestep loops, just algebra.</p>
<h3>The Validation Problem</h3>
<p>This is the most dangerous part of porting a paper, and the part that gets the least attention.</p>
<p>Getting a matrix "mostly right" is worse than getting it obviously wrong. A 6×6 STM with one wrong element out of 36 will often produce trajectories that <em>converge</em> — the Newton-Raphson solver is robust enough to compensate — but the trajectories will be physically wrong. The solver finds <em>a</em> minimum. It's just not <em>the</em> minimum. You get a smooth, plausible-looking trajectory to the wrong destination. No error, no divergence, no warning.</p>
<p>This is fundamentally different from most software bugs. A wrong index throws. A null pointer crashes. A wrong sign in element (3,4) of a State Transition Matrix gives you a spacecraft that calmly spirals in the wrong direction over six orbits. You have to know enough physics to look at the output and feel that something is off.</p>
<p>The validation approach: implement the same dynamics as an RK4 numerical integrator. Propagate a state using both the analytical STM and the brute-force integrator. They should agree within numerical tolerance. This was done in the original Bun implementation with full test coverage. Adam Koenig's direct review of the matrix implementations provides a second, independent verification path — the author checking the port against his own math.</p>
<h2>Library Architecture</h2>
<p>The orbital library (<code>src/orbital/</code>) is ~4,800 lines of framework-agnostic TypeScript. Some deliberate architectural choices:</p>
<h3>Pure Functions, Immutable Types</h3>
<p>Every public function is a pure transform. No internal state, no side effects. The core types use <code>readonly</code> on every field:</p>
<pre><code class="language-typescript">export type ManeuverLeg = {
  readonly from: Vector3;
  readonly to: Vector3;
  readonly targetVelocity: Vector3;
  readonly tof: number;
  readonly burn1: Maneuver;
  readonly burn2: Maneuver;
  readonly totalDeltaV: number;
  readonly converged: boolean;
  readonly iterations: number;
  readonly positionError: number;
};
</code></pre>
<p>This isn't just style. When trajectory data flows from the solver → the store → the 3D renderer → the HUD → the export module, immutability means you can reason about correctness locally. A component receiving a <code>ManeuverLeg</code> knows it won't change out from under it. In MATLAB, this guarantee doesn't exist — any function can mutate any workspace variable, and the resulting bugs are insidious.</p>
<h3>Hand-Rolled Linear Algebra</h3>
<p>The library implements its own 3×3 and 6×6 matrix operations (~350 lines). Deliberate tradeoff — pulling in a full LA library for structured small-matrix operations adds dependency weight without meaningful benefit when the matrix dimensions are compile-time constants. The 9×9 drag STMs push the boundary of where this flips, but the operations are still structured enough (block matrix embedding) that generic LA isn't needed yet.</p>
<h3>Solver Robustness</h3>
<p>The targeting solver (Newton-Raphson with numerical Jacobian) uses a Clohessy-Wiltshire initial guess from the linearized Hill equations, adaptive damping in early iterations to stay in the convergence basin, and a singular Jacobian fallback for degenerate geometries where specific burn directions become ineffective. None of this is sophisticated — it emerged empirically from testing across the full scenario range. Convergence to 0.1 meter accuracy in 5–15 iterations is typical for formations from 100m to 10km.</p>
<h2>Frontend Integration</h2>
<p>The React layer consumes the orbital library through a service module that maps Zustand store actions to library calls.</p>
<p><strong>Three stores with cross-store subscriptions.</strong> Mission state, simulation state, and UI state live in separate Zustand stores, with a sync layer (<code>sync.ts</code>) that bridges them — resetting the simulation when trajectory data changes. The mission store uses <code>subscribeWithSelector</code> so the 3D scene re-renders on trajectory changes, not sidebar toggles. This separation matters because mission recomputation is the expensive path, and you don't want it coupled to UI state transitions.</p>
<p><strong>Incremental replanning.</strong> When a waypoint is dragged, only downstream legs are recomputed. The planner preserves converged legs before the modification point and resolves forward from the boundary state. This keeps multi-waypoint interactions fluid.</p>
<p><strong>Synchronous main-thread solver.</strong> No Web Workers. For matrix multiplies at this scale, the serialization overhead of structured clone likely exceeds the computation cost. If this ever needs to move off the main thread, WASM is the more promising path — it avoids the serialization penalty entirely.</p>
<h2>The Math Is Public. The UX Doesn't Have to Be 2005.</h2>
<p>TypeScript is a credible platform for this class of problem. Types catch the bugs MATLAB can't detect. Browser deployment eliminates installation friction. Analytical formulations like Koenig et al.'s STMs provide the performance budget for real-time interactivity. The modern frontend ecosystem makes building rich 3D tools surprisingly tractable.</p>
<p>The barrier is no longer computational. It's architectural. The math is published. The papers are public. What's been missing is the software engineering to make it accessible.</p>
<p>The orbital library is designed to be consumed independently of the React app. If you're building relative motion analysis tools, formation flying simulators, or mission planning interfaces — this might save you from the MATLAB-script-on-a-shared-drive approach.</p>
<p><a href="https://github.com/sakobu/koenig-guffanti-damico-roe-stm"><strong>github.com/sakobu/koenig-guffanti-damico-roe-stm</strong></a></p>
<hr />
<p><em>What papers are you sitting on that could use a real implementation? If you've ported peer-reviewed math to production code, I'd like to hear what worked — and what the paper didn't prepare you for.</em></p>
]]></content:encoded></item><item><title><![CDATA[Schema-First React Forms: @railway-ts/use-form]]></title><description><![CDATA[Every React form I build needs a schema library, a form library, a resolver to connect them, and an adapter for the server response. That's four layers before I've validated a single field.
Pick a sch]]></description><link>https://railway-ts-pipelines.hashnode.dev/schema-first-react-forms-railway-ts-use-form</link><guid isPermaLink="true">https://railway-ts-pipelines.hashnode.dev/schema-first-react-forms-railway-ts-use-form</guid><category><![CDATA[React]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[forms]]></category><category><![CDATA[form validation]]></category><category><![CDATA[Validation]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Sarkis M]]></dc:creator><pubDate>Sun, 01 Mar 2026 06:53:48 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a301640728161017ef7467/850c98ab-204d-451b-b04b-e309c2e16ab3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every React form I build needs a schema library, a form library, a resolver to connect them, and an adapter for the server response. That's four layers before I've validated a single field.</p>
<p>Pick a schema library. Pick a form library. Install a resolver to connect them. Thread <code>errors.fieldName?.message</code> through JSX. Call <code>setError</code> and <code>clearErrors</code> for server responses. Coordinate async field checks with schema validation. Realize your backend's error format doesn't match your form library. Write an adapter. Maintain the adapter.</p>
<p>Somewhere along the way, you start mass-producing translation layers instead of building product.</p>
<p><code>@railway-ts/use-form</code> exists because I got tired of translating error formats across the stack.</p>
<p>The core idea is simple:</p>
<p><strong>One error format, from schema to server to field — with nothing in between.</strong></p>
<p>The same schema validates the frontend form and the backend request. The error shape produced by the server is the exact shape the form hook consumes. No resolver. No adapter. No format conversion. One data contract across the entire stack.</p>
<p>And because the hook implements <a href="https://standardschema.dev/">Standard Schema v1</a>, you don't need to adopt <code>@railway-ts/pipelines</code> to use it. Zod, Valibot, ArkType — anything that speaks the spec works immediately.</p>
<hr />
<h2>The Pitch in 30 Seconds</h2>
<pre><code class="language-typescript">import * as S from "@railway-ts/pipelines/schema";
import { useForm } from "@railway-ts/use-form";

const schema = S.object({
  email: S.required(S.chain(S.string(), S.nonEmpty("Required"), S.email())),
  password: S.required(S.chain(S.string(), S.minLength(8, "Min 8 chars"))),
});

type Login = S.InferSchemaType&lt;typeof schema&gt;;

function LoginForm() {
  const form = useForm&lt;Login&gt;(schema, {
    initialValues: { email: "", password: "" },
    onSubmit: async (values) =&gt; { /* send to API */ },
  });

  return (
    &lt;form onSubmit={(e) =&gt; void form.handleSubmit(e)}&gt;
      &lt;input {...form.getFieldProps("email")} /&gt;
      {form.getFieldError("email") &amp;&amp; &lt;span&gt;{form.getFieldError("email")}&lt;/span&gt;}

      &lt;input type="password" {...form.getFieldProps("password")} /&gt;
      {form.getFieldError("password") &amp;&amp; &lt;span&gt;{form.getFieldError("password")}&lt;/span&gt;}

      &lt;button type="submit" disabled={form.isSubmitting}&gt;Log In&lt;/button&gt;
    &lt;/form&gt;
  );
}
</code></pre>
<p>That's a complete, type-safe, validated form. <code>getFieldProps("emal")</code> is a TypeScript error. <code>form.errors</code> is fully typed. The schema is the single source of truth.</p>
<hr />
<h2>Standard Schema: Use Whatever You Want</h2>
<p>Standard Schema is a small TypeScript interface created by the authors of Zod, Valibot, and ArkType. It's not a validator — it's a contract. If a schema exposes a <code>~standard</code> property with a <code>validate</code> method, <code>useForm</code> can consume it.</p>
<p>Your schema library is a local decision, not a stack-wide commitment.</p>
<h3>With Zod</h3>
<pre><code class="language-typescript">import { z } from "zod";
import { useForm } from "@railway-ts/use-form";

const schema = z.object({
  username: z.string().min(3, "Too short"),
  email: z.email("Invalid email"),
  password: z.string().min(8),
});

type User = z.infer&lt;typeof schema&gt;;

const form = useForm&lt;User&gt;(schema, {
  initialValues: { username: "", email: "", password: "" },
  onSubmit: (values) =&gt; console.log(values),
});
</code></pre>
<p>No <code>zodResolver</code>. No adapter layer. Full <code>useForm</code> API: <code>getFieldProps</code>, <code>arrayHelpers</code>, <code>fieldValidators</code>, <code>setServerErrors</code>, error prioritization — all available.</p>
<h3>With @railway-ts/pipelines</h3>
<p>The native schema unlocks additional capabilities: targeted cross-field validation, conditional validation, and Railway-oriented <code>Result</code> types — and it enables the full-stack symmetry covered later.</p>
<pre><code class="language-typescript">import * as S from "@railway-ts/pipelines/schema";

const schema = S.chain(
  S.object({
    password: S.required(S.chain(S.string(), S.minLength(8))),
    confirmPassword: S.required(S.chain(S.string(), S.nonEmpty("Required"))),
  }),
  S.refineAt(
    "confirmPassword",
    (d) =&gt; d.password === d.confirmPassword,
    "Passwords must match",
  ),
);
</code></pre>
<p><code>S.refineAt</code> runs a cross-field check but attaches the error to a specific field. With most schema libraries, object-level refinements typically require manually mapping errors back to individual fields. Here, placement is explicit.</p>
<hr />
<h2>Three Error Layers, Deterministic Priority</h2>
<p>Every form has three error sources: schema validation ("invalid email"), async field checks ("username taken"), and server responses ("email already registered"). Most form libraries merge them into a single bucket and let call order decide what the user sees.</p>
<p><code>useForm</code> makes this deterministic:</p>
<table>
<thead>
<tr>
<th>Priority</th>
<th>Source</th>
<th>Clears when</th>
</tr>
</thead>
<tbody><tr>
<td>1 (lowest)</td>
<td>Schema validation</td>
<td>Next validation run</td>
</tr>
<tr>
<td>2</td>
<td>Async field validator</td>
<td>Field re-validates</td>
</tr>
<tr>
<td>3 (highest)</td>
<td>Server error</td>
<td>User edits the field</td>
</tr>
</tbody></table>
<p>Higher priority wins. Server errors override schema errors. Async checks override sync validation. Editing a field clears its server error and hands control back to validation.</p>
<p>Component code doesn't manage this. It just reads <code>form.getFieldError("email")</code> and displays whatever won.</p>
<p>Here's all three layers wired up in a single form:</p>
<pre><code class="language-typescript">const form = useForm&lt;Registration&gt;(schema, {
  initialValues: { username: "", email: "", password: "", confirmPassword: "" },

  // Layer 2: async per-field validators
  fieldValidators: {
    username: async (value) =&gt; {
      if (value.length &lt; 3) return; // skip if sync already fails
      const res = await fetch(`/api/check-username?u=${encodeURIComponent(value)}`);
      const { available } = await res.json();
      return available ? undefined : `"${value}" is already taken`;
    },
  },

  // Layer 3: server errors on submit
  onSubmit: async (values) =&gt; {
    const res = await fetch("/api/register", {
      method: "POST",
      body: JSON.stringify(values),
    });
    if (!res.ok) {
      form.setServerErrors(await res.json());
      // { email: "Email already registered" }
      // Shows up on the email field. Clears when the user edits it.
    }
  },
});
</code></pre>
<p>The <code>fieldValidators</code> key is typed — it only accepts field names from your schema type.</p>
<h3>Form-Level Errors</h3>
<p>Not every error belongs to a field. Network failures and global server rejections are handled via <code>ROOT_ERROR_KEY</code>:</p>
<pre><code class="language-typescript">form.setServerErrors({
  email: "Already registered",
  [ROOT_ERROR_KEY]: "Registration failed. Please fix the errors below.",
});
</code></pre>
<p>One error contract, including form-level state.</p>
<hr />
<h2>The Full-Stack Payoff</h2>
<p>This is where the thesis pays off.</p>
<p>The same schema drives the frontend form and the backend validation pipeline. The error format produced by <code>formatErrors</code>on the server is the exact <code>Record&lt;string, string&gt;</code> that <code>setServerErrors</code> expects on the client. No conversion layer. No field name mapping.</p>
<p><strong>Shared schema:</strong></p>
<pre><code class="language-typescript">// shared/schema.ts
import * as S from "@railway-ts/pipelines/schema";

export const registrationSchema = S.chain(
  S.object({
    username: S.required(S.chain(S.string(), S.nonEmpty(),     S.minLength(3))),
    email: S.required(S.chain(S.string(), S.nonEmpty(), S.email())),
    password: S.required(S.chain(S.string(), S.nonEmpty(), S.minLength(8))),
    confirmPassword: S.required(S.chain(S.string(), S.nonEmpty())),
  }),
  S.refineAt("confirmPassword", (d) =&gt; d.password === d.confirmPassword, "Passwords must match"),
);

export type Registration = S.InferSchemaType&lt;typeof registrationSchema&gt;;
</code></pre>
<p><strong>Server:</strong></p>
<pre><code class="language-typescript">import { validate, formatErrors } from "@railway-ts/pipelines/schema";
import { pipeAsync } from "@railway-ts/pipelines/composition";
import { ok, err, flatMapWith, match } from "@railway-ts/pipelines/result";
import { registrationSchema, type Registration } from "../shared/schema";

const checkEmailUnique = async (data: Registration) =&gt; {
  const exists = await db.user.findUnique({ where: { email: data.email } });
  return exists
    ? err([{ path: ["email"], message: "Email already registered" }])
    : ok(data);
};

const createUser = async (data: Registration) =&gt; {
  const user = await db.user.create({
    data: {
      username: data.username,
      email: data.email,
      password: await hash(data.password),
      age: data.age,
    },
  });
  return ok(user);
};

const handleRegistration = async (body: unknown) =&gt; {
  const result = await pipeAsync(
    validate(body, registrationSchema),
    flatMapWith(checkEmailUnique),
    flatMapWith(createUser),
  );

  return match(result, {
    ok: (user) =&gt; ({ status: 201, body: { id: user.id } }),
    err: (errors) =&gt; ({ status: 422, body: formatErrors(errors) }),
  });
};
</code></pre>
<p><strong>Client:</strong></p>
<pre><code class="language-typescript">onSubmit: async (values) =&gt; {
  const res = await fetch("/api/register", {
    method: "POST",
    body: JSON.stringify(values),
  });
  if (!res.ok) {
    form.setServerErrors(await res.json());
    // Server returned { email: "Email already registered" }
    // It shows up on the email field. Done.
  }
}
</code></pre>
<p>Follow the data: <code>validate()</code> returns <code>Result&lt;Registration, ValidationError[]&gt;</code>. If it passes, <code>checkEmailUnique</code> runs. If that passes, <code>createUser</code> runs. <code>match</code> branches once at the end. <code>formatErrors</code> converts <code>ValidationError[]</code> to <code>Record&lt;string, string&gt;</code>. The frontend calls <code>setServerErrors</code> and the error appears on the correct field.</p>
<p>Same schema. Same types. Same error format. No translation layer anywhere.</p>
<hr />
<h2>Conditional Validation</h2>
<p>Fields that only matter under certain conditions:</p>
<pre><code class="language-typescript">const eventSchema = S.chain(
  S.object({
    title: S.required(S.chain(S.string(), S.nonEmpty())),
    isVirtual: S.optional(S.boolean()),
    platform: S.optional(S.string()),
    meetingUrl: S.emptyAsOptional(S.chain(S.string(), S.url())),
  }),
  S.when(
    (data) =&gt; !!data.isVirtual,
    S.chain(
      S.refineAt("platform", (d) =&gt; !!d.platform, "Platform required for virtual events"),
      S.refineAt("meetingUrl", (d) =&gt; !!d.meetingUrl, "URL required for virtual events"),
    ),
  ),
);
</code></pre>
<p><code>S.when</code> applies validation conditionally. When <code>isVirtual</code> is false, <code>platform</code> and <code>meetingUrl</code> are ignored. When it's true, both become required — with errors on the right fields.</p>
<p>Combined with <code>onFieldChange</code>, dependent fields clear automatically:</p>
<pre><code class="language-typescript">const form = useForm(eventSchema, {
  initialValues: { title: "", isVirtual: false, platform: "", meetingUrl: "" },
  onFieldChange: (field, value) =&gt; {
    if (field === "isVirtual" &amp;&amp; !value) {
      form.setFieldValue("platform", "", false);
      form.setFieldValue("meetingUrl", "", false);
    }
  },
});
</code></pre>
<hr />
<h2>Arrays, Modes, and Results</h2>
<p>Dynamic lists and checkbox groups are type-safe through <code>arrayHelpers</code>:</p>
<pre><code class="language-typescript">const helpers = form.arrayHelpers("attendees");

helpers.push({ name: "", email: "", role: "" });
helpers.remove(2);
helpers.swap(0, 1);

// Per-item field binding and errors
&lt;input {...helpers.getFieldProps(index, "name")} /&gt;
{helpers.getFieldError(index, "name") &amp;&amp; &lt;span&gt;{helpers.getFieldError(index, "name")}&lt;/span&gt;}
</code></pre>
<p>Validation timing is configurable via <code>validationMode</code> — <code>live</code>, <code>blur</code>, <code>mount</code>, or <code>submit</code>.</p>
<p>If you omit <code>onSubmit</code>, <code>handleSubmit()</code> returns a Railway-style <code>Result</code>, so submission can be pattern-matched instead of guarded with flags:</p>
<pre><code class="language-typescript">const result = await form.handleSubmit();

R.match(result, {
  ok: (values) =&gt; console.log("Valid:", values),
  err: (errors) =&gt; console.log("Errors:", errors),
});
</code></pre>
<p>The API stays consistent because the underlying contract stays consistent.</p>
<hr />
<h2>On Performance</h2>
<p><code>useForm</code> uses controlled inputs. Every keystroke updates React state. That's intentional: form state lives in React, not in a parallel ref system.</p>
<p>Uncontrolled approaches (like react-hook-form) avoid re-renders and can be faster for very large forms or live-preview editors. This library optimizes for architectural clarity and stack symmetry over micro-optimizing keystrokes.</p>
<p>For typical product forms — login, registration, CRUD under a few dozen fields — both approaches feel instant.</p>
<h2>On Size</h2>
<p><code>@railway-ts/use-form</code> is ~3.6 kB gzip. <code>@railway-ts/pipelines</code> is ~4.8 kB gzip. A typical Zod + react-hook-form + resolver setup is significantly larger depending on configuration. If you're already using pipelines on the backend, the form hook is the only marginal cost.</p>
<hr />
<h2>Get Started</h2>
<p><strong>Already using Zod or Valibot?</strong> Install the hook and pass your existing schema directly to <code>useForm</code>. No resolver. No adapter. Full API immediately.</p>
<pre><code class="language-bash">npm install @railway-ts/use-form @railway-ts/pipelines
</code></pre>
<p><strong>Want full-stack validation symmetry?</strong> Use <code>@railway-ts/pipelines</code> as your schema library and share it across frontend and backend.</p>
<ul>
<li><p><a href="https://stackblitz.com/edit/vitejs-vite-c3zpmon9?file=src%2FApp.tsx"><strong>Live Demo</strong> (StackBlitz)</a> — five tabs, progressively complex</p>
</li>
<li><p><a href="https://www.npmjs.com/package/@railway-ts/use-form"><strong>npm</strong></a></p>
</li>
<li><p><a href="https://github.com/sakobu/railway-ts-use-form"><strong>GitHub</strong></a></p>
</li>
<li><p><a href="https://github.com/sakobu/railway-ts-use-form/blob/main/GETTING_STARTED.md"><strong>Getting Started Guide</strong></a></p>
</li>
<li><p><a href="https://github.com/sakobu/railway-ts-use-form/blob/main/docs/API.md"><strong>API Reference</strong></a></p>
</li>
</ul>
<hr />
<p>If you're tired of writing adapters between your schema, your server, and your form layer — this is built for you.</p>
<p>Forms aren't a UI problem. They're a data contract problem.</p>
<p>This library treats them that way.</p>
]]></content:encoded></item><item><title><![CDATA[Typed Errors in TypeScript: The Options, the Tradeoffs, and the Missing Middle]]></title><description><![CDATA[When a function throws, the error channel disappears from the type system. The caller gets unknown in a catch block. There's no equivalent to Java's throws clause. No way for the compiler to say:

Thi]]></description><link>https://railway-ts-pipelines.hashnode.dev/typed-errors-typescript-missing-middle</link><guid isPermaLink="true">https://railway-ts-pipelines.hashnode.dev/typed-errors-typescript-missing-middle</guid><category><![CDATA[Functional Programming]]></category><category><![CDATA[effect-ts]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[etl-pipeline]]></category><category><![CDATA[error handling]]></category><category><![CDATA[railway-oriented-programming]]></category><dc:creator><![CDATA[Sarkis M]]></dc:creator><pubDate>Sun, 01 Mar 2026 00:01:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/69a301640728161017ef7467/10a40df6-4f21-4d43-abba-41486f51d79f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When a function throws, the error channel disappears from the type system. The caller gets <code>unknown</code> in a <code>catch</code> block. There's no equivalent to Java's <code>throws</code> clause. No way for the compiler to say:</p>
<blockquote>
<p>This function can fail in these specific ways.</p>
</blockquote>
<p>That's deliberate. Anders Hejlsberg has <a href="https://www.artima.com/articles/the-trouble-with-checked-exceptions">explained why</a> checked exceptions create coupling and refactoring pain. TypeScript chose not to encode failure into the type system.</p>
<p>That tradeoff works until you start building pipelines.</p>
<p>Validation. Normalization. Business rules. External lookups. Enrichment.</p>
<p>At every stage, the type system tracks what flows forward on success.</p>
<p>On failure, it goes dark.</p>
<p>The error channel becomes invisible.</p>
<p>This article walks through the different ways to make that channel visible in TypeScript, what each approach costs, and where the library I built fits on that spectrum.</p>
<h2>The Status Quo: Throw and Catch</h2>
<p>Here's a typical pipeline:</p>
<pre><code class="language-typescript">async function processTransaction(raw: unknown):Promise&lt;ProcessedTransaction&gt; {
  const validated = validateTransaction(raw);
  const normalized = normalizeTransaction(validated);
  assertBusinessRules(normalized);

  try {
    return await enrichWithCustomerData(normalized);
  } catch {
    return makePartialResult(normalized);
  }
}
</code></pre>
<p>This works.</p>
<p>It's readable. It's idiomatic. Most production TypeScript looks like this.</p>
<p>The friction shows up later.</p>
<h3>1) The call site can't see failure modes</h3>
<p>The signature says:</p>
<p><code>Promise&lt;ProcessedTransaction&gt;</code></p>
<p>It does not say:</p>
<ul>
<li><p>This might throw <code>ValidationError</code></p>
</li>
<li><p>Or <code>BusinessRuleError</code></p>
</li>
<li><p>Or an enrichment failure</p>
</li>
</ul>
<p>The only way to distinguish them is runtime narrowing on <code>unknown</code>. The type system can't help. If another team owns the caller, they must read your implementation to understand what can go wrong.</p>
<p>The type signature doesn't describe the failure surface.</p>
<h3>2) Batch processing collapses error structure</h3>
<p>Wrap this in a loop and every failure becomes "something threw." You lose the distinction between validation failures and business rule violations unless you manually re-encode it.</p>
<p>Switching from "keep the valid ones" to "fail the whole batch" means rewriting control flow, not flipping a strategy.</p>
<h3>3) Recovery logic is invisible</h3>
<p>This line:</p>
<pre><code class="language-typescript">return makePartialResult(normalized);
</code></pre>
<p>is a business decision inside a <code>catch</code>. The caller sees only <code>ProcessedTransaction</code>. Whether it's partial or complete isn't reflected in the type.</p>
<p>None of this is broken.</p>
<p>But in multi-stage pipelines, especially batch systems, the cost becomes visible.</p>
<h2>Level 1: Discriminated Unions (Zero Dependencies)</h2>
<p>The simplest typed-error approach is one you already know:</p>
<pre><code class="language-typescript">type ProcessResult =
  | { type: "success"; data: EnrichedTransaction }
  | { type: "validation_error"; errors: string[] }
  | { type: "business_rule"; reason: string }
  | { type: "partial"; data: PartialTransaction };

async function processTransaction(raw: unknown): Promise&lt;ProcessResult&gt; {
  const validation = validateTransaction(raw);
  if (validation.type === "validation_error") return validation;

  const normalized = normalizeTransaction(validation.data);

  const ruleCheck = checkBusinessRules(normalized);
  if (ruleCheck.type === "business_rule") return ruleCheck;

  const enrichment = await enrichWithCustomerData(normalized);
  if (enrichment.type === "success") return enrichment;

  return { type: "partial", data: makePartialResult(normalized) };
}
</code></pre>
<p>This is a real improvement.</p>
<p>The error channel is visible in the type. The compiler forces narrowing. There's no dependency and every TypeScript developer understands it.</p>
<p>The tradeoffs:</p>
<ul>
<li><p>The union is per-pipeline, not per-stage.</p>
</li>
<li><p>Composition is manual (<code>if (...) return ...</code> everywhere).</p>
</li>
<li><p>Batch semantics are DIY per union type.</p>
</li>
</ul>
<p>For one or two pipelines, this is probably the right solution.</p>
<p>When you have five pipelines and each grows to six stages, the repetition starts to show.</p>
<h2>Level 2: A Standardized Result Type (<code>neverthrow</code>)</h2>
<p><a href="https://github.com/supermacro/neverthrow">neverthrow</a> standardizes the discriminated union into <code>Result&lt;T, E&gt;</code> with helpers like <code>map</code>, <code>andThen</code>, and <code>match</code>:</p>
<pre><code class="language-typescript">import { ok, err } from "neverthrow";

const result = validateRaw(input)
  .andThen(normalize)
  .andThen(applyBusinessRules);
</code></pre>
<p>This removes the short-circuit boilerplate. Errors propagate automatically.</p>
<p><code>neverthrow</code> gives you:</p>
<ul>
<li><p>A consistent <code>Result</code> abstraction</p>
</li>
<li><p>Clean chaining</p>
</li>
<li><p>A small conceptual footprint</p>
</li>
</ul>
<p>What it intentionally does not give you:</p>
<ul>
<li><p>A pipeline composition model</p>
</li>
<li><p>Batch semantics across arrays of results</p>
</li>
<li><p>A schema layer</p>
</li>
<li><p>Cross-cutting tracing hooks</p>
</li>
</ul>
<p><code>neverthrow</code> is a <code>Result</code> type.</p>
<p>It standardizes how one function communicates success or failure.</p>
<p>It does not define how functions compose into reusable pipeline values.</p>
<p>That distinction matters once pipelines become architectural patterns instead of one-offs.</p>
<h2>Level 3: Result + Option + Composition + Schema (<code>@railway-ts/pipelines</code>)</h2>
<p>This is the missing middle I kept running into.</p>
<p><a href="https://github.com/sakobu/railway-ts-pipelines">@railway-ts/pipelines</a> keeps the same <code>Result&lt;T, E&gt;</code> idea but adds:</p>
<ul>
<li><p>Point-free composition (<code>flow</code>, <code>flowAsync</code>)</p>
</li>
<li><p>Batch semantics (<code>partition</code>, <code>combine</code>, <code>combineAll</code>)</p>
</li>
<li><p>An <code>Option</code> type</p>
</li>
<li><p>A schema module integrated directly into the error track</p>
</li>
</ul>
<p>The same pipeline becomes:</p>
<pre><code class="language-typescript">import { flowAsync } from "@railway-ts/pipelines/composition";
import { ok, err, flatMapWith, orElseWith } from "@railway-ts/pipelines/result";

const processTransaction = flowAsync(
  validateRaw,
  flatMapWith(normalize),
  flatMapWith(applyBusinessRules),
  flatMapWith(enrichWithCustomerData),
  orElseWith(recoverPartial),
);
</code></pre>
<p>Instead of chaining off a starting value, <code>flowAsync</code> produces a pipeline as a first-class function.</p>
<p>That means you can:</p>
<ul>
<li><p>Pass it around</p>
</li>
<li><p>Wrap it with tracing</p>
</li>
<li><p>Test it independently</p>
</li>
<li><p>Swap recovery strategies</p>
</li>
</ul>
<p>The pipeline itself becomes a value.</p>
<h2>Batch Semantics as a Strategy</h2>
<p>Given:</p>
<pre><code class="language-typescript">const results = await Promise.all(records.map((r) =&gt; processTransaction(r)));
</code></pre>
<p>You can choose interpretation:</p>
<pre><code class="language-typescript">import { partition, combine, combineAll } from "@railway-ts/pipelines/result";

const { successes, failures } = partition(results);
const allOrNothing = combine(results);
const allErrors = combineAll(results);
</code></pre>
<p>Switching behavior is a one-line change. No control-flow rewrite.</p>
<h2>Why Schema Belongs Here</h2>
<p>If the goal is to make the error channel visible, validation is the first place it appears.</p>
<p>Most pipelines start by turning <code>unknown</code> into typed data. That transformation produces:</p>
<ul>
<li><p>A parsed value</p>
</li>
<li><p>Structured validation errors</p>
</li>
</ul>
<p>If validation lives outside your error abstraction, the model breaks immediately.</p>
<p>That's why the schema module is included—not to compete with standalone validators, but to ensure validation failures share the same typed error track as business rule and enrichment failures.</p>
<p>The module is benchmarked on <a href="http://schemabenchmarks.dev">schemabenchmarks.dev</a> and fully tree-shakable.</p>
<p>Schema isn't an add-on. It's the front door to the pipeline.</p>
<h2>Tracing Without Modifying Stages</h2>
<p>Once a pipeline is a composed value, observability becomes compositional too.</p>
<p><code>tapWith</code> and <code>tapErrWith</code> observe values on either track without changing results. A traced pipeline and an untraced pipeline produce identical output for identical input.</p>
<p>Instrumentation becomes insertion, not intrusion. The <a href="https://stackblitz.com/github/sakobu/etl-demo?file=src%2Fapi%2Fetl.ts">ETL demo</a> visualizes this — each record's stage trace shows exactly where it succeeded, where it failed, and how long each stage took, all captured by taps woven into the pipeline.</p>
<h2>What It Costs</h2>
<ul>
<li><p>A dependency</p>
</li>
<li><p>New primitives (<code>flowAsync</code>, <code>flatMapWith</code>)</p>
</li>
<li><p>A more declarative style</p>
</li>
</ul>
<p>The imperative version is immediately readable.</p>
<p>The composed version requires learning a protocol.</p>
<p>Whether that's worth it depends on:</p>
<ul>
<li><p>How many pipelines you have</p>
</li>
<li><p>How complex your batch logic is</p>
</li>
<li><p>Whether your team values explicit error types enough to learn new abstractions</p>
</li>
</ul>
<h2>Level 4: Effect-TS</h2>
<p><a href="https://effect.website/">Effect-TS</a> goes further than any of the above.</p>
<p>An <code>Effect&lt;A, E, R&gt;</code> models:</p>
<ul>
<li><p>Success</p>
</li>
<li><p>Typed errors</p>
</li>
<li><p>Required dependencies</p>
</li>
<li><p>Concurrency</p>
</li>
<li><p>Resource safety</p>
</li>
<li><p>Scheduling</p>
</li>
</ul>
<p>Effect is not just a container for success or failure. It's a full runtime and programming model.</p>
<p>Adopting it means adopting:</p>
<ul>
<li><p>A fiber-based concurrency system</p>
</li>
<li><p>A layer-based dependency model</p>
</li>
<li><p>An execution boundary (<code>runPromise</code>, etc.)</p>
</li>
</ul>
<p>That's a framework-level commitment.</p>
<p>If your team wants typed errors as part of a broader architectural system, Effect is powerful.</p>
<p>If your problem is simply:</p>
<blockquote>
<p>I have multi-stage data pipelines and I want failures to compose as data.</p>
</blockquote>
<p>A lightweight <code>Result</code> + composition layer may be sufficient.</p>
<p>The difference isn't capability.</p>
<p>It's scope and commitment.</p>
<h2>When to Use What</h2>
<h3>Throw and catch</h3>
<p>When failure handling happens at boundaries and you don't need programmatic distinction.</p>
<h3>Discriminated unions</h3>
<p>When you have a small number of pipelines and want zero dependencies.</p>
<h3><code>neverthrow</code></h3>
<p>When you want a standardized <code>Result</code> with clean chaining.</p>
<h3><code>@railway-ts/pipelines</code></h3>
<p>When pipelines are recurring architecture, batch semantics matter, and validation should integrate with composition.</p>
<h3>Effect-TS</h3>
<p>When typed errors are part of a larger concurrency and dependency story.</p>
<h2>The Missing Middle</h2>
<p>There's a spectrum in TypeScript:</p>
<ul>
<li><p>Exceptions at the boundary</p>
</li>
<li><p>Hand-rolled unions</p>
</li>
<li><p>A standalone <code>Result</code></p>
</li>
<li><p>A full effect system</p>
</li>
</ul>
<p>What I kept hitting in production was a gap in the middle.</p>
<p>A way to:</p>
<ul>
<li><p>Treat failures as data</p>
</li>
<li><p>Compose multi-stage pipelines without boilerplate</p>
</li>
<li><p>Switch batch strategies without rewriting loops</p>
</li>
<li><p>Drive validation and forms from one definition</p>
</li>
<li><p>Add tracing without modifying business logic</p>
</li>
</ul>
<p>Without committing to an entire programming model.</p>
<p>That's the niche <code>@railway-ts/pipelines</code> is designed for.</p>
<p>If you've written the same validation → normalization → rule-check pipeline five times in one codebase, you already know the problem this is solving.</p>
<p>There's an <a href="https://stackblitz.com/github/sakobu/etl-demo?file=src%2Fapi%2Fetl.ts">interactive ETL demo</a> you can run in the browser. Edit records, switch between <code>partition</code>, <code>combine</code>, and <code>combineAll</code>, and watch how the same data produces different batch outcomes.</p>
<p>If it feels wrong, awkward, or incomplete, I'd genuinely like to hear why. The sharpest feedback comes from engineers who've lived through these tradeoffs.</p>
<ul>
<li><p><a href="https://github.com/sakobu/railway-ts-pipelines">GitHub — @railway-ts/pipelines</a></p>
</li>
<li><p><a href="https://github.com/sakobu/railway-ts-use-form">GitHub — @railway-ts/use-form</a></p>
</li>
<li><p><a href="https://www.npmjs.com/package/@railway-ts/pipelines">npm — @railway-ts/pipelines</a></p>
</li>
<li><p><a href="https://www.npmjs.com/package/@railway-ts/use-form">npm — @railway-ts/use-form</a></p>
</li>
</ul>
]]></content:encoded></item></channel></rss>