Rxova

Projects

Take one package, keep the rest of your stack.

4 independent TypeScript libraries, one problem each: journey, react-inputs, use-everywhere, and ts-extended-errors. Each installs on its own, with its docs and a walkthrough below.

Projects

journey

Declarative journey graphs for non-linear UI flows.

Model multi-step, branching flows as a graph instead of a tangle of booleans and effects. Core is framework-agnostic; React bindings and a Chrome DevTools bridge come in the box.

npm i @rxova/journey-core
  • JS Core
  • React
  • TypeScript
  • Chrome Extension
  • Zero-dependency

A checkout that branches, waits and goes back

react-inputs

The tricky React inputs, done right.

Nine focused input components — currency, OTP, rating, password, phone, date, time, tags and file. Headless with no stylesheet to import, accessible by construction, and zero runtime dependencies each.

npm i @rxova/react-inputs
  • React
  • TypeScript
  • Zero-dependency
  • Accessible

A transfer form, filled in from Berlin

use-everywhere

State and messages that exist in every tab, window, and worker.

Share state and messages across every tab, window, and worker — a framework-agnostic core plus React bindings, one small API.

npm i use-everywhere
  • JS Core
  • React
  • TypeScript
  • Cross-tab
  • Zero-dependency

A cart open in three tabs

ts-extended-errors

Typed, serializable errors that survive a JSON round trip.

Typed error classes for TypeScript, and a JSON round trip that rebuilds them as the classes they were — across a process boundary, a worker or a log line. A base class, a one-line factory, and helpers that search the cause chain.

npm i ts-extended-errors
  • TypeScript
  • Zero-dependency
  • Node
  • Browser

An error that has to cross a boundary

A worker charges a card and the charge fails. On the other side of a JSON hop, the API must decide whether to retry, or to tell the customer why.

By hand

  1. Every class declares and assigns each field, sets its name and fixes its prototype.
  2. A payload type and a toPayload branch for every class. Add a field and forget one, and it stays behind.
  3. A type guard and a fromPayload branch for every class, to recognise the payload and rebuild it.
  4. Only the outer error travels. Wrap it to add the order id, and toPayload no longer knows it: a 500.
// errors.ts: shared by the worker and the API
class CardDeclinedError extends Error {
reason: 'expired' | 'insufficient_funds'
last4: string
constructor(reason: 'expired' | 'insufficient_funds', last4: string) {
super(`card ending ${last4} declined: ${reason}`)
this.name = 'CardDeclinedError'
this.reason = reason
this.last4 = last4
Object.setPrototypeOf(this, CardDeclinedError.prototype)
}
}
class RateLimitError extends Error {
limit: number
retryAfterMs: number
constructor(limit: number, retryAfterMs: number) {
super(`rate limit of ${limit} hit, retry in ${retryAfterMs} ms`)
this.name = 'RateLimitError'
this.limit = limit
this.retryAfterMs = retryAfterMs
Object.setPrototypeOf(this, RateLimitError.prototype)
}
}
// wire.ts: a payload, a guard and a rebuild for every class, kept in step by hand
type CardDeclinedPayload = { name: 'CardDeclinedError'; reason: 'expired' | 'insufficient_funds'; last4: string }
type RateLimitPayload = { name: 'RateLimitError'; limit: number; retryAfterMs: number }
function toPayload(e: unknown): CardDeclinedPayload | RateLimitPayload | { name: 'Error' } {
if (e instanceof CardDeclinedError) return { name: 'CardDeclinedError', reason: e.reason, last4: e.last4 }
if (e instanceof RateLimitError) return { name: 'RateLimitError', limit: e.limit, retryAfterMs: e.retryAfterMs }
return { name: 'Error' }
}
const isCardDeclined = (p: any): p is CardDeclinedPayload =>
p?.name === 'CardDeclinedError' && typeof p.reason === 'string' && typeof p.last4 === 'string'
const isRateLimit = (p: any): p is RateLimitPayload =>
p?.name === 'RateLimitError' && typeof p.limit === 'number' && typeof p.retryAfterMs === 'number'
function fromPayload(p: unknown): Error {
if (isCardDeclined(p)) return new CardDeclinedError(p.reason, p.last4)
if (isRateLimit(p)) return new RateLimitError(p.limit, p.retryAfterMs)
return new Error('unknown failure')
}
// worker.ts: charges the card, and reports a failure as JSON
async function runCheckout(orderId: string, charge: () => Promise<void>) {
try {
await charge()
return { ok: true }
} catch (e) {
// error: { name: 'RateLimitError', limit: 100, retryAfterMs: 1200 }
// No orderId: wrapping e to carry it would hide e from toPayload's instanceof checks
return { ok: false, error: toPayload(e) }
}
}
// api.ts: receives that JSON, and decides what the customer sees
function respond(payload: unknown) {
const error = fromPayload(payload)
if (error instanceof RateLimitError) {
return { status: 429, limit: error.limit, retryAfterMs: error.retryAfterMs }
}
if (error instanceof CardDeclinedError) return { status: 402, reason: error.reason }
return { status: 500 }
}

With ts-extended-errors

  1. defineError writes the class, its code and its message, all from one context type.
  2. serializeError handles every class the same way: name, code, context and the whole cause chain.
  3. deserializeError rebuilds the real classes from the list it is given. There are no guards to write.
  4. The wrapper carries the order id, its cause travels with it, and findCauseOf finds it at any depth.
import { defineError, deserializeError, findCauseOf, serializeError } from 'ts-extended-errors'
// errors.ts: shared by the worker and the API
type CardDeclined = { reason: 'expired' | 'insufficient_funds'; last4: string }
type RateLimited = { limit: number; retryAfterMs: number }
export const CardDeclinedError = defineError<CardDeclined>('CardDeclinedError', {
code: 'CARD_DECLINED',
message: ({ reason, last4 }) => `card ending ${last4} declined: ${reason}`,
})
export const RateLimitError = defineError<RateLimited>('RateLimitError', {
code: 'RATE_LIMITED',
message: ({ limit, retryAfterMs }) =>
`rate limit of ${String(limit)} hit, retry in ${String(retryAfterMs)} ms`,
})
export const CheckoutError = defineError<{ orderId: string }>('CheckoutError', { code: 'CHECKOUT' })
// worker.ts: charges the card, and reports a failure as JSON
export async function runCheckout(orderId: string, charge: () => Promise<void>) {
try {
await charge()
return { ok: true }
} catch (cause) {
const error = new CheckoutError('checkout failed', { cause, context: { orderId } })
// error: {
// name: 'CheckoutError', message: 'checkout failed', code: 'CHECKOUT',
// context: { orderId: 'order-7' },
// cause: {
// name: 'RateLimitError', message: 'rate limit of 100 hit, retry in 1200 ms',
// code: 'RATE_LIMITED', context: { limit: 100, retryAfterMs: 1200 },
// },
// }
return { ok: false, error: serializeError(error, { includeStack: false }) }
}
}
// api.ts: receives that JSON, and decides what the customer sees
export function respond(payload: unknown) {
const error = deserializeError(payload, {
classes: [CheckoutError, CardDeclinedError, RateLimitError],
})
// error is a chain: CheckoutError { orderId }
// └─ cause: RateLimitError { limit, retryAfterMs }
// findCauseOf walks down it and returns the first RateLimitError, typed, or undefined.
const limited = findCauseOf(error, RateLimitError)
if (limited) return { status: 429, ...limited.context } // { limit: number; retryAfterMs: number }
// The same search for a declined card, however many wrappers deep it is.
const declined = findCauseOf(error, CardDeclinedError)
if (declined) return { status: 402, reason: declined.context.reason } // 'expired' | 'insufficient_funds'
return { status: 500 }
}

The rules

One set of rules, every project.

These are what everything here has in common — and what it is held to. Each is stated so you can check it against the code and catch me failing it.