Result Pattern with React Query Integration Guide
Overview
The Result Pattern is a functional programming approach to error handling that makes success and error states explicit in your type system. Instead of throwing exceptions, all operations return a Result<T, E> type that can either be Ok<T> (success) or Err<E> (error).
Core Result Types
export type Ok<T> = {
  type: "ok";
  value: T;
};
export type Err<E> = {
  type: "err";
  error: E;
};
export type Result<T, E = Error> = Ok<T> | Err<E>;
// Helper constructors
export const ok = <T>(value: T): Ok<T> => ({
  type: "ok",
  value,
});
export const err = <E>(error: E): Err<E> => ({
  type: "err",
  error,
});
Benefits of the Result Pattern
- Explicit Error Handling: Errors become part of the type system
 - Type Safety: TypeScript ensures you handle both success and error cases
 - Composability: Results can be chained and transformed safely
 - No Hidden Exceptions: All failure modes are visible in the API
 - Better Testing: Easier to test both success and failure paths
 
API Design with Result Pattern
Service Layer Example
// types.ts
interface User {
  id: string;
  name: string;
  email: string;
}
interface ApiError {
  message: string;
  code: string;
  statusCode: number;
}
// userService.ts
class UserService {
  async fetchUsers(): Promise<Result<User[], ApiError>> {
    try {
      const response = await fetch('/api/users');
      
      if (!response.ok) {
        return err({
          message: 'Failed to fetch users',
          code: 'FETCH_ERROR',
          statusCode: response.status,
        });
      }
      
      const users: User[] = await response.json();
      return ok(users);
    } catch (error) {
      return err({
        message: 'Network error occurred',
        code: 'NETWORK_ERROR',
        statusCode: 0,
      });
    }
  }
  async fetchUserById(id: string): Promise<Result<User, ApiError>> {
    try {
      const response = await fetch(`/api/users/${id}`);
      
      if (response.status === 404) {
        return err({
          message: 'User not found',
          code: 'USER_NOT_FOUND',
          statusCode: 404,
        });
      }
      
      if (!response.ok) {
        return err({
          message: 'Failed to fetch user',
          code: 'FETCH_ERROR',
          statusCode: response.status,
        });
      }
      
      const user: User = await response.json();
      return ok(user);
    } catch (error) {
      return err({
        message: 'Network error occurred',
        code: 'NETWORK_ERROR',
        statusCode: 0,
      });
    }
  }
}
React Query Integration
Direct Integration Approach
The key insight is that with Result Pattern, your API always returns HTTP 200 (success), and the actual success/error state is encoded in the Result<T, E> type within the response data.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { match } from 'ts-pattern';
// Direct React Query usage
export const useFetchUsers = () => {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => userService.fetchUsers(), // Returns Promise<Result<User[], ApiError>>
    // No need for error handling here - Result pattern handles it
  });
};
export const useFetchUserById = (id: string | undefined) => {
  return useQuery({
    queryKey: ['users', id],
    queryFn: () => userService.fetchUserById(id!),
    enabled: !!id,
  });
};
Mutation Example
export const useCreateUser = () => {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (userData: CreateUserRequest) => userService.createUser(userData),
    onSuccess: (result) => {
      // Handle result using ts-pattern
      match(result)
        .with({ type: "ok" }, ({ value: user }) => {
          // Success case
          console.log('User created:', user.name);
          queryClient.invalidateQueries({ queryKey: ['users'] });
        })
        .with({ type: "err" }, ({ error }) => {
          // Error case
          console.error('Creation failed:', error.message);
        })
        .exhaustive();
    },
  });
};
Component Usage with ts-pattern
Basic Component Example
import React from 'react';
import { match } from 'ts-pattern';
import { useFetchUsers } from './hooks/userQueries';
export const UserList: React.FC = () => {
  const { data: usersResult, isLoading } = useFetchUsers();
  if (isLoading) {
    return <div>Loading...</div>;
  }
  // usersResult is Result<User[], ApiError> | undefined
  if (!usersResult) {
    return <div>No data</div>;
  }
  return match(usersResult)
    .with({ type: "ok" }, ({ value: users }) => (
      <div>
        <h2>Users ({users.length})</h2>
        <ul>
          {users.map(user => (
            <li key={user.id}>
              {user.name} - {user.email}
            </li>
          ))}
        </ul>
      </div>
    ))
    .with({ type: "err" }, ({ error }) => (
      <div>
        <h2>Error Loading Users</h2>
        <p>{error.message}</p>
        {error.code === 'NETWORK_ERROR' && (
          <button onClick={() => window.location.reload()}>
            Retry
          </button>
        )}
      </div>
    ))
    .exhaustive();
};
Component with Specific Error Handling
export const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
  const { data: userResult, isLoading } = useFetchUserById(userId);
  if (isLoading) {
    return <div>Loading user...</div>;
  }
  if (!userResult) {
    return <div>No data</div>;
  }
  return match(userResult)
    .with({ type: "ok" }, ({ value: user }) => (
      <div>
        <h1>{user.name}</h1>
        <p>Email: {user.email}</p>
        <p>ID: {user.id}</p>
      </div>
    ))
    .with({ type: "err", error: { code: "USER_NOT_FOUND" } }, () => (
      <div>
        <h2>User Not Found</h2>
        <p>The user with ID {userId} does not exist.</p>
      </div>
    ))
    .with({ type: "err", error: { code: "NETWORK_ERROR" } }, () => (
      <div>
        <h2>Connection Error</h2>
        <p>Please check your internet connection and try again.</p>
      </div>
    ))
    .with({ type: "err" }, ({ error }) => (
      <div>
        <h2>Error</h2>
        <p>{error.message}</p>
      </div>
    ))
    .exhaustive();
};
Form with Mutation
export const CreateUserForm: React.FC = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  
  const { mutate: createUser, isPending } = useCreateUser();
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    
    createUser({ name, email }, {
      onSuccess: (result) => {
        match(result)
          .with({ type: "ok" }, ({ value: user }) => {
            alert(`User ${user.name} created successfully!`);
            setName('');
            setEmail('');
          })
          .with({ type: "err", error: { code: "VALIDATION_ERROR" } }, ({ error }) => {
            alert(`Validation Error: ${error.message}`);
          })
          .with({ type: "err" }, ({ error }) => {
            alert(`Error: ${error.message}`);
          })
          .exhaustive();
      },
    });
  };
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Name:</label>
        <input 
          value={name} 
          onChange={(e) => setName(e.target.value)}
          required 
        />
      </div>
      <div>
        <label>Email:</label>
        <input 
          type="email"
          value={email} 
          onChange={(e) => setEmail(e.target.value)}
          required 
        />
      </div>
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
};
Key Concepts
1. API Always Returns Success
With Result Pattern, your HTTP calls always return 200 OK. The success/failure is encoded in the Result type:
// API Response is always successful HTTP-wise
const response = await fetch('/api/users'); // Always 200 OK
const result: Result<User[], ApiError> = await response.json();
// The actual success/error is in the Result
match(result)
  .with({ type: "ok" }, ({ value }) => {
    // Handle successful data
  })
  .with({ type: "err" }, ({ error }) => {
    // Handle error case
  })
  .exhaustive();
2. No React Query Error States
Since the HTTP call always succeeds, React Query's isError will rarely be true. All your error handling happens through pattern matching on the Result type:
const { data: result, isLoading } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers, // Returns Result<User[], ApiError>
});
// isError will be false (HTTP succeeded)
// Real error handling is done via pattern matching on `result`
3. Type Safety with ts-pattern
The ts-pattern library provides exhaustive pattern matching, ensuring you handle all cases:
// TypeScript ensures all cases are handled
return match(result)
  .with({ type: "ok" }, ({ value }) => {
    // Handle success - TypeScript knows `value` is User[]
  })
  .with({ type: "err" }, ({ error }) => {
    // Handle error - TypeScript knows `error` is ApiError
  })
  .exhaustive(); // Compiler error if any case is missing
Best Practices
- Consistent Error Types: Use a standardized error interface across your app
 - Explicit Pattern Matching: Always use 
match()with.exhaustive()for complete type safety - Specific Error Codes: Use meaningful error codes for different error handling
 - No Throwing: Never throw exceptions - always return Result types
 - Early Pattern Matching: Handle Result types as close to the data source as possible
 - Type Annotations: Be explicit about Result types in function signatures
 
Migration Strategy
- Start Small: Begin with one service/API endpoint
 - Convert Services First: Update your service layer to return Result types
 - Update Components: Replace try/catch with pattern matching
 - Gradual Adoption: Migrate endpoints one by one
 - Team Education: Ensure team understands the pattern before wide adoption
 
This approach provides explicit error handling, better type safety, and eliminates hidden exceptions while working seamlessly with React Query.