Claude Code Plugins

Community-maintained marketplace

Feedback

Never throw for expected failures. Use Result<T, E> types with explicit error handling and workflow composition.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name result-types
description Never throw for expected failures. Use Result<T, E> types with explicit error handling and workflow composition.
version 1.0.0
libraries @jagreehal/workflow

Typed Errors: Never Throw

Core Principle

Exceptions are invisible, bypass composition, and conflate different failures. Return Result<T, E> instead.

// WRONG - Signature lies
async function getUser(args): Promise<User> {
  const user = await deps.db.findUser(args.userId);
  if (!user) throw new Error('User not found');  // Hidden!
  return user;
}

// CORRECT - Signature tells the truth
async function getUser(args, deps): Promise<Result<User, 'NOT_FOUND' | 'DB_ERROR'>> {
  try {
    const user = await deps.db.findUser(args.userId);
    return user ? ok(user) : err('NOT_FOUND');
  } catch {
    return err('DB_ERROR');
  }
}

The Result Type

type Result<T, E> =
  | { ok: true; value: T }
  | { ok: false; error: E };

type AsyncResult<T, E> = Promise<Result<T, E>>;

const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
const err = <E>(error: E): Result<never, E> => ({ ok: false, error });

Required Behaviors

1. Business Functions Return Results

async function getUser(
  args: { userId: string },
  deps: GetUserDeps
): Promise<Result<User, 'NOT_FOUND' | 'DB_ERROR'>> {
  try {
    const user = await deps.db.findUser(args.userId);
    if (!user) return err('NOT_FOUND');
    return ok(user);
  } catch {
    return err('DB_ERROR');
  }
}

2. Use createWorkflow() for Composition

Avoid verbose if-checking with railway-oriented programming:

import { createWorkflow } from '@jagreehal/workflow';

// Declare dependencies -> error union computed automatically
const loadUserData = createWorkflow({ getUser, getPosts, enrichUser });

const result = await loadUserData(async (step) => {
  const user = await step(() => getUser({ userId }, deps));
  const posts = await step(() => getPosts({ userId: user.id }, deps));
  const enriched = await step(() => enrichUser({ user, posts }, deps));

  return { user: enriched };
});

// result: Result<{ user: EnrichedUser }, 'NOT_FOUND' | 'DB_ERROR' | 'FETCH_ERROR' | ...>

The step() function:

  • Unwraps ok results and continues on happy path
  • On err, immediately short-circuits and skips remaining steps

3. Use step.try() for Throwing Code

Bridge between throwing code and Result pipeline:

const workflow = createWorkflow({ getUser });

const result = await workflow(async (step) => {
  const user = await step(() => getUser({ userId }, deps));

  // Throwing function: use step.try() with error mapping
  const config = await step.try(
    () => JSON.parse(user.configJson),
    { error: 'INVALID_CONFIG' as const }
  );

  return { user, config };
});
  • step(): For functions that already return Result (your code)
  • step.try(): For functions that throw (third-party, built-in)
  • step.fromResult(): For Result-returning functions where you need to map errors

For Result-returning functions: Use step.fromResult() to preserve typed errors:

// callProvider returns Result<Response, ProviderError>
const callProvider = async (input: string): AsyncResult<Response, ProviderError> => { ... };

const response = await step.fromResult(
  () => callProvider(input),
  {
    onError: (e) => ({
      type: 'PROVIDER_FAILED' as const,
      provider: e.provider,  // TypeScript knows e is ProviderError
      code: e.code,
    })
  }
);

4. Map Results to HTTP at Boundary

const errorToStatus: Record<string, number> = {
  NOT_FOUND: 404,
  UNAUTHORIZED: 401,
  FORBIDDEN: 403,
  VALIDATION_FAILED: 400,
  CONFLICT: 409,
};

function resultToResponse<T, E extends string>(
  result: Result<T, E>,
  res: Response
): Response {
  if (result.ok) {
    return res.status(200).json(result.value);
  }

  const status = errorToStatus[result.error] ?? 500;
  return res.status(status).json({
    error: result.error,
    code: result.error,
  });
}

// Handler becomes simple
app.get('/users/:id', async (req, res) => {
  const result = await getUser({ userId: req.params.id }, deps);
  return resultToResponse(result, res);
});

5. Exhaustive Error Handling

TypeScript enforces handling all error cases:

if (!result.ok) {
  switch (result.error) {
    case 'NOT_FOUND':
      return res.status(404).json({ error: 'User not found' });
    case 'DB_ERROR':
    case 'FETCH_ERROR':
      return res.status(500).json({ error: 'Internal error' });
    // TypeScript will error if you miss a case!
  }
}

Error Type Patterns

String Literals (Simple)

type AppError = 'NOT_FOUND' | 'UNAUTHORIZED' | 'DB_ERROR';

Discriminated Unions (Rich)

type AppError =
  | { type: 'NOT_FOUND'; resource: string }
  | { type: 'VALIDATION'; field: string; message: string }
  | { type: 'DB_ERROR'; query: string };

Const Objects (Runtime + Type)

const Errors = {
  NOT_FOUND: 'NOT_FOUND',
  DB_ERROR: 'DB_ERROR',
} as const;

type AppError = (typeof Errors)[keyof typeof Errors];

return err(Errors.NOT_FOUND);  // Runtime value available

Error Grouping at Scale

As applications grow, error unions become unwieldy:

// This becomes a "Type Wall"
type AllErrors =
  | 'NOT_FOUND'
  | 'DB_ERROR'
  | 'DB_CONNECTION_FAILED'
  | 'DB_TIMEOUT'
  | 'FETCH_ERROR'
  | 'HTTP_TIMEOUT'
  | 'RATE_LIMITED'
  | 'CIRCUIT_OPEN'
  | 'VALIDATION_FAILED'
  // ... 20 more errors

Solution: Group related errors into categories:

// Group by domain
type DatabaseError = 'DB_ERROR' | 'DB_CONNECTION_FAILED' | 'DB_TIMEOUT';
type NetworkError = 'FETCH_ERROR' | 'HTTP_TIMEOUT' | 'RATE_LIMITED';
type BusinessError = 'NOT_FOUND' | 'VALIDATION_FAILED' | 'UNAUTHORIZED';

type AppError = DatabaseError | NetworkError | BusinessError;

// Or use discriminated unions for richer context
type AppError =
  | { type: 'DATABASE'; code: 'CONNECTION_FAILED' | 'TIMEOUT' | 'QUERY_FAILED' }
  | { type: 'NETWORK'; code: 'TIMEOUT' | 'RATE_LIMITED' | 'UNREACHABLE' }
  | { type: 'BUSINESS'; code: 'NOT_FOUND' | 'VALIDATION_FAILED' };

This keeps error types manageable while preserving type safety.

When Throwing Is Still Right

Throw only for:

  • Invariant violation (programmer error, impossible state)
  • Corrupted process state (can't recover)
  • Truly unrecoverable situations
// Good: throw for impossible states
if (!user) throw new Error('Unreachable: user should exist after insert');

Using asserts for Type Narrowing

The asserts keyword creates runtime checks that also narrow types:

// Assert function: throws if condition fails, narrows type if succeeds
function assertUser(user: User | null): asserts user is User {
  if (!user) throw new Error('Invariant violated: user must exist');
}

function assertDefined<T>(value: T | undefined, name: string): asserts value is T {
  if (value === undefined) throw new Error(`${name} must be defined`);
}

// Usage: TypeScript narrows the type after the assertion
const user = await deps.db.findUser(userId);
assertUser(user);  // Throws if null
// TypeScript now knows `user` is `User`, not `User | null`
console.log(user.name);  // Safe access

When to use asserts:

  • After database inserts (record MUST exist)
  • After config loading (values MUST be present)
  • After state transitions (state MUST be valid)

Don't use for: Normal business logic failures (use Result instead)

Quick Reference

Situation Use
Domain failure (not found, validation) Result
Infrastructure failure (recoverable) Result
Programmer error throw
Corrupted state throw

Architecture Layer

Handlers / Routes
  -> map Result -> HTTP response

Business Logic
  -> createWorkflow({ ... })(async (step) => { ... })

Core Functions
  -> fn(args, deps): Result<T, E>

Infrastructure
  -> catch exceptions, return Results