ts-safe
v2.1.0
Published
A functional, type-safe utility library for elegant error handling and asynchronous operations in JavaScript/TypeScript.
Maintainers
Readme
ts-safe
Type-safe error handling for TypeScript — with automatic Promise inference.
const name = await safe(() => fetch('/api/user'))
.map(res => res.json()) // transform — value changes
.effect(user => saveToDb(user)) // side effect — can break chain, value preserved
.observeOk(user => console.log(user))// observe — can't break chain, just watch
.recover(() => defaultUser) // recover — replace error with fallback
.map(user => user.name) // transform — type flows automatically
.unwrap(); // extract — Promise<string>Why ts-safe?
There are great Result/Either libraries for TypeScript — neverthrow, fp-ts, Effect. But they either require you to learn a new paradigm, or don't handle the sync → async boundary well.
ts-safe is different:
1. Promises just work
Most Result libraries force you to choose between sync and async variants, or wrap everything in a Task/Effect monad. ts-safe handles Promise transitions automatically at the type level:
safe(1) // Safe<number>
.map(x => x + 1) // Safe<number> — still sync
.map(async x => fetchData(x)) // Safe<Promise<Data>> — now async
.map(data => data.name) // Safe<Promise<string>> — stays async
.unwrap() // Promise<string> — awaitableNo TaskEither, no ResultAsync, no separate API. The type system tracks it for you.
2. Minimal API, maximum clarity
Every method name tells you two things: what it does and whether it affects the chain.
Chain affected: map · flatMap · effect · recover
Chain unaffected: observe · observeOk · observeError
Extract result: unwrap · orElse · match · isOkThat's it. No chain, andThen, mapLeft, bimap, fold, tryCatch, fromEither, taskify...
3. Tiny
| Library | Bundle (minified + gzip) | Dependencies | |---------|------------------------:|:------------:| | ts-safe | ~1 KB | 0 | | neverthrow | ~2 KB | 0 | | fp-ts | ~30 KB | 0 | | Effect | ~50 KB+ | multiple |
Install
npm install ts-safeQuick Start
import { safe } from 'ts-safe';
// Wrap a value
safe(42) // Safe<number>
// Wrap a function — errors are captured, not thrown
safe(() => JSON.parse(input)) // Safe<any>
// Chain operations
safe(() => riskyOperation())
.map(value => transform(value)) // transform the value
.effect(value => sideEffect(value)) // side effect — errors break the chain
.observeOk(value => console.log(value)) // observe — errors are ignored
.recover(err => fallbackValue) // recover from error
.unwrap() // extract the resultAPI
Methods are organized by impact on the chain:
┌──────────────────────────────────────────────────────────────┐
│ │
│ Transform map(fn) value → new value │
│ (changes value) flatMap(fn) value → Safe → flatten │
│ │
│ Side Effect effect(fn) run on success, keep value │
│ (can break) recover(fn) run on error, provide value │
│ │
│ Observe observe(fn) see SafeResult, can't break │
│ (can't break) observeOk(fn) see value only, can't break │
│ observeError(fn) see error only, can't break │
│ │
│ Extract unwrap() get value or throw │
│ orElse(v) get value or default │
│ match({ok,err}) pattern match │
│ isOk boolean (or Promise<boolean>) │
│ │
└──────────────────────────────────────────────────────────────┘map(fn) — Transform
Changes the value. Skipped on error.
safe(2).map(x => x * 3).unwrap() // 6flatMap(fn) — Transform with Safe
Like map, but for functions that return another Safe. Flattens the result.
const parse = (s: string) => safe(() => JSON.parse(s));
safe('{"a":1}').flatMap(parse).unwrap() // { a: 1 }effect(fn) — Side Effect on Success
Runs a function on success. The original value is preserved (return value ignored). If the function throws, the error propagates. If it returns a Promise, the chain becomes async.
safe(user)
.effect(u => saveToDb(u)) // if throws → error state
.effect(u => sendEmail(u)) // skipped if above threw
.map(u => u.name)
.unwrap()recover(fn) — Error Recovery
Provides a replacement value on error. After recovery, the chain continues as success.
safe(() => { throw new Error('fail') })
.recover(err => 'default') // chain is now ok
.map(v => v.toUpperCase())
.unwrap() // 'DEFAULT'observe(fn) / observeOk(fn) / observeError(fn) — Observe
Pure observation. Nothing you do inside can affect the chain — thrown errors are silently ignored, Promises are silently ignored, return values are ignored. The chain passes through completely unchanged.
Use for logging, metrics, debugging — anything where failure shouldn't stop the flow.
safe(result)
.observeOk(v => console.log('success:', v)) // only on success
.observeError(e => console.error('error:', e)) // only on error
.observe(r => metrics.record(r.isOk)) // always runs
.unwrap()The difference from effect: if your logging service throws, effect would break the chain. observe swallows the error and continues.
// effect — error propagates (for important side effects)
safe(data).effect(d => saveToDb(d)) // DB failure → chain breaks ✓
// observeOk — error ignored (for optional side effects)
safe(data).observeOk(d => analytics(d)) // analytics failure → chain continues ✓match({ ok, err }) — Pattern Match
Handles both success and error explicitly. Returns the handler's result.
const message = safe(() => fetchUser())
.map(user => user.name)
.match({
ok: name => `Hello, ${name}!`,
err: error => `Failed: ${error.message}`
});unwrap() / orElse(fallback) — Extract
safe(42).unwrap() // 42
safe(() => { throw new Error() }).unwrap() // throws!
safe(42).orElse('default') // 42
safe(() => { throw new Error() }).orElse('default') // 'default' (never throws)isOk — Check State
safe(42).isOk // true
safe(() => { throw new Error() }).isOk // false
await safe(1).map(async x => x).isOk // Promise<true>Promise Inference
This is the core differentiator. ts-safe tracks sync/async state at the type level without any extra syntax.
How it works
| What you do | Chain type | Why |
|---|---|---|
| safe(42) | Safe<number> | Sync value |
| .map(x => x + 1) | Safe<number> | Sync callback → stays sync |
| .map(async x => fetch(x)) | Safe<Promise<Response>> | Async callback → becomes async |
| .map(res => res.json()) | Safe<Promise<any>> | Callback receives awaited value |
| .effect(async x => log(x)) | Safe<Promise<T>> | Async side effect → becomes async |
| .observe(async x => log(x)) | Safe<T> | observe never goes async |
The key rule
When the chain is async, callbacks receive the awaited value — not the Promise.
safe(1)
.map(async x => x + 1) // callback returns Promise<number>
.map(x => x * 10) // x is number (not Promise<number>)
.unwrap() // Promise<number>You write synchronous-looking transforms. ts-safe handles the awaiting.
Comparison with alternatives
// neverthrow — need separate ResultAsync + awkward wrapping
const result = ResultAsync.fromPromise(fetch('/api'), handleErr)
.andThen(res => ResultAsync.fromPromise(res.json(), handleErr))
.map(data => data.name);
// ts-safe — just write it
const result = safe(() => fetch('/api'))
.map(res => res.json())
.map(data => data.name);Utilities
Validation
Validator functions for use with map. They return the value if valid, or throw if not.
import { safe, errorIfNull, errorIfEmpty, errorIf } from 'ts-safe';
safe(userInput)
.map(errorIfNull('Input required'))
.map(errorIfEmpty('Must not be empty'))
.map(errorIf(v => v.length < 3 ? 'Too short' : false))
.unwrap()| Function | Throws when |
|---|---|
| errorIfNull(msg?) | null or undefined |
| errorIfFalsy(msg?) | any falsy value |
| errorIfEmpty(msg?) | .length === 0 |
| errorIf(predicate) | predicate returns a string |
Retry
Automatic retry with optional exponential backoff:
import { safe, retry } from 'ts-safe';
await safe(url)
.map(retry(fetchData, {
maxTries: 3, // default: 3
delay: 1000, // default: 1000ms
backoff: true // exponential: 1s, 2s, 4s
}))
.unwrap();Pipe
Compose functions into a reusable pipeline with automatic error handling:
const getUsername = safe.pipe(
(id: number) => fetchUser(id), // number → User
user => user.name, // User → string
name => name.toUpperCase() // string → string
);
getUsername(1).unwrap() // 'ALICE'
getUsername(999).orElse('ANONYMOUS')License
MIT
