outfitter-dev

stack-patterns

Reference for Outfitter Stack patterns including Result types, Handler contract, Error taxonomy, and @outfitter/* package conventions. Use when learning the stack, looking up patterns, understanding packages, or when "Result", "Handler", "error taxonomy", "OutfitterError", "CLI output", "pagination", "MCP server", "MCP tool", "structured logging", "redaction", "test handler", "daemon", "IPC", or "@outfitter/*" are mentioned.

outfitter-dev 26 Updated 4mo ago

Resources

1
GitHub

Install

npx skillscat add outfitter-dev/agents/stack-patterns

Install via the SkillsCat registry.

SKILL.md

Outfitter Stack Patterns

Primary reference for @outfitter/* package conventions.

Handler Contract

Handlers are pure functions that:

  • Accept typed input and context
  • Return Result<TOutput, TError>
  • Know nothing about transport (CLI flags, HTTP headers, MCP tool schemas)
type Handler<TInput, TOutput, TError extends OutfitterError> = (
  input: TInput,
  ctx: HandlerContext
) => Promise<Result<TOutput, TError>>;

Example

import { Result, NotFoundError, type Handler } from "@outfitter/contracts";

export const getUser: Handler<{ id: string }, User, NotFoundError> = async (input, ctx) => {
  ctx.logger.debug("Fetching user", { userId: input.id });
  const user = await db.users.findById(input.id);

  if (!user) {
    return Result.err(new NotFoundError("user", input.id));
  }
  return Result.ok(user);
};

Why? Testability (just call the function), reusability (same handler for CLI/MCP/HTTP), type safety (explicit types), composability (handlers wrap handlers).

Result Types

Uses Result<T, E> from better-result for explicit error handling.

import { Result } from "@outfitter/contracts";

// Create
const ok = Result.ok({ name: "Alice" });
const err = Result.err(new NotFoundError("user", "123"));

// Check
if (result.isOk()) {
  console.log(result.value);  // TypeScript knows T
} else {
  console.log(result.error);  // TypeScript knows E
}

// Pattern match
const message = result.match({
  ok: (user) => `Found ${user.name}`,
  err: (error) => `Error: ${error.message}`,
});

// Combine
const combined = combine2(result1, result2);  // tuple or first error

Error Taxonomy

Ten categories map to exit codes and HTTP status:

Category Exit HTTP When to Use
validation 1 400 Invalid input, schema failures
not_found 2 404 Resource doesn't exist
conflict 3 409 Already exists, version mismatch
permission 4 403 Forbidden action
timeout 5 504 Operation took too long
rate_limit 6 429 Too many requests
network 7 503 Connection failures
internal 8 500 Unexpected errors, bugs
auth 9 401 Authentication required
cancelled 130 499 User interrupted (Ctrl+C)
import { ValidationError, NotFoundError, getExitCode } from "@outfitter/contracts";

new ValidationError("Invalid email", { field: "email" });
new NotFoundError("user", "user-123");

getExitCode(error.category);   // 2 for not_found
getStatusCode(error.category); // 404 for not_found

Validation

Use Zod with createValidator for type-safe validation returning Results:

import { createValidator } from "@outfitter/contracts";
import { z } from "zod";

const InputSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

const validateInput = createValidator(InputSchema);

// In handler
const inputResult = validateInput(rawInput);
if (inputResult.isErr()) return inputResult;
const input = inputResult.value;  // typed as z.infer<typeof InputSchema>

Context

HandlerContext carries cross-cutting concerns:

import { createContext } from "@outfitter/contracts";

const ctx = createContext({
  logger: myLogger,           // structured logger
  config: resolvedConfig,     // merged configuration
  signal: controller.signal,  // cancellation
  workspaceRoot: "/project",
});

// ctx.requestId is auto-generated UUIDv7 for tracing
Field Type Description
requestId string Auto-generated UUIDv7
logger Logger Structured logger
config ResolvedConfig Merged config
signal AbortSignal Cancellation signal
workspaceRoot string Project root
cwd string Current directory

Package Reference

Package Purpose When to Use
@outfitter/contracts Result types, errors, Handler contract Always (foundation)
@outfitter/types Type utilities, collection helpers Type manipulation
@outfitter/cli CLI commands, output modes, formatting CLI applications
@outfitter/mcp MCP server, tool registration, Zod schemas AI agent tools
@outfitter/config XDG paths, config loading, env handling Configuration needed
@outfitter/logging Structured logging, sinks, redaction Logging needed
@outfitter/daemon Background services, IPC, health checks Long-running services
@outfitter/file-ops Secure paths, atomic writes, file locking File operations
@outfitter/state Pagination, cursor state Paginated data
@outfitter/testing Test harnesses, fixtures, Bun test Testing

Selection guidance:

  • All projects start with @outfitter/contracts
  • CLI apps add @outfitter/cli (includes UI components)
  • MCP servers add @outfitter/mcp
  • Projects with config add @outfitter/config
  • File operations need @outfitter/file-ops for safety

Type Utilities

@outfitter/types provides collection helpers and type utilities:

Collection Helpers

import { sortBy, dedupe, chunk } from "@outfitter/types";

// Sort by property
const users = [{ name: "Bob" }, { name: "Alice" }];
sortBy(users, "name");         // [{ name: "Alice" }, { name: "Bob" }]
sortBy(users, u => u.name);    // Same, with accessor function

// Remove duplicates
dedupe([1, 2, 2, 3, 3, 3]);    // [1, 2, 3]
dedupe(users, u => u.name);    // Dedupe by property

// Split into chunks
chunk([1, 2, 3, 4, 5], 2);     // [[1, 2], [3, 4], [5]]

Type Utilities

Standard TypeScript utility types for common patterns:

import type { Prettify, DeepPartial, Nullable } from "@outfitter/types";

// Prettify: Flatten complex intersection types for better IntelliSense
type Combined = { a: string } & { b: number };
type Pretty = Prettify<Combined>;  // Shows { a: string; b: number }

// DeepPartial: Make all properties optional recursively
type Config = { db: { host: string; port: number } };
type PartialConfig = DeepPartial<Config>;

// Nullable: T | null
type MaybeUser = Nullable<User>;

Domain Error Mapping

Map your domain errors to the 10 taxonomy categories:

Domain Error Stack Category Error Class Exit HTTP
Not found not_found NotFoundError 2 404
Invalid input validation ValidationError 1 400
Already exists conflict ConflictError 3 409
No permission permission PermissionError 4 403
Auth required auth AuthError 9 401
Timed out timeout TimeoutError 5 504
Connection failed network NetworkError 7 503
Limit exceeded rate_limit RateLimitError 6 429
Bug/unexpected internal InternalError 8 500
User cancelled cancelled CancelledError 130 499

Mapping examples:

// "User not found" -> NotFoundError
new NotFoundError("user", userId);

// "Invalid email format" -> ValidationError
new ValidationError("Invalid email", { field: "email" });

// "User already exists" -> ConflictError
new ConflictError("Email already registered", { email });

// "Cannot delete admin" -> PermissionError
new PermissionError("Cannot delete admin users");

// Unexpected errors -> InternalError
new InternalError("Database connection failed", { cause: error });

Bun-First APIs

Prefer Bun-native APIs:

Need Bun API
Hashing Bun.hash()
Globbing Bun.Glob
Semver Bun.semver
Shell Bun.$
Colors Bun.color()
String width Bun.stringWidth()
SQLite bun:sqlite
UUID v7 Bun.randomUUIDv7()

References

Core Patterns

Package Deep Dives