try-tuple
v1.0.0
Published
Go-style error handling for TypeScript. Never write try/catch again.
Maintainers
Readme
🛡️ try-tuple
Go-style error handling for TypeScript. Never write try/catch again.
✅ Zero dependencies • ✅ Tiny (~1KB) • ✅ Perfect TypeScript narrowing • ✅ Sync + Async
The Problem
- Try/catch is ugly, verbose, and breaks your code flow:
// 😩 Nested try/catch hell
try {
const user = await db.findUser(id);
try {
const posts = await api.getPosts(user.id);
try {
await cache.set(user.id, posts);
} catch (e) {
/* ... */
}
} catch (e) {
/* ... */
}
} catch (e) {
/* ... */
}The Solution
- Go solved this years ago. Now you can do it in TypeScript:
// 😎 Clean, flat, readable
import { safe } from "try-tuple";
const [err, user] = await safe(db.findUser(id));
if (err) return handleError(err);
const [err2, posts] = await safe(api.getPosts(user.id));
if (err2) return handleError(err2);
await safe(cache.set(user.id, posts));
// No nesting. No blocks. Just tuples.Install
# npm
npm install try-tuple
# pnpm
pnpm add try-tuple
# yarn
yarn add try-tuple
# bun
bun add try-tupleUsage
Async (Promises)
- Wrap any promise. Get a tuple back.
import { safe } from "try-tuple";
const [err, user] = await safe(db.findUser(id));
if (err) {
console.error(err.message);
return;
}
// TypeScript knows user is defined here ✅
console.log(user.name);Sync
- Wrap any function that might throw:
const [err, data] = safe.sync(() => JSON.parse(rawJson));
if (err) {
console.error("Invalid JSON:", err.message);
return;
}
console.log(data);Pipe
- Chain multiple operations. Stops at the first error:
const [err, result] = await safe.pipe(
() => db.findUser(id),
(user) => stripe.charge(user.stripeId, 1000),
(charge) => db.orders.create({ chargeId: charge.id }),
(order) => email.send({ subject: `Order ${order.id} confirmed` }),
);
if (err) {
console.error("Pipeline failed at:", err.message);
return;
}
console.log("Order completed:", result);- Mix sync and async freely:
const [err, result] = await safe.pipe(
() => readFile("config.json", "utf-8"), // async
(raw) => JSON.parse(raw), // sync — works too
(config) => startServer(config), // async
);All (Parallel)
- Like Promise.all, but returns a tuple:
const [err, results] = await safe.all([
db.findUser(1),
db.findUser(2),
db.findUser(3),
]);
if (err) return handleError(err);
const [user1, user2, user3] = results;Race
- Like Promise.race, but safe:
const [err, fastest] = await safe.race([
fetch("https://api-us.example.com/data"),
fetch("https://api-eu.example.com/data"),
]);
if (err) return handleError(err);
console.log("Got response from fastest server:", fastest);Retry
- Retries a function with optional delay and exponential backoff:
const [err, data] = await safe.retry(
() => fetch("https://api.com/data"),
{
attempts: 3,
delay: 1000,
backoff: true, // 1s → 2s → 4s
onRetry: (err, attempt) => {
console.log(`⚠️ Attempt ${attempt} failed: ${err.message}`);
},
},
);
if (err) {
console.error("All 3 attempts failed:", err.message);
return;
}
console.log("Got data:", data);Wrap
- Turn any function into a safe version. Once wrapped, use it everywhere:
// Wrap once
const safeJsonParse = safe.wrap((raw: string) => JSON.parse(raw))
// Use everywhere
const [err, data] = safeJsonParse('{"a":1}') // ✅ [null, {a:1}]
const [err2, data2] = safeJsonParse('invalid') // ✅ [Error, null]TypeScript Narrowing
- This is where try-tuple really shines.
- TypeScript automatically narrows the types after you check for errors:
const [err, user] = await safe(db.findUser(id));
if (err) {
// ✅ TypeScript knows:
// err → Error
// user → null
console.error(err.message);
return;
}
// ✅ TypeScript knows:
// err → null
// user → User (not null, not undefined)
console.log(user.name);- No type casting. No as. No !. It just works.
API Reference
- safe(promise) Wraps a promise into a result tuple.
const [err, result] = await safe(somePromise)
Parameter Type Description
promise Promise<T> Any promise
Returns: Promise<[Error, null] \| [null, T]>
safe.sync(fn)
- Wraps a sync function into a result tuple.
const [err, result] = safe.sync(() => riskyOperation())
Parameter Type
fn () => T Any function that might throw
Returns: [Error, null] \| [null, T]
safe.pipe(...fns)
- Chains functions sequentially. Each function receives the result of the previous one. Stops at the first error.
const [err, result] = await safe.pipe(() => step1(),
(prev) => step2(prev),
(prev) => step3(prev),
)
Parameter Type Description
...fns Function[] Functions to chain (sync or async)
Returns: Promise<[Error, null] \| [null, T]>safe.all(promises)
- Like Promise.all but returns a result tuple.
const [err, results] = await safe.all([p1, p2, p3])
Parameter Type Description
promises Promise[] Array of promises
Returns: Promise<[Error, null] \| [null, T[]]>safe.race(promises)
- Like Promise.race but returns a result tuple.
const [err, fastest] = await safe.race([p1, p2])
Parameter Type Description
promises Promise[] Array of promises
Returns: Promise<[Error, null] \| [null, T]>safe.retry(fn, options?)
- Retries a function with configurable attempts, delay and backoff.
const [err, result] = await safe.retry(fn, {
attempts: 3,
delay: 1000,
backoff: true,
onRetry: (err, attempt) => {},
})
Option Type Default Description
attempts number 3 Maximum number of attempts
delay number 0 Delay between retries in ms
backoff boolean false Use exponential backoff
onRetry (err, attempt) => void — Called after each failed attempt
Returns: Promise<[Error, null] \| [null, T]>Backoff example:
delay: 1000, backoff: true
Attempt 1 fails → waits 1000ms
Attempt 2 fails → waits 2000ms
Attempt 3 fails → waits 4000ms
Attempt 4 fails → returns [error, null]safe.wrap(fn)
- Wraps any function to always return a result tuple instead of throwing.
const safeFn = safe.wrap(originalFn)
const [err, result] = safeFn(args)
Parameter Type Description
fn Function Any sync or async function
Returns: Wrapped function that returns Result<T> (or Promise<Result<T>> for async)Real-World Examples
- Express / Fastify Route
import { safe } from "try-tuple";
app.post("/checkout", async (req, res) => {
const [err, user] = await safe(db.users.findById(req.userId));
if (err) return res.status(500).json({ error: "Database error" });
if (!user) return res.status(404).json({ error: "User not found" });
const [err2, charge] = await safe(
stripe.charges.create({
amount: req.body.amount,
customer: user.stripeId,
}),
);
if (err2) return res.status(402).json({ error: "Payment failed" });
const [err3, order] = await safe(
db.orders.create({
userId: user.id,
chargeId: charge.id,
amount: req.body.amount,
}),
);
if (err3) return res.status(500).json({ error: "Could not create order" });
res.json({ orderId: order.id, status: "confirmed" });
});- File Operations
import { safe } from 'try-tuple'
import { readFile, writeFile } from 'fs/promises'
async function updateConfig(key: string, value: string) {
const [readErr, raw] = await safe(readFile('config.json', 'utf-8'))
if (readErr) return console.error('Cannot read config:', readErr.message)
const [parseErr, config] = safe.sync(() => JSON.parse(raw))
if (parseErr) return console.error('Invalid JSON:', parseErr.message)
config[key] = value
config.updatedAt = new Date().toISOString()
const [writeErr] = await safe(
writeFile('config.json', JSON.stringify(config, null, 2))
)
if (writeErr) return console.error('Cannot write config:', writeErr.message)
console.log(`✅ Updated ${key}`)
}External API with Retry
import { safe } from 'try-tuple'
async function fetchUserData(userId: string) {
const [err, data] = await safe.retry(async () => {
const res = await fetch(`https://api.example.com/users/${userId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}, {
attempts: 3,
delay: 500,
backoff: true,
onRetry: (err, attempt) => {
console.log(`⚠️ Attempt ${attempt} failed: ${err.message}`)
}})
if (err) {
console.error('All attempts failed:', err.message)
return null
}
return data
}Full Pipeline
import { safe } from 'try-tuple'
async function processOrder(userId: string, amount: number) {
const [err, result] = await safe.pipe(
() => db.users.findById(userId),
(user) => stripe.charges.create({ amount, customer: user.stripeId }),
(charge) => db.orders.create({ userId, chargeId: charge.id, amount }),
(order) => email.send({
to: userId,
subject: `Order ${order.id} confirmed`,
body: `You were charged $${amount / 100}`,
}),
)
if (err) {
console.error('Order failed:', err.message)
await notifyTeam(err)
return null
}
return result
}Wrapping Libraries
import { safe } from 'try-tuple'
import jwt from 'jsonwebtoken'
// Wrap once
const safeVerify = safe.wrap(
(token: string) => jwt.verify(token, process.env.JWT_SECRET!)
)
// Use everywhere
app.use(async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).json({ error: 'No token' })
const [err, payload] = safeVerify(token)
if (err) return res.status(401).json({ error: 'Invalid token' })
req.user = payload
next()
})Comparison
- try/catch vs try-tuple
// ❌ try/catch
let user;
try {
user = await db.findUser(id);
} catch (err) {
return handleError(err);
}
// user might still be undefined here 😬
// ✅ try-tuple
const [err, user] = await safe(db.findUser(id));
if (err) return handleError(err);
// user is guaranteed to be defined here 😊- Why not just .catch()?
// ❌ .catch() — awkward flow
const user = await db.findUser(id).catch((err) => {
handleError(err);
return null;
});
if (!user) return; // is it null because of error or because user doesn't exist?
// ✅ try-tuple — clear distinction
const [err, user] = await safe(db.findUser(id));
if (err) return handleError(err); // error
if (!user) return handleNotFound(); // no userError Coercion
- try-tuple always gives you a proper Error object, even when the thrown value is not an Error:
// throw 'oops' → Error('oops')
// throw 404 → Error('404')
// throw { code: 'FAIL' } → Error('[object Object]')
// throw new Error('ok') → Error('ok') (unchanged)❓ FAQ
Does it work with any promise?
- Yes. fetch, database queries, file operations, anything that returns a Promise.
Does it add overhead?
- Negligible. It's just a try/catch wrapper (~1KB). No runtime magic.
Can I use it with Express/Fastify/Hono?
- Yes. Works with any framework. It's just a function.
Does it work in the browser?
- Yes. Zero Node.js dependencies.
CommonJS or ESM?
- Both. The package ships with .js (CJS) and .mjs (ESM).
// ESM
import { safe } from "try-tuple";
// CJS
const { safe } = require("try-tuple");📄 License
Published under the MIT license. Made by Wallace Frota
