askill
result-types

result-typesSafety 100Repository

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

1 stars
1.2k downloads
Updated 1/15/2026

Package Files

Loading files...
SKILL.md

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

SituationUse
Domain failure (not found, validation)Result
Infrastructure failure (recoverable)Result
Programmer errorthrow
Corrupted statethrow

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

Install

Download ZIP
Requires askill CLI v1.0+

AI Quality Score

92/100Analyzed 2/19/2026

Excellent skill document on Result<T, E> types and error handling. Covers the core principle of never throwing for expected failures, provides extensive code examples for Result type definition, workflow composition using createWorkflow, step.try() for bridging throwing code, and mapping to HTTP responses. Includes error type patterns (string literals, discriminated unions, const objects), grouping strategies for scaling, and when throwing is appropriate. Structured with quick reference table and architecture layer diagram. Slight扣分 for library-specific references but overall highly actionable and reusable for TypeScript error handling best practices.

100
95
85
95
95

Metadata

Licenseunknown
Version1.0.0
Updated1/15/2026
Publisherjagreehal

Tags

apici-cddatabasegithub-actions