Skip to content

wellcrafted-dev/wellcrafted

Repository files navigation

wellcrafted

npm version TypeScript License: MIT Bundle Size

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

🔄 Query Integration

Seamless 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...

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()),
  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);
}

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:

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
}

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),
  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
  })
});

Real-World Service + Query Layer Example

// 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 });
  }
}

Smart Return Type Narrowing

The catch parameter in trySync and tryAsync enables smart return type narrowing based on your error handling strategy:

Recovery Pattern (Always Succeeds)

// 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

Propagation Pattern (May Fail)

// 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
}

Mixed Strategy (Conditional Recovery)

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 type

This eliminates unnecessary error checking when you always recover, while still requiring proper error handling when failures are possible.

Why wellcrafted?

JavaScript's try-catch has fundamental problems:

  1. Invisible Errors: Function signatures don't show what errors can occur
  2. Lost in Transit: JSON.stringify(new Error()) loses critical information
  3. No Type Safety: TypeScript can't help with catch (error) blocks
  4. 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

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;
}

Comparison with Alternatives

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

Advanced Usage

For comprehensive examples, service layer patterns, framework integrations, and migration guides, see the full documentation →

API Reference

Result Functions

  • Ok(data) - Create success result
  • Err(error) - Create failure result
  • isOk(result) - Type guard for success
  • isErr(result) - Type guard for failure
  • trySync(options) - Wrap throwing function
  • tryAsync(options) - Wrap async function
  • unwrap(result) - Extract data or throw error
  • resolve(value) - Handle values that may or may not be Results
  • partitionResults(results) - Split array into oks/errs

Query Functions

  • createQueryFactories(client) - Create query/mutation factories for TanStack Query
  • defineQuery(options) - Define a query with dual interface (.options() + .fetch())
  • defineMutation(options) - Define a mutation with dual interface (.options() + .execute())

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
  • extractErrorMessage(error) - Extract readable message from unknown error

Types

  • Result<T, E> - Union of Ok | Err
  • Ok<T> - Success result type
  • Err<E> - Error result type
  • TaggedError<T> - Structured error type
  • Brand<T, B> - Branded type wrapper
  • ExtractOkFromResult<R> - Extract Ok variant from Result union
  • ExtractErrFromResult<R> - Extract Err variant from Result union
  • UnwrapOk<R> - Extract success value type from Result
  • UnwrapErr<R> - Extract error value type from Result

License

MIT


Made with ❤️ by developers who believe error handling should be delightful.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

No packages published