Skip to main content
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:
src/lib/errors/index.ts
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:
src/lib/errors/types.ts
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

src/lib/errors/index.ts
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

src/lib/errors/index.ts
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

ApproachExplicit errorsType-safeComposableNo 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.

Build docs developers (and LLMs) love