Semola takes a different approach to error handling. Instead of throwing exceptions and wrapping everything in try-catch blocks, Semola uses result tuples—a pattern that makes errors explicit, composable, and impossible to ignore.
The result tuple pattern
A result tuple is an array with exactly two elements: [error, data]
- When an operation succeeds:
[null, data]
- When an operation fails:
[error, null]
import { ok, err } from 'semola/errors';
// Success case
const success = ok({ userId: '123', name: 'Alice' });
// [null, { userId: '123', name: 'Alice' }]
// Error case
const failure = err('NotFoundError', 'User not found');
// [{ type: 'NotFoundError', message: 'User not found' }, null]
The ok() and err() helpers
Semola provides two simple functions for creating result tuples:
export const ok = <T>(data: T) => {
return [null, data] as const;
};
export const err = <T extends CommonError>(type: T, message: string) => {
return [{ type, message }, null] as const;
};
These helpers:
- Return immutable tuples (using
as const)
- Preserve TypeScript type information
- Make success and failure paths explicit
Error types
Semola defines common error types, but you can use custom strings too:
export type CommonError =
| "NotFoundError"
| "UnauthorizedError"
| "InternalServerError"
| "ValidationError"
| (string & {});
The (string & {}) allows any custom error type while preserving autocomplete for common ones.
Wrapping throwing code
Many JavaScript APIs throw exceptions. Semola provides wrappers to convert them into result tuples:
mightThrow for promises
export const mightThrow = async <T>(promise: Promise<T>) => {
try {
const data = await promise;
return [null, data] as const;
} catch (error) {
return [error, null] as const;
}
};
Use it to handle async operations that might fail:
import { mightThrow } from 'semola/errors';
const [error, data] = await mightThrow(fetch('https://api.example.com'));
if (error) {
console.error('Request failed:', error);
return;
}
console.log('Success:', data);
mightThrowSync for synchronous code
export const mightThrowSync = <T>(fn: () => T) => {
try {
const result = fn();
return [null, result] as const;
} catch (error) {
return [error, null] as const;
}
};
Use it for synchronous operations:
import { mightThrowSync } from 'semola/errors';
const [parseError, parsed] = mightThrowSync(() => JSON.parse(jsonString));
if (parseError) {
return err('ValidationError', 'Invalid JSON');
}
console.log('Parsed:', parsed);
Why not try-catch?
Traditional error handling with try-catch has several problems:
1. Errors are invisible
// Which line can throw? You have to read the implementation.
async function getUser(id: string) {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data;
}
With result tuples, errors are explicit:
async function getUser(id: string) {
const [fetchError, response] = await mightThrow(fetch(`/api/users/${id}`));
if (fetchError) return err('NetworkError', 'Failed to fetch user');
const [parseError, data] = await mightThrow(response.json());
if (parseError) return err('ParseError', 'Invalid response format');
return ok(data);
}
2. Easy to ignore errors
// Forgot to catch? Runtime error.
const data = await riskyOperation();
With result tuples, TypeScript forces you to handle both cases:
const [error, data] = await riskyOperation();
// TypeScript knows 'data' might be null
// You must check 'error' before using 'data'
3. Control flow is implicit
try {
const user = await getUser(id);
const posts = await getPosts(user.id);
return { user, posts };
} catch (error) {
// Which operation failed? When did it fail?
console.error(error);
}
With result tuples, control flow is explicit:
const [userError, user] = await getUser(id);
if (userError) {
console.error('Failed to get user:', userError);
return err('NotFoundError', 'User not found');
}
const [postsError, posts] = await getPosts(user.id);
if (postsError) {
console.error('Failed to get posts:', postsError);
return err('InternalServerError', 'Could not load posts');
}
return ok({ user, posts });
4. Nested try-catch blocks are ugly
try {
const response = await fetch(url);
try {
const data = await response.json();
return data;
} catch (parseError) {
console.error('Parse failed:', parseError);
}
} catch (fetchError) {
console.error('Fetch failed:', fetchError);
}
With result tuples, there’s no nesting:
const [fetchError, response] = await mightThrow(fetch(url));
if (fetchError) {
console.error('Fetch failed:', fetchError);
return err('NetworkError', 'Request failed');
}
const [parseError, data] = await mightThrow(response.json());
if (parseError) {
console.error('Parse failed:', parseError);
return err('ParseError', 'Invalid response');
}
return ok(data);
Real-world examples
API request validation
Semola’s validation functions return result tuples:
src/lib/api/validation/index.ts
export const validateBody = async (
req: Request,
bodySchema?: StandardSchemaV1,
bodyCache?: BodyCache,
) => {
if (!bodySchema) {
return ok(true);
}
const contentType = req.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
return ok(undefined);
}
// Use mightThrow to handle JSON parsing
const [parseError, parsedBody] = await mightThrow(req.json());
if (parseError) {
return err("ParseError", "Invalid JSON body");
}
// Validate against schema
return validateSchema(bodySchema, parsedBody);
};
This allows route handlers to check for errors explicitly:
src/lib/api/core/index.ts
const [err, val] = await validateBody(req, schema.body, bodyCache);
if (err) {
return responseHelpers.json(400, { message: err.message });
}
v.body = val;
Chaining operations
Result tuples compose naturally:
import { ok, err, mightThrow } from 'semola/errors';
async function processUser(id: string) {
// Fetch user
const [fetchError, response] = await mightThrow(
fetch(`/api/users/${id}`)
);
if (fetchError) {
return err('NetworkError', 'Failed to fetch user');
}
// Parse JSON
const [parseError, data] = await mightThrow(response.json());
if (parseError) {
return err('ParseError', 'Invalid user data');
}
// Validate schema
const [validationError, user] = await validateSchema(userSchema, data);
if (validationError) {
return err('ValidationError', validationError.message);
}
return ok(user);
}
// Usage
const [error, user] = await processUser('123');
if (error) {
console.error(`${error.type}: ${error.message}`);
return;
}
console.log('User loaded:', user);
Integration with tests
Result tuples make testing error paths straightforward:
src/lib/errors/index.test.ts
test("should return error tuple when promise rejects", async () => {
const promise = Promise.reject(new Error("Promise failed"));
const [error, data] = await mightThrow(promise);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("Promise failed");
expect(data).toBeNull();
});
test("should work with JSON.parse failure", () => {
const [error, data] = mightThrowSync(() => JSON.parse("invalid json"));
expect(error).toBeInstanceOf(SyntaxError);
expect(data).toBeNull();
});
Type safety with result tuples
TypeScript understands result tuples through discriminated unions:
const [error, data] = await riskyOperation();
if (error) {
// TypeScript knows:
// - error is not null
// - data is null
console.error(error.message);
return;
}
// TypeScript knows:
// - error is null
// - data is not null
console.log(data.someProperty);
No need for type assertions or non-null assertions (!).
When to use result tuples
Use result tuples when:
- Errors are expected (network requests, file I/O, validation)
- You want explicit error handling
- You need to chain operations that might fail
You can still use throw for:
- Unexpected errors (programming bugs, assertion failures)
- Library boundaries where throwing is conventional
Result tuples work great with early returns. Check for errors, return early, and keep the happy path unindented.
Comparison with other approaches
| Approach | Explicit errors | Type-safe | Composable | No nesting |
|---|
| Result tuples | ✅ | ✅ | ✅ | ✅ |
| Try-catch | ❌ | ⚠️ | ❌ | ❌ |
| Error-first callbacks | ✅ | ❌ | ⚠️ | ✅ |
| Result objects | ✅ | ✅ | ⚠️ | ✅ |
Semola’s result tuple pattern is inspired by Go’s error handling and Rust’s Result type, adapted for TypeScript’s type system.