@andersoncustodio/railway
v1.0.0
Published
Railway Oriented Programming for TypeScript
Maintainers
Readme
Railway
A lightweight Railway Oriented Programming toolkit for TypeScript. Models operations as two parallel tracks, success and failure, so your domain code stays free of try/catch and error handling stays type-safe and explicit.
Install
npm install @andersoncustodio/railwayPrimitives
| | Purpose |
|---|---|
| Result<T> | Discriminated union (Ok<T> \| Err) for domain logic. |
| Outcome<T> | API-facing response with HTTP status, data, meta, errors. |
| ErrorDetail | A single field-level error (code, field, message, meta). |
| ErrorCollector | Accumulates errors from multiple validations. |
| HaltError | Structured exception that carries the error track across the call stack. |
| HttpStatus | Standard HTTP status codes enum. |
Value Object with Result
A classic DDD pattern: the value object has a private constructor and a static factory returning Result, so invalid state is impossible to construct.
import { Result, ErrorDetail } from '@andersoncustodio/railway';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export class Email {
private constructor(readonly value: string) {}
static create(input: string): Result<Email> {
const value = input.trim();
if (value.length === 0) {
return Result.err([
ErrorDetail.from({ code: 'required', field: 'email', message: 'Email is required' }),
]);
}
if (!EMAIL_REGEX.test(value)) {
return Result.err([
ErrorDetail.from({ code: 'invalid_format', field: 'email', message: 'Invalid email' }),
]);
}
return Result.ok(new Email(value.toLowerCase()));
}
}
const r = Email.create('foo@bar');
if (r.isErr()) {
console.log(r.firstError.message); // "Invalid email"
} else {
r.value; // Email - type is narrowed, no casting
}No hidden control flow: the signature tells you it can fail, and the compiler forces you to handle it.
Aggregating errors with ErrorCollector
Validate many value objects and report all errors at once instead of failing on the first.
import { Result, ErrorCollector } from '@andersoncustodio/railway';
class User {
private constructor(readonly name: Name, readonly email: Email) {}
static create(params: { name: string; email: string }): Result<User> {
const errorCollector = ErrorCollector.create();
const name = Name.create(params.name).unwrap(errorCollector);
const email = Email.create(params.email).unwrap(errorCollector);
if (errorCollector.hasErrors()) return Result.err(errorCollector.errors());
return Result.ok(new User(name, email));
}
}unwrap(errorCollector) is the key: on Err, errors flow into the collector and the call returns never. Once hasErrors() passes, name and email are narrowed to their valid types.
Remapping field names
unwrap accepts a second argument, a fieldMapper, that rewrites the field of each error as it flows into the collector. Useful when validating nested structures or lists, where the inner value object doesn't know its position in the parent.
class Order {
private constructor(readonly items: OrderItem[]) {}
static create(params: { items: ItemInput[] }): Result<Order> {
const errorCollector = ErrorCollector.create();
const items = params.items.map((item, i) =>
OrderItem.create(item).unwrap(errorCollector, (field) => `items[${i}].${field}`)
);
if (errorCollector.hasErrors()) return Result.err(errorCollector.errors());
return Result.ok(new Order(items));
}
}An error emitted by OrderItem.create with field: 'sku' becomes field: 'items[2].sku' in the final payload, giving the client a path it can use to highlight the offending input.
From domain to HTTP with Outcome
Outcome wraps a domain operation for HTTP delivery, carrying the right status code, a machine-readable code, and a structured payload.
import { Outcome, HttpStatus, ErrorCollector } from '@andersoncustodio/railway';
async function createUserHandler(cmd: CreateUserCommand): Promise<Outcome<{ data: { id: string } }>> {
const errorCollector = ErrorCollector.create();
const user = User.create({ name: cmd.name, email: cmd.email }).unwrap(errorCollector);
if (errorCollector.hasErrors()) {
return Outcome.err({
code: 'user.validation_error',
message: 'Validation failed',
errors: errorCollector.errors(),
});
}
const existing = await repository.findByEmail(user.email);
if (existing) {
return Outcome.err({
status: HttpStatus.CONFLICT,
code: 'user.email_taken',
message: 'Email already in use',
});
}
await repository.save(user);
return Outcome.ok({
status: HttpStatus.CREATED,
data: { id: user.id },
});
}unwrap(errorCollector) returns User on success; on failure, errors flow into the collector and the happy path exits through the hasErrors() guard. Each failure branch maps cleanly to a distinct HTTP status: 422 (default), 409, etc. Success returns 201 Created with the payload.
License
MIT
