tsentials

Railway-oriented programming for TypeScript — Result<T>, Maybe<T>, Rule Engine, and DDD base classes with full async pipeline support.
Your Function Signature Is Lying →
A deep dive into why try/catch falls short in TypeScript, the philosophy behind Railway Oriented Programming, and the design decisions that shaped tsentials.
Table of Contents
Install
npm install tsentials
Requirements: Node.js ≥ 18, TypeScript ≥ 5.0
Modules
tsentials/result | Result<T>, ResultAsync<T>, ResultChain<T>, fromAsync, maybeToResult, resultToMaybe |
tsentials/maybe | Maybe<T>, collection utilities |
tsentials/errors | AppError, ErrorType, Err factory, ErrorMetadata |
tsentials/rules | Rule<T>, RuleEngine |
tsentials/entity | createEntityBase, createSoftDeletable, DomainEvent |
tsentials/http | fetchResult, RequestBuilder |
tsentials/time | DateTimeProvider, SystemDateTimeProvider, createFakeDateTimeProvider |
tsentials/clone | Cloneable<T>, deepClone, cloneArray |
tsentials/union | Union<T> |
tsentials/json | Json, JsonObject, JsonArray, JsonPrimitive, safeJsonParse, safeJsonStringify, parseAndValidate, type guards |
tsentials/function | pipe, flow, identity, constant, flip |
tsentials/array | NonEmptyArray<T>, head, tail, last, asNonEmptyArray |
tsentials/eq | Eq<T>, contramap, struct, getArrayEq |
tsentials/ord | Ord<T>, sortBy, min, max, clamp, between |
tsentials/predicate | Predicate<T>, Refinement<A, B>, and, or, not, all, any |
tsentials/these | These<E, A>, toResult, fromResult, partition |
tsentials/tree | Tree<T>, map, filter, fold, drawTree |
tsentials/record | Record utilities — map, filter, pick, omit, reduce |
tsentials/string | String case conversion utilities (Pascal, Camel, Kebab, Snake, Macro, Train, Title, _camelCase) |
Result<T>
Discriminated union { ok: true; value: T } | { ok: false; errors: AppError[] }. No exceptions — errors are values.
Creating Results
import { Result } from 'tsentials/result';
import { Err } from 'tsentials/errors';
function divide(a: number, b: number): Result<number> {
if (b === 0) return Result.failure(Err.validation('Math.DivideByZero', 'Cannot divide by zero'));
return Result.success(a / b);
}
const r = divide(10, 2);
if (Result.isSuccess(r)) console.log(r.value);
if (Result.isFailure(r)) console.log(Result.firstError(r).code);
Result.successIf(user.age >= 18, user, Err.validation('User.Underage', 'Must be 18+'));
Result.failIf(user.isBanned, user, Err.forbidden('User.Banned', 'Account suspended'));
Result.try(() => JSON.parse(raw), () => Err.validation('JSON.Invalid', 'Malformed JSON'));
Result.ok();
Pipeline (sync)
import { Result } from 'tsentials/result';
import { Err } from 'tsentials/errors';
const price = Result.success(100)
|> Result.map(_, n => n * 1.2)
|> Result.ensure(_, n => n < 200, Err.validation('Price.TooHigh', 'Exceeds limit'))
|> Result.map(_, n => `$${n.toFixed(2)}`);
Result.ensure(
Result.success(3),
n => n > 5,
n => Err.validation('Value.TooSmall', `Value ${n} is too small`),
);
Result.tap(price, v => console.log('computed', v));
Result.tapError(price, errs => console.error('failed', errs[0].code));
Conditional & Guarded Pipeline
import { Result } from 'tsentials/result';
Result.bindIf(Result.success(5), true, n => Result.success(n * 2));
Result.bindIf(Result.success(5), n => n > 3, n => Result.success(n * 2));
Result.tapIf(Result.success(42), true, v => console.log(v));
Result.tapIf(Result.success(42), v => v > 10, v => console.log(v));
Result.tapErrorIf(
Result.failure(err),
errs => errs.length > 0,
errs => metrics.track(errs[0].code),
);
Error Handling & Recovery
import { Result } from 'tsentials/result';
Result.compensate(Result.failure(err), () => Result.success(-1));
Result.compensateFirst(
Result.failureFrom([err1, err2]),
first => Result.success(first.code),
);
Result.recover(
Result.failure(notFoundError),
e => e.code === 'User.NotFound',
() => Result.success(guestUser),
);
Result.mapError(
Result.failure(err),
errs => errs.map(e => ({ ...e, code: `Wrapped.${e.code}` })),
);
Result.unwrapOr(Result.success(42), 0);
Result.unwrapOr(Result.failure(err), 0);
Result.unwrapOrElse(Result.failure(err), errs => errs.length);
const [ok, value, errors] = Result.deconstruct(result);
Async Pipeline — ResultAsync<T>
ResultAsync<T> implements PromiseLike<Result<T>> — the entire chain builds synchronously, resolves once at the end with a single await.
import { fromAsync } from 'tsentials/result';
import { Err } from 'tsentials/errors';
const profile = await fromAsync(fetchUser(userId))
.andThen(user => validateUser(user))
.ensure(user => user.isActive, Err.validation('User.Inactive', 'Not active'))
.map(user => user.profile)
.tap(p => console.log('fetched', p.name))
.match(
profile => profile,
() => null,
);
Async variants of all sync operations are available: thenAsync, mapAsync, ensureAsync, tapAsync, tapErrorAsync, compensateAsync, mapErrorAsync.
await Result.bindIfAsync(
Result.success(user),
u => u.isAdmin,
async u => fetchAdminDashboard(u),
);
await Result.recoverAsync(
Result.failure(cacheMiss),
e => e.code === 'Cache.Miss',
async () => fetchFromDatabase(),
);
ResultChain<T> — Fluent Wrapper
import { chain } from 'tsentials/result';
const r = chain(Result.success(5))
.bind(n => Result.success(n * 2))
.ensure(n => n > 5, Err.validation('Value.TooSmall', 'Too small'))
.map(n => `value: ${n}`)
.unwrap();
Combination & Utilities
import { Result } from 'tsentials/result';
Result.and([Result.success(1), Result.success(2)]);
Result.and([Result.success(1), Result.failure(err)]);
Result.or([Result.failure(err1), Result.success(99), Result.failure(err2)]);
Result.combine(Result.success(1), Result.success('hello'), Result.success(true));
Result.flatten(Result.success(Result.success(42)));
Result.always(result, r => {
console.log(r.ok ? 'success' : 'failure');
return 'done';
});
Result.traverse([1, 2, 3], n => n > 0 ? Result.success(n * 2) : Result.failure(err));
await Result.traverseAsync([1, 2], async n => fetchUser(n));
Maybe<T>
Explicit optional values — no accidental undefined.
Creating Maybe Values
import { Maybe } from 'tsentials/maybe';
Maybe.some(42);
Maybe.none<number>();
Maybe.from(user.nickname);
Maybe.fromTry(() => riskyParse());
Pipeline
import { Maybe } from 'tsentials/maybe';
const display = Maybe.getOrElse(
Maybe.filter(
Maybe.map(Maybe.from(user.nickname), s => s.trim()),
s => s.length > 0,
),
() => 'Anonymous',
);
if (Maybe.isSome(maybe)) console.log(maybe.value);
if (Maybe.isNone(maybe)) console.log('empty');
Maybe.getOrUndefined(maybe);
Maybe.getOrThrow(maybe, 'Missing!');
Maybe.deconstruct(maybe);
Conditional Operations
import { Maybe } from 'tsentials/maybe';
Maybe.mapIf(Maybe.some(5), true, n => n * 2);
Maybe.mapIf(Maybe.some(5), n => n > 3, n => n * 2);
Maybe.bindIf(Maybe.some(5), n => n > 3, n => Maybe.some(n * 2));
Maybe.or(Maybe.none<number>(), Maybe.some(99));
Maybe.orElse(Maybe.none<number>(), () => Maybe.some(99));
Maybe.tapNone(Maybe.none<number>(), () => console.warn('missing'));
Async Pipeline
import { Maybe } from 'tsentials/maybe';
const user = await Maybe.mapAsync(Maybe.some(userId), async id => fetchUser(id));
const profile = await Maybe.bindAsync(user, async u =>
u.isActive ? Maybe.some(u.profile) : Maybe.none(),
);
const filtered = await Maybe.filterAsync(profile, async p => p.isPublic);
Collection Utilities
import { tryFirst, tryFind, choose, asMaybe } from 'tsentials/maybe';
const first = tryFirst(items);
const found = tryFind(items, (x) => x.id === targetId);
const values = choose([Maybe.some(1), Maybe.none(), Maybe.some(3)]);
const m = asMaybe(maybeNullValue);
Result ↔ Maybe Bridge
import { maybeToResult, resultToMaybe } from 'tsentials/result';
import { Maybe } from 'tsentials/maybe';
import { Err } from 'tsentials/errors';
const result = maybeToResult(Maybe.from(user), Err.notFound('User.NotFound', 'Missing'));
const maybe = resultToMaybe(Result.success(42));
const none = resultToMaybe(Result.failure(err));
maybeToResult(resultToMaybe(Result.success(data)), fallbackError);
Rule Engine
import { RuleEngine } from 'tsentials/rules';
import type { Rule } from 'tsentials/rules';
const isAdult = RuleEngine.fromPredicate<User>(
u => u.age >= 18,
Err.validation('User.Underage', 'Must be 18+'),
);
const hasBalance = RuleEngine.fromPredicate<Account>(
a => a.balance > 0,
a => Err.validation('Account.Insufficient', `Balance ${a.balance} is too low`),
);
RuleEngine.and(isAdult, hasBalance);
RuleEngine.linear(isAdult, hasBalance);
RuleEngine.or(isAdult, hasBalance);
RuleEngine.if(isAdult, hasBalance);
RuleEngine.if(isAdult, hasBalance, minorRule);
const asyncRule = RuleEngine.fromPredicateAsync<User>(
async u => await fetchStatus(u.id) === 'active',
Err.validation('User.Inactive', 'Not active'),
);
RuleEngine.andAsync(asyncRule, anotherAsyncRule);
RuleEngine.linearAsync(asyncRule, anotherAsyncRule);
RuleEngine.orAsync(asyncRule, fallbackAsyncRule);
RuleEngine.ifAsync(asyncRule, onTrue, onFalse);
const result = RuleEngine.evaluate(isAdult, user);
const asyncResult = await RuleEngine.evaluateAsync(asyncRule, user);
AppError & Err Factory
import { Err } from 'tsentials/errors';
Err.validation('Field.Required', 'Name is required');
Err.notFound('User.NotFound', 'User does not exist');
Err.unexpected('DB.ConnectionFailed', 'Cannot connect to database');
Err.conflict('Email.AlreadyTaken', 'This email is already in use');
Err.unauthorized('Auth.InvalidToken', 'Token is expired');
Err.forbidden('Permissions.Denied', 'Insufficient permissions');
Err.fromException(new Error('timeout'));
Err.fromException(new Error('timeout'), ErrorType.Unexpected, 'Network.Timeout');
Err.equals(errA, errB);
Error Metadata
import { ErrorMetadata } from 'tsentials/errors';
const meta = ErrorMetadata.fromRecord({ field: 'email', constraint: 'unique' });
const err = Err.validation('Email.Invalid', 'Invalid format', meta);
const combined = ErrorMetadata.combine(baseMeta, additionalMeta);
const record = ErrorMetadata.toRecord(meta);
const exceptionMeta = ErrorMetadata.fromException(new TypeError('fail'));
Entity Base (DDD)
import { createEntityBase, createSoftDeletable } from 'tsentials/entity';
import type { DomainEvent } from 'tsentials/entity';
interface OrderCreatedEvent extends DomainEvent {
readonly orderId: string;
}
class Order implements EntityBase, SoftDeletable {
private readonly _base = createEntityBase();
private readonly _softDelete = createSoftDeletable();
get domainEvents() { return this._base.domainEvents; }
get createdAt() { return this._base.createdAt; }
get createdBy() { return this._base.createdBy; }
get updatedAt() { return this._base.updatedAt; }
get updatedBy() { return this._base.updatedBy; }
get isDeleted() { return this._softDelete.isDeleted; }
get isHardDeleted() { return this._softDelete.isHardDeleted; }
get deletedAt() { return this._softDelete.deletedAt; }
get deletedBy() { return this._softDelete.deletedBy; }
raise(event: DomainEvent) { this._base.raise(event); }
clearDomainEvents() { return this._base.clearDomainEvents(); }
setCreatedInfo(at: Date, by: string) { this._base.setCreatedInfo(at, by); }
setUpdatedInfo(at: Date, by: string) { this._base.setUpdatedInfo(at, by); }
markAsDeleted(at: Date, by: string) { this._softDelete.markAsDeleted(at, by); }
markAsHardDeleted() { this._softDelete.markAsHardDeleted(); }
restore() { this._softDelete.restore(); }
}
HTTP (fetchResult)
fetchResult never throws — network errors and HTTP error responses are captured as Result<T>.
import { fetchResult, RequestBuilder } from 'tsentials/http';
const result = await fetchResult.get<User>('https://api.example.com/users/42');
if (!result.ok) console.error(result.errors[0].code);
await fetchResult.post('/users', { name: 'Alice' });
await fetchResult.put('/users/1', { name: 'Bob' });
await fetchResult.patch('/users/1', { active: true });
await fetchResult.delete('/users/1');
const r = await fetchResult.get('/offline');
const users = await RequestBuilder.get('https://api.example.com/users')
.header('Authorization', `Bearer ${token}`)
.query('page', '1')
.query('limit', '10')
.send<User[]>();
const created = await RequestBuilder.post('https://api.example.com/users')
.header('X-Idempotency-Key', key)
.json({ name: 'Alice', email: 'alice@example.com' })
.send<User>();
Status code mapping:
| 400, 422 | Validation |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404, 410 | NotFound |
| 409, 429 | Conflict |
| ≥500 | Unexpected |
Supports application/problem+json (RFC 9457) for error descriptions.
import { HttpCodes } from 'tsentials/http';
import type { HttpCode } from 'tsentials/http';
const status: HttpCode = HttpCodes.Ok;
const notFound = HttpCodes.NotFound;
const serverErr = HttpCodes.InternalServerError;
Union<T>
Programmatic discriminated union with exhaustive match.
import { Union } from 'tsentials/union';
type PaymentResult = Union<{
success: { transactionId: string };
pending: { estimatedMs: number };
failed: { error: AppError };
}>;
const result = Union.of<{ success: { transactionId: string }; pending: { estimatedMs: number }; failed: { error: AppError } }>('success', { transactionId: 'txn_123' });
const message = Union.match(result, {
success: ({ transactionId }) => `Paid! Ref: ${transactionId}`,
pending: ({ estimatedMs }) => `Pending for ${estimatedMs}ms`,
failed: ({ error }) => `Failed: ${error.description}`,
});
if (Union.is(result, 'success')) {
console.log(result.value.transactionId);
}
const id = Union.get(result, 'success').transactionId;
const { lefts, rights } = Union.partition(shapes, 'circle', 'rect');
const groups = Union.groupBy(shapes);
Time & Fake Providers
import { SystemDateTimeProvider, createFakeDateTimeProvider } from 'tsentials/time';
const now = SystemDateTimeProvider.utcNow();
const today = SystemDateTimeProvider.utcNowDate();
const ms = SystemDateTimeProvider.utcNowMs();
const fake = createFakeDateTimeProvider(new Date('2024-06-01T12:00:00Z'));
fake.utcNow();
fake.advance(1000);
fake.setTime(newDate);
fake.utcNowDate();
Clone Utilities
import { deepClone, cloneArray } from 'tsentials/clone';
import type { Cloneable } from 'tsentials/clone';
deepClone uses the native structuredClone API when available, falling back to a robust
recursive implementation. Never throws — works in React Native (Hermes) and all JS runtimes.
const copy = deepClone({ user: { id: 1, tags: ['a', 'b'] } });
copy.user.tags.push('c');
deepClone({ createdAt: new Date(), lookup: new Map([['key', 'value']]) });
deepClone(new Uint8Array([1, 2, 3]));
const obj: { self?: unknown } = {};
obj.self = obj;
const cloned = deepClone(obj);
cloned.self === cloned;
const err = Object.assign(new TypeError('fail'), { code: 'ERR_X' });
deepClone(err).code;
deepClone({ fn: () => 42 });
deepClone({ sym: Symbol('x') });
deepClone(new WeakMap());
class Product implements Cloneable<Product> {
constructor(public readonly id: number) {}
clone() { return new Product(this.id); }
}
const cloned = cloneArray([new Product(1), new Product(2)]);
JSON Utilities
Type-safe JSON parsing and validation that returns Result<T> — no exceptions, fits directly into the railway pipeline.
import { safeJsonParse, safeJsonStringify, parseAndValidate } from 'tsentials/json';
import { isJsonObject } from 'tsentials/json';
const result = safeJsonParse('{"name":"Alice","age":30}');
if (result.ok) {
console.log(result.value);
} else {
console.error(result.errors[0].code);
}
const json = safeJsonStringify({ id: 1, tags: ['a', 'b'] });
if (json.ok) console.log(json.value);
interface User { name: string; age: number }
function isUser(value: unknown): value is User {
return isJsonObject(value) && typeof value.name === 'string' && typeof value.age === 'number';
}
const user = parseAndValidate<User>('{"name":"Alice","age":30}', isUser);
if (user.ok) console.log(user.value.name);
Type Guards
import { isJson, isJsonObject, isJsonArray, isJsonPrimitive } from 'tsentials/json';
isJsonPrimitive('hello');
isJsonArray([1, 2, 3]);
isJsonObject({ a: 1 });
isJson({ nested: [1, null] });
isJson({ fn: () => {} });
isJson({ key: undefined });
Error Codes
Json.SyntaxError | JSON.parse failed — malformed input |
Json.ValidationError | Parsed value failed type guard |
Json.StringifyFailed | JSON.stringify failed (e.g. circular reference) |
Pipeline Integration
import { Result } from 'tsentials/result';
import { safeJsonParse } from 'tsentials/json';
const processed = Result.then(
safeJsonParse(rawInput),
data => validatePayload(data),
);
pipe & flow
import { pipe, flow } from 'tsentials/function';
const result = pipe(
5,
n => n * 2,
n => n + 1,
n => String(n),
);
const doubleAndStringify = flow(
(n: number) => n * 2,
n => String(n),
);
doubleAndStringify(5);
NonEmptyArray<T>
Type-safe arrays guaranteed to have at least one element. No null checks needed for head() or last().
import { NonEmptyArray, asNonEmptyArray } from 'tsentials/array';
const items: NonEmptyArray<string> = ['a', 'b', 'c'];
NonEmptyArray.head(items);
NonEmptyArray.last(items);
const maybe = asNonEmptyArray([]);
const sure = asNonEmptyArray([1, 2]);
Eq<T> & Ord<T>
Composable, type-safe equality and ordering.
import { Eq, Ord } from 'tsentials/eq';
import { sortBy, min, max, clamp } from 'tsentials/ord';
interface User { readonly id: number; readonly name: string; }
const eqUser = Eq.struct<User>({ id: Eq.number, name: Eq.string });
const byAge = Ord.contramap(Ord.number, (u: User) => u.age);
const sorted = sortBy(users, byAge);
min(byAge, userA, userB);
clamp(Ord.number, 0, 100, 150);
Predicate<T>
Composable boolean predicates for validation and filtering.
import { Predicate } from 'tsentials/predicate';
const isAdult = Predicate.from((u: User) => u.age >= 18);
const isActive = Predicate.from((u: User) => u.isActive);
const isValid = Predicate.and(isAdult, isActive);
const isAnyOf = Predicate.any(isAdult, isGuest, isAdmin);
These<E, A>
Partial success — a value together with errors/warnings. Unlike Result<T> which is either-or, These allows both.
import { These } from 'tsentials/these';
const parseAge = (raw: string): These<AppError, number> => {
const age = Number(raw);
if (Number.isNaN(age)) return These.left(Err.validation('Age.NaN', 'Not a number'));
if (age < 0) return These.both(Err.validation('Age.Negative', 'Negative age'), 0);
return These.right(age);
};
These.toResult(parseAge('-5'));
Tree<T>
Recursive tree data structure for hierarchies.
import { Tree } from 'tsentials/tree';
const tree = Tree.of('root', [
Tree.of('a', [Tree.leaf('a1')]),
Tree.leaf('b'),
]);
Tree.toArray(tree);
Tree.find(tree, v => v === 'a1');
Tree.drawTree(tree);
Record Utilities
Functional operations on plain objects.
import { Record as R } from 'tsentials/record';
const users = { a: { name: 'Alice' }, b: { name: 'Bob' } };
R.map(users, u => u.name);
R.filter(users, u => u.name !== 'Bob');
R.pick(users, 'a');
R.omit(users, 'b');
String Utilities
import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase, toMacroCase, toTrainCase, toTitleCase, toUnderscoreCamelCase } from 'tsentials/string';
toPascalCase('hello world')
toCamelCase('hello-world')
toKebabCase('HelloWorld')
toSnakeCase('helloWorld')
toMacroCase('hello world')
toTrainCase('hello world')
toTitleCase('hello world foo')
toUnderscoreCamelCase('helloWorld')
All functions handle: spaces, hyphens, underscores, camelCase, PascalCase, and mixed input.
Design Notes
Result<T> — discriminated union, no class, zero runtime overhead
ResultAsync<T> — implements PromiseLike<Result<T>> for direct await; monadic bind named andThen to avoid thenable collision
ResultChain<T> — fluent sync wrapper; monadic bind named bind (not then) for the same reason
Maybe<T> — pure functional namespace, all operations are static functions
Rule<T> — just (ctx: T) => VoidResult, no interface hierarchy
- Entity base — mixin factory pattern (
createEntityBase()), not abstract class inheritance
sideEffects: false — all subpath imports are fully tree-shakeable
AI Skills
Install skills for Claude Code, Cursor, Codex, and 50+ other AI agents:
npx skills add senrecep/tsentials
Each module has a dedicated skill with accurate API examples, correct import paths, and common pitfalls.
License
MIT © Recep Şen