wellcrafted
Delightful TypeScript utilities for elegant, type-safe applications
Transform unpredictable errors into type-safe results
// ❌ Before: Which errors can this throw? 🤷
try {
await saveUser(user);
} catch (error) {
// ... good luck debugging in production
}
// ✅ After: Every error is visible and typed
const { data, error } = await saveUser(user);
if (error) {
switch (error.name) {
case "ValidationError":
showToast(`Invalid ${error.context.field}`);
break;
case "AuthError":
redirectToLogin();
break;
// TypeScript ensures you handle all cases!
}
}
A collection of simple, powerful primitives
🎯 Result Type
Make errors explicit in function signatures
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return Err("Division by zero");
return Ok(a / b);
}
🏷️ Brand Types
Create distinct types from primitives
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// TypeScript prevents mixing them up!
function getUser(id: UserId) { /* ... */ }
📋 Tagged Errors
Structured, serializable errors with convenient factory functions
import { createTaggedError } from "wellcrafted/error";
const { ApiError, ApiErr } = createTaggedError("ApiError");
// ApiError() creates error object, ApiErr() creates Err-wrapped error
Installation
npm install wellcrafted
Quick Start
import { tryAsync } from "wellcrafted/result";
import { createTaggedError } from "wellcrafted/error";
// Define your error with factory function
const { ApiError, ApiErr } = createTaggedError("ApiError");
type ApiError = ReturnType<typeof ApiError>;
// Wrap any throwing operation
const { data, error } = await tryAsync({
try: () => fetch('/api/user').then(r => r.json()),
mapErr: (error) => ApiErr({
message: "Failed to fetch user",
context: { endpoint: '/api/user' },
cause: error
})
});
if (error) {
console.error(`${error.name}: ${error.message}`);
} else {
console.log("User:", data);
}
Core Features
🎯 Explicit Error Handling All errors visible in function signatures |
📦 Serialization-Safe Plain objects work everywhere |
✨ Elegant API Clean, intuitive patterns |
🔍 Zero Magic ~50 lines of core code |
🚀 Lightweight Zero dependencies, < 2KB |
🎨 Composable Mix and match utilities |
The Result Pattern Explained
The Result type makes error handling explicit and type-safe:
// The entire implementation
type Ok<T> = { data: T; error: null };
type Err<E> = { error: E; data: null };
type Result<T, E> = Ok<T> | Err<E>;
The Magic: This creates a discriminated union where TypeScript automatically narrows types:
if (result.error) {
// TypeScript knows: error is E, data is null
} else {
// TypeScript knows: data is T, error is null
}
Basic Patterns
Handle Results with Destructuring
const { data, error } = await someOperation();
if (error) {
// Handle error with full type safety
return;
}
// Use data - TypeScript knows it's safe
Wrap Unsafe Operations
// Synchronous
const result = trySync({
try: () => JSON.parse(jsonString),
mapErr: (error) => Err({
name: "ParseError",
message: "Invalid JSON",
context: { input: jsonString },
cause: error
})
});
// Asynchronous
const result = await tryAsync({
try: () => fetch(url),
mapErr: (error) => Err({
name: "NetworkError",
message: "Request failed",
context: { url },
cause: error
})
});
Service Layer Example
import { Result, Ok, tryAsync } from "wellcrafted/result";
import { createTaggedError } from "wellcrafted/error";
// Define service-specific errors
const { ValidationError, ValidationErr } = createTaggedError("ValidationError");
const { DatabaseError, DatabaseErr } = createTaggedError("DatabaseError");
type ValidationError = ReturnType<typeof ValidationError>;
type DatabaseError = ReturnType<typeof DatabaseError>;
// Factory function pattern - no classes!
export function createUserService(db: Database) {
return {
async createUser(input: CreateUserInput): Promise<Result<User, ValidationError | DatabaseError>> {
// Direct return with Err variant
if (!input.email.includes('@')) {
return ValidationErr({
message: "Invalid email format",
context: { field: 'email', value: input.email },
cause: undefined
});
}
return tryAsync({
try: () => db.save(input),
mapErr: (error) => DatabaseErr({
message: "Failed to save user",
context: { operation: 'createUser', input },
cause: error
})
});
},
async getUser(id: string): Promise<Result<User | null, DatabaseError>> {
return tryAsync({
try: () => db.findById(id),
mapErr: (error) => DatabaseErr({
message: "Failed to fetch user",
context: { userId: id },
cause: error
})
});
}
};
}
// Export type for the service
export type UserService = ReturnType<typeof createUserService>;
// Create a live instance (dependency injection at build time)
export const UserServiceLive = createUserService(databaseInstance);
Why wellcrafted?
JavaScript's try-catch
has fundamental problems:
- Invisible Errors: Function signatures don't show what errors can occur
- Lost in Transit:
JSON.stringify(new Error())
loses critical information - No Type Safety: TypeScript can't help with
catch (error)
blocks - Inconsistent: Libraries throw different things (strings, errors, objects, undefined)
wellcrafted solves these with simple, composable primitives that make errors:
- Explicit in function signatures
- Serializable across all boundaries
- Type-safe with full TypeScript support
- Consistent with structured error objects
Service Pattern Best Practices
Based on real-world usage, here's the recommended pattern for creating services with wellcrafted:
Factory Function Pattern
import { createTaggedError } from "wellcrafted/error";
// 1. Define service-specific errors
const { RecorderServiceError, RecorderServiceErr } = createTaggedError("RecorderServiceError");
type RecorderServiceError = ReturnType<typeof RecorderServiceError>;
// 2. Create service with factory function
export function createRecorderService() {
// Private state in closure
let isRecording = false;
// Return object with methods
return {
startRecording(): Result<void, RecorderServiceError> {
if (isRecording) {
return RecorderServiceErr({
message: "Already recording",
context: { isRecording },
cause: undefined
});
}
isRecording = true;
return Ok(undefined);
},
stopRecording(): Result<Blob, RecorderServiceError> {
if (!isRecording) {
return RecorderServiceErr({
message: "Not currently recording",
context: { isRecording },
cause: undefined
});
}
isRecording = false;
return Ok(new Blob(["audio data"]));
}
};
}
// 3. Export type
export type RecorderService = ReturnType<typeof createRecorderService>;
// 4. Create singleton instance
export const RecorderServiceLive = createRecorderService();
Platform-Specific Services
For services that need different implementations per platform:
// types.ts - shared interface
export type FileService = {
readFile(path: string): Promise<Result<string, FileServiceError>>;
writeFile(path: string, content: string): Promise<Result<void, FileServiceError>>;
};
// desktop.ts
export function createFileServiceDesktop(): FileService {
return {
async readFile(path) {
// Desktop implementation using Node.js APIs
},
async writeFile(path, content) {
// Desktop implementation
}
};
}
// web.ts
export function createFileServiceWeb(): FileService {
return {
async readFile(path) {
// Web implementation using File API
},
async writeFile(path, content) {
// Web implementation
}
};
}
// index.ts - runtime selection
export const FileServiceLive = typeof window !== 'undefined'
? createFileServiceWeb()
: createFileServiceDesktop();
Common Use Cases
typescript
export async function GET(request: Request) {
const result = await userService.getUser(params.id);
if (result.error) {
switch (result.error.name) {
case "UserNotFoundError":
return new Response("Not found", { status: 404 });
case "DatabaseError":
return new Response("Server error", { status: 500 });
}
}
return Response.json(result.data);
}
typescript
function validateLoginForm(data: unknown): Result<LoginData, FormError> {
const errors: Record<string, string[]> = {};
if (!isValidEmail(data?.email)) {
errors.email = ["Invalid email format"];
}
if (Object.keys(errors).length > 0) {
return Err({
name: "FormError",
message: "Validation failed",
context: { fields: errors },
cause: undefined
});
}
return Ok(data as LoginData);
}
typescript
function useUser(id: number) {
const [state, setState] = useState<{
loading: boolean;
user?: User;
error?: ApiError;
}>({ loading: true });
useEffect(() => {
fetchUser(id).then(result => {
if (result.error) {
setState({ loading: false, error: result.error });
} else {
setState({ loading: false, user: result.data });
}
});
}, [id]);
return state;
}
Comparison with Alternatives
wellcrafted | fp-ts | Effect | neverthrow | |
---|---|---|---|---|
Learning Curve | Minimal | Steep | Steep | Moderate |
Syntax | Native async/await | Pipe operators | Generators | Method chains |
Bundle Size | < 2KB | ~30KB | ~50KB | ~5KB |
Type Safety | ✅ Full | ✅ Full | ✅ Full | ✅ Full |
Serializable Errors | ✅ Built-in | ❌ Classes | ❌ Classes | ❌ Classes |
API Reference
Result Functions
Ok(data)
- Create success resultErr(error)
- Create failure resultisOk(result)
- Type guard for successisErr(result)
- Type guard for failuretrySync(options)
- Wrap throwing functiontryAsync(options)
- Wrap async functionpartitionResults(results)
- Split array into oks/errs
Error Functions
createTaggedError(name)
- Creates error factory functions- Returns two functions:
{ErrorName}
and{ErrorName}Err
- The first creates plain error objects
- The second creates Err-wrapped errors
- Returns two functions:
Types
Result<T, E>
- Union of Ok<T> | Err<E>TaggedError<T>
- Structured error typeBrand<T, B>
- Branded type wrapper
License
MIT
Made with ❤️ by developers who believe error handling should be delightful.