Skip to main content

Overview

Result instances are class instances with methods and cannot be directly serialized to JSON for transmission over the network or storage. The Result.serialize and Result.deserialize functions convert between Result instances and plain objects suitable for JSON serialization.

When to Use Serialization

Serialization is essential when:
  • Server Actions: Passing Results from Next.js server actions to client components
  • RPC/tRPC: Returning Results from remote procedure calls
  • Storage: Persisting Results to databases or caches
  • Message Queues: Sending Results through message brokers
  • Web Workers: Transferring Results between main thread and workers
Result instances lose their methods when serialized with JSON.stringify. Always use Result.serialize before JSON serialization and Result.deserialize after JSON parsing.

Serialization API

Result.serialize

Converts a Result instance to a plain object:
const okResult = Result.ok({ id: 42, name: 'Alice' });
const serialized = Result.serialize(okResult);

console.log(serialized);
// { status: 'ok', value: { id: 42, name: 'Alice' } }

const errResult = Result.err({ code: 'NOT_FOUND', message: 'User not found' });
const serializedErr = Result.serialize(errResult);

console.log(serializedErr);
// { status: 'error', error: { code: 'NOT_FOUND', message: 'User not found' } }

SerializedResult Type

The serialized form is a discriminated union:
// From src/result.ts:722-735
interface SerializedOk<T> {
  status: 'ok';
  value: T;
}

interface SerializedErr<E> {
  status: 'error';
  error: E;
}

type SerializedResult<T, E> = SerializedOk<T> | SerializedErr<E>;
You can work with serialized Results directly if needed:
import type { SerializedResult } from 'better-result';

const handleSerializedResult = (data: SerializedResult<User, AppError>) => {
  if (data.status === 'ok') {
    console.log('User:', data.value);
  } else {
    console.error('Error:', data.error);
  }
};

Result.deserialize

Converts a serialized Result back to a Result instance:
const serialized = { status: 'ok', value: { id: 1, name: 'Bob' } };
const result = Result.deserialize<User, Error>(serialized);

if (Result.isOk(result)) {
  console.log(result.value); // { id: 1, name: 'Bob' }
  console.log(result.map(u => u.name)); // Result can use methods again
}
Result.deserialize validates the structure and returns Err<ResultDeserializationError> if the input is invalid.

Handling Deserialization Errors

When deserializing untrusted data, always handle ResultDeserializationError:
import { Result, ResultDeserializationError } from 'better-result';

const deserializeUserResult = (data: unknown) => {
  const result = Result.deserialize<User, AppError>(data);
  
  if (Result.isError(result)) {
    // Could be AppError OR ResultDeserializationError
    if (ResultDeserializationError.is(result.error)) {
      console.error('Invalid serialized Result:', result.error.value);
      // Handle malformed data
      return null;
    }
    
    // It's an AppError
    console.error('Application error:', result.error.message);
    return null;
  }
  
  return result.value;
};

JSON Roundtrip

Complete example of serializing, JSON encoding, and deserializing:
// 1. Create Result
const original = Result.ok({ id: 1, data: [1, 2, 3] });

// 2. Serialize to plain object
const serialized = Result.serialize(original);

// 3. JSON encode
const json = JSON.stringify(serialized);
console.log(json);
// '{"status":"ok","value":{"id":1,"data":[1,2,3]}}'

// 4. JSON decode
const parsed = JSON.parse(json);

// 5. Deserialize back to Result
const restored = Result.deserialize<{ id: number; data: number[] }, never>(parsed);

// 6. Use as normal Result
const mapped = restored.map(v => v.data.length);
console.log(mapped.unwrap()); // 3

Use Cases

Next.js Server Actions

// app/actions.ts
'use server';

import { Result, SerializedResult } from 'better-result';

class ValidationError extends TaggedError('ValidationError')<{
  message: string;
  field: string;
}>() {}

export async function createUser(
  data: FormData
): Promise<SerializedResult<User, ValidationError>> {
  const result = await Result.gen(async function* () {
    const name = data.get('name')?.toString();
    
    if (!name || name.length < 2) {
      return Result.err(new ValidationError({
        message: 'Name must be at least 2 characters',
        field: 'name'
      }));
    }
    
    const user = yield* Result.await(
      Result.tryPromise(() => db.users.create({ name }))
    );
    
    return Result.ok(user);
  });
  
  // Serialize before returning to client
  return Result.serialize(result);
}
// app/components/UserForm.tsx
'use client';

import { createUser } from '@/app/actions';
import { Result } from 'better-result';

export function UserForm() {
  const handleSubmit = async (formData: FormData) => {
    const serialized = await createUser(formData);
    
    // Deserialize on client side
    const result = Result.deserialize<User, ValidationError>(serialized);
    
    if (Result.isError(result)) {
      alert(`Error: ${result.error.message}`);
      return;
    }
    
    console.log('Created user:', result.value);
  };
  
  return <form action={handleSubmit}>...</form>;
}

tRPC Procedures

// server/routers/users.ts
import { z } from 'zod';
import { Result, SerializedResult } from 'better-result';
import { publicProcedure, router } from '../trpc';

class NotFoundError extends TaggedError('NotFoundError')<{
  message: string;
  id: string;
}>() {}

export const usersRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }): Promise<SerializedResult<User, NotFoundError>> => {
      const result = await Result.tryPromise(
        {
          try: async () => {
            const user = await db.users.findUnique({ 
              where: { id: input.id } 
            });
            
            if (!user) {
              throw { notFound: true };
            }
            
            return user;
          },
          catch: (e: any) => new NotFoundError({
            message: `User ${input.id} not found`,
            id: input.id
          })
        }
      );
      
      return Result.serialize(result);
    })
});
// client/hooks/useUser.ts
import { trpc } from '@/lib/trpc';
import { Result } from 'better-result';

export function useUser(id: string) {
  const query = trpc.users.getById.useQuery({ id });
  
  if (!query.data) return { user: null, error: null, loading: true };
  
  const result = Result.deserialize<User, NotFoundError>(query.data);
  
  if (Result.isError(result)) {
    return { user: null, error: result.error, loading: false };
  }
  
  return { user: result.value, error: null, loading: false };
}

Redis Cache Storage

import { Redis } from 'ioredis';
import { Result, SerializedResult } from 'better-result';

const redis = new Redis();

class CacheError extends TaggedError('CacheError')<{
  message: string;
  operation: 'get' | 'set';
}>() {}

const cacheResult = async <T, E>(
  key: string,
  result: Result<T, E>,
  ttlSeconds: number = 3600
): Promise<Result<void, CacheError>> => {
  return await Result.tryPromise(
    {
      try: async () => {
        const serialized = Result.serialize(result);
        const json = JSON.stringify(serialized);
        await redis.setex(key, ttlSeconds, json);
      },
      catch: (e) => new CacheError({
        message: String(e),
        operation: 'set'
      })
    }
  );
};

const getCachedResult = async <T, E>(
  key: string
): Promise<Result<Result<T, E> | null, CacheError>> => {
  return await Result.tryPromise(
    {
      try: async () => {
        const json = await redis.get(key);
        
        if (!json) {
          return null;
        }
        
        const parsed = JSON.parse(json);
        const result = Result.deserialize<T, E>(parsed);
        
        // Handle deserialization error
        if (Result.isError(result) && ResultDeserializationError.is(result.error)) {
          // Invalid cached data - delete it
          await redis.del(key);
          return null;
        }
        
        return result;
      },
      catch: (e) => new CacheError({
        message: String(e),
        operation: 'get'
      })
    }
  );
};

// Usage
const getUserWithCache = async (id: string) => {
  const cacheKey = `user:${id}`;
  
  // Try cache first
  const cached = await getCachedResult<User, NotFoundError>(cacheKey);
  
  if (Result.isOk(cached) && cached.value !== null) {
    return cached.value; // Return cached Result<User, NotFoundError>
  }
  
  // Cache miss - fetch from database
  const result = await fetchUserFromDb(id);
  
  // Cache the result (both Ok and Err)
  await cacheResult(cacheKey, result);
  
  return result;
};

Message Queue with BullMQ

import { Queue, Worker } from 'bullmq';
import { Result, SerializedResult } from 'better-result';

interface ProcessUserJob {
  userId: string;
  result: SerializedResult<User, FetchError>;
}

const queue = new Queue<ProcessUserJob>('user-processing');

// Producer: Add job with serialized Result
const enqueueUserProcessing = async (userId: string) => {
  const userResult = await fetchUser(userId);
  
  await queue.add('process', {
    userId,
    result: Result.serialize(userResult)
  });
};

// Consumer: Deserialize and process
const worker = new Worker<ProcessUserJob>('user-processing', async (job) => {
  const { userId, result: serializedResult } = job.data;
  
  const result = Result.deserialize<User, FetchError>(serializedResult);
  
  if (Result.isError(result)) {
    console.error(`Failed to process user ${userId}:`, result.error);
    throw new Error(`User fetch failed: ${result.error.message}`);
  }
  
  await processUser(result.value);
});

Testing Serialization

import { describe, it, expect } from 'bun:test';
import { Result } from 'better-result';

describe('Serialization', () => {
  it('roundtrips Ok through serialization', () => {
    const original = Result.ok({ id: 42, name: 'test' });
    const serialized = Result.serialize(original);
    const deserialized = Result.deserialize<{ id: number; name: string }, never>(
      serialized
    );
    
    expect(deserialized.unwrap()).toEqual({ id: 42, name: 'test' });
  });

  it('roundtrips Err through serialization', () => {
    const original = Result.err({ code: 'ERR', message: 'failed' });
    const serialized = Result.serialize(original);
    const deserialized = Result.deserialize<never, { code: string; message: string }>(
      serialized
    );
    
    expect(Result.isError(deserialized)).toBe(true);
    if (Result.isError(deserialized)) {
      expect(deserialized.error).toEqual({ code: 'ERR', message: 'failed' });
    }
  });

  it('handles invalid deserialization input', () => {
    const invalid = { foo: 'bar' };
    const result = Result.deserialize(invalid);
    
    expect(Result.isError(result)).toBe(true);
    if (Result.isError(result)) {
      expect(ResultDeserializationError.is(result.error)).toBe(true);
    }
  });

  it('roundtrips through JSON.stringify/parse', () => {
    const original = Result.ok({ data: [1, 2, 3] });
    const json = JSON.stringify(Result.serialize(original));
    const parsed = JSON.parse(json);
    const deserialized = Result.deserialize<{ data: number[] }, never>(parsed);
    
    expect(deserialized.unwrap()).toEqual({ data: [1, 2, 3] });
  });
});

Best Practices

1

Always serialize before JSON encoding

Never use JSON.stringify directly on Result instances. Always call Result.serialize first.
2

Handle deserialization errors

Check for ResultDeserializationError when deserializing untrusted or external data.
3

Type serialized Results at boundaries

Use SerializedResult<T, E> as return types for server actions, RPC procedures, and API routes.
4

Cache both success and failure

Serialize and cache both Ok and Err Results to avoid redundant operations for known failures.
5

Validate deserialized values

After deserialization, validate the inner value if it comes from external sources.
TaggedError instances serialize through their toJSON() method, preserving the _tag, message, cause, and other properties. This works seamlessly with Result.serialize.
For long-term storage, consider versioning your serialized Result schemas to handle breaking changes gracefully.