Delightful TypeScript utilities for elegant, type-safe applications
// ❌ 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!
}
}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);
}Create distinct types from primitives
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// TypeScript prevents mixing them up!
function getUser(id: UserId) { /* ... */ }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 errorSeamless TanStack Query integration with dual interfaces
import { createQueryFactories } from "wellcrafted/query";
import { QueryClient } from "@tanstack/query-core";
const queryClient = new QueryClient();
const { defineQuery, defineMutation } = createQueryFactories(queryClient);
// Define operations that return Result types
const userQuery = defineQuery({
queryKey: ['users', userId],
resultQueryFn: () => getUserFromAPI(userId) // Returns Result<User, ApiError>
});
// Use reactively in components with automatic state management
const query = createQuery(userQuery.options());
// query.data, query.error, query.isPending all managed automatically
// Or use imperatively for direct execution (perfect for event handlers)
const { data, error } = await userQuery.fetch();
if (error) {
showErrorToast(error.message);
return;
}
// Use data...npm install wellcraftedimport { 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()),
catch: (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);
}|
🎯 Explicit Error Handling |
📦 Serialization-Safe |
✨ Elegant API |
|
🔍 Zero Magic |
🚀 Lightweight |
🎨 Composable |
The Result type makes error handling explicit and type-safe:
type Ok<T> = { data: T; error: null };
type Err<E> = { error: E; data: null };
type Result<T, E> = Ok<T> | Err<E>;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
}const { data, error } = await someOperation();
if (error) {
// Handle error with full type safety
return;
}
// Use data - TypeScript knows it's safe// Synchronous
const result = trySync({
try: () => JSON.parse(jsonString),
catch: (error) => Err({
name: "ParseError",
message: "Invalid JSON",
context: { input: jsonString },
cause: error
})
});
// Asynchronous
const result = await tryAsync({
try: () => fetch(url),
catch: (error) => Err({
name: "NetworkError",
message: "Request failed",
context: { url },
cause: error
})
});// 1. Service Layer - Pure business logic
import { createTaggedError } from "wellcrafted/error";
import { tryAsync, Result } from "wellcrafted/result";
const { RecorderServiceError, RecorderServiceErr } = createTaggedError("RecorderServiceError");
type RecorderServiceError = ReturnType<typeof RecorderServiceError>;
export function createRecorderService() {
let isRecording = false;
let currentBlob: Blob | null = null;
return {
async startRecording(): Promise<Result<void, RecorderServiceError>> {
if (isRecording) {
return RecorderServiceErr({
message: "Already recording",
context: { currentState: 'recording' },
cause: undefined
});
}
return tryAsync({
try: async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
// ... recording setup
isRecording = true;
},
catch: (error) => RecorderServiceErr({
message: "Failed to start recording",
context: { permissions: 'microphone' },
cause: error
})
});
},
async stopRecording(): Promise<Result<Blob, RecorderServiceError>> {
if (!isRecording) {
return RecorderServiceErr({
message: "Not currently recording",
context: { currentState: 'idle' },
cause: undefined
});
}
// Stop recording and return blob...
isRecording = false;
return Ok(currentBlob!);
}
};
}
// 2. Query Layer - Adds caching, reactivity, and UI error handling
import { createQueryFactories } from "wellcrafted/query";
const { defineQuery, defineMutation } = createQueryFactories(queryClient);
export const recorder = {
getRecorderState: defineQuery({
queryKey: ['recorder', 'state'],
resultQueryFn: async () => {
const { data, error } = await services.recorder.getState();
if (error) {
// Transform service error to UI-friendly error
return Err({
title: "❌ Failed to get recorder state",
description: error.message,
action: { type: 'retry' }
});
}
return Ok(data);
},
refetchInterval: 1000, // Poll for state changes
}),
startRecording: defineMutation({
mutationKey: ['recorder', 'start'],
resultMutationFn: async () => {
const { error } = await services.recorder.startRecording();
if (error) {
return Err({
title: "❌ Failed to start recording",
description: error.message,
action: { type: 'more-details', error }
});
}
// Optimistically update cache
queryClient.setQueryData(['recorder', 'state'], 'recording');
return Ok(undefined);
}
})
};
// 3. Component Usage - Choose reactive or imperative based on needs
// Reactive: Automatic state management
const recorderState = createQuery(recorder.getRecorderState.options());
// Imperative: Direct execution for event handlers
async function handleStartRecording() {
const { error } = await recorder.startRecording.execute();
if (error) {
showToast(error.title, { description: error.description });
}
}The catch parameter in trySync and tryAsync enables smart return type narrowing based on your error handling strategy:
// When catch always returns Ok<T>, function returns Ok<T>
const alwaysSucceeds = trySync({
try: () => JSON.parse(riskyJson),
catch: () => Ok({ fallback: "default" }) // Always recover with fallback
});
// alwaysSucceeds: Ok<object> - No error checking needed!
console.log(alwaysSucceeds.data); // Safe to access directly// When catch can return Err<E>, function returns Result<T, E>
const mayFail = trySync({
try: () => JSON.parse(riskyJson),
catch: (error) => Err(ParseError({ message: "Invalid JSON", cause: error }))
});
// mayFail: Result<object, ParseError> - Must check for errors
if (isOk(mayFail)) {
console.log(mayFail.data); // Only safe after checking
}const smartParse = trySync({
try: () => JSON.parse(input),
catch: (error) => {
// Recover from empty input
if (input.trim() === "") {
return Ok({}); // Return Ok<T> for fallback
}
// Propagate other errors
return Err(ParseError({ message: "Parse failed", cause: error }));
}
});
// smartParse: Result<object, ParseError> - Mixed handling = Result typeThis eliminates unnecessary error checking when you always recover, while still requiring proper error handling when failures are possible.
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
Based on real-world usage, here's the recommended pattern for creating services with wellcrafted:
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();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();API Route Handler
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);
}Form Validation
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);
}React Hook
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;
}| wellcrafted | fp-ts | Effect | neverthrow | |
|---|---|---|---|---|
| Bundle Size | < 2KB | ~30KB | ~50KB | ~5KB |
| Learning Curve | Minimal | Steep | Steep | Moderate |
| Syntax | Native async/await | Pipe operators | Generators | Method chains |
| Type Safety | ✅ Full | ✅ Full | ✅ Full | ✅ Full |
| Serializable Errors | ✅ Built-in | ❌ Classes | ❌ Classes | ❌ Classes |
| Runtime Overhead | Zero | Minimal | Moderate | Minimal |
For comprehensive examples, service layer patterns, framework integrations, and migration guides, see the full documentation →
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 functionunwrap(result)- Extract data or throw errorresolve(value)- Handle values that may or may not be ResultspartitionResults(results)- Split array into oks/errs
createQueryFactories(client)- Create query/mutation factories for TanStack QuerydefineQuery(options)- Define a query with dual interface (.options()+.fetch())defineMutation(options)- Define a mutation with dual interface (.options()+.execute())
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:
extractErrorMessage(error)- Extract readable message from unknown error
Result<T, E>- Union of Ok | ErrOk<T>- Success result typeErr<E>- Error result typeTaggedError<T>- Structured error typeBrand<T, B>- Branded type wrapperExtractOkFromResult<R>- Extract Ok variant from Result unionExtractErrFromResult<R>- Extract Err variant from Result unionUnwrapOk<R>- Extract success value type from ResultUnwrapErr<R>- Extract error value type from Result
MIT
Made with ❤️ by developers who believe error handling should be delightful.