Skip to main content

Cache

A type-safe Redis cache wrapper with TTL support and result-based error handling. Built on Bun’s native Redis client.

Import

import { Cache } from "semola/cache";

Class: Cache

Constructor

new Cache<T>(options: CacheOptions<T>)
options
CacheOptions<T>
required
Cache configuration options
options.redis
Bun.RedisClient
required
Bun Redis client instance
options.ttl
number | ((key: string, value: T) => number)
Time-to-live in milliseconds. Can be a number or a function that computes TTL per entry.
options.enabled
boolean
default:"true"
Enable/disable caching. When false, all operations become no-ops.
options.prefix
string
Key prefix (e.g., "users" makes keys like "users:123")
options.serializer
(value: T) => string
default:"JSON.stringify"
Custom serialization function
options.deserializer
(raw: string) => T
default:"JSON.parse"
Custom deserialization function
options.onError
(error: { type: CacheError; message: string }) => void
Error callback for unexpected errors (not called on cache misses)
Example:
type User = {
  id: number;
  name: string;
  email: string;
};

const cache = new Cache<User>({
  redis: new Bun.RedisClient("redis://localhost:6379"),
  ttl: 60000, // 1 minute
  prefix: "users",
});

Methods

get

Retrieves a value from the cache.
get(key: string): Promise<[Error, null] | [null, T]>
key
string
required
Cache key (will be prefixed if prefix option is set)
result
[null, T] | [Error, null]
Result tuple containing either:
  • [null, value] - Success with parsed value
  • [error, null] - Error with type "NotFoundError" (cache miss) or "CacheError"
Example:
const [error, user] = await cache.get("user:123");

if (error) {
  switch (error.type) {
    case "NotFoundError":
      console.log("Cache miss");
      break;
    case "CacheError":
      console.error("Cache error:", error.message);
      break;
  }
} else {
  console.log("Cache hit:", user);
}

set

Stores a value in the cache with serialization.
set(key: string, value: T): Promise<[Error, null] | [null, T]>
key
string
required
Cache key (will be prefixed if prefix option is set)
value
T
required
Value to cache
result
[null, T] | [Error, null]
Result tuple containing either:
  • [null, value] - Success with the original value
  • [error, null] - Error with type "CacheError" or "InvalidTTLError"
Example:
const [error, data] = await cache.set("user:123", { 
  id: 123, 
  name: "John",
  email: "[email protected]"
});

if (error) {
  console.error("Failed to cache:", error.message);
} else {
  console.log("Cached successfully");
}

delete

Removes a key from the cache.
delete(key: string): Promise<[Error, null] | [null, number]>
key
string
required
Cache key to delete (will be prefixed if prefix option is set)
result
[null, number] | [Error, null]
Result tuple containing either:
  • [null, count] - Success with number of keys deleted (0 or 1)
  • [error, null] - Error with type "CacheError"
Example:
const [error] = await cache.delete("user:123");

if (error) {
  console.error("Failed to delete:", error.message);
}

Type Definitions

CacheOptions

type CacheOptions<T> = {
  redis: Bun.RedisClient;
  ttl?: number | ((key: string, value: T) => number);
  enabled?: boolean;
  prefix?: string;
  serializer?: (value: T) => string;
  deserializer?: (raw: string) => T;
  onError?: (error: { type: CacheError; message: string }) => void;
};

CacheError

type CacheError = "CacheError" | "InvalidTTLError" | "NotFoundError";
CacheError
string
General cache operation error
InvalidTTLError
string
Invalid TTL value (negative, non-integer, non-finite, or function threw)
NotFoundError
string
Key not found in cache (normal cache miss, not an error condition)

Usage Examples

Basic Cache

import { Cache } from "semola/cache";

type User = {
  id: number;
  name: string;
  email: string;
};

// Create cache instance
const userCache = new Cache<User>({
  redis: new Bun.RedisClient("redis://localhost:6379"),
  ttl: 300000, // 5 minutes
});

// Get or fetch user
async function getUser(id: string) {
  // Try cache first
  const [cacheError, cachedUser] = await userCache.get(`user:${id}`);
  
  if (!cacheError) {
    return ok(cachedUser);
  }
  
  // Cache miss - fetch from database
  const [dbError, user] = await fetchUserFromDB(id);
  
  if (dbError) {
    return err("NotFoundError", "User not found");
  }
  
  // Store in cache for next time
  await userCache.set(`user:${id}`, user);
  
  return ok(user);
}

With Prefix

const usersCache = new Cache<User>({
  redis: redisClient,
  prefix: "users",
  ttl: 60000,
});

await usersCache.set("123", user);      // Stored as "users:123"
await usersCache.get("123");            // Reads from "users:123"
await usersCache.delete("123");         // Deletes "users:123"

Conditional Caching

const cache = new Cache<User>({
  redis: redisClient,
  enabled: process.env.CACHE_ENABLED !== "false",
  ttl: 60000,
});

// When enabled=false:
// - get() returns NotFoundError (cache miss)
// - set() returns the value without storing
// - delete() returns 0 without deleting

Custom Serialization

const cache = new Cache<User>({
  redis: redisClient,
  serializer: (user) => `${user.id}:${user.name}:${user.email}`,
  deserializer: (raw) => {
    const [id, name, email] = raw.split(":");
    return { id: Number(id), name, email };
  },
});

Dynamic TTL

type Session = {
  userId: string;
  rememberMe: boolean;
  data: Record<string, unknown>;
};

const sessionCache = new Cache<Session>({
  redis: redisClient,
  ttl: (_key, session) => {
    // Long TTL for "remember me", short for regular sessions
    return session.rememberMe ? 86400000 : 3600000;
  },
});

await sessionCache.set("session-123", {
  userId: "user-456",
  rememberMe: true,
  data: {},
}); // Stored with 24 hour TTL

Error Callback

const cache = new Cache<User>({
  redis: redisClient,
  onError: (error) => {
    // Log unexpected errors (not cache misses)
    logger.warn(`Cache: ${error.type} - ${error.message}`);
  },
});

// onError is called for CacheError and InvalidTTLError
// onError is NOT called for NotFoundError (normal cache miss)

Multi-layer Cache

import { Cache } from "semola/cache";
import { ok, err } from "semola/errors";

type Product = {
  id: string;
  name: string;
  price: number;
};

// Layer 1: Short TTL for frequently accessed items
const hotCache = new Cache<Product>({
  redis: redisClient,
  prefix: "products:hot",
  ttl: 60000, // 1 minute
});

// Layer 2: Longer TTL for all items
const warmCache = new Cache<Product>({
  redis: redisClient,
  prefix: "products:warm",
  ttl: 3600000, // 1 hour
});

async function getProduct(id: string) {
  // Try hot cache
  const [hotError, hotProduct] = await hotCache.get(id);
  if (!hotError) return ok(hotProduct);
  
  // Try warm cache
  const [warmError, warmProduct] = await warmCache.get(id);
  if (!warmError) {
    // Promote to hot cache
    await hotCache.set(id, warmProduct);
    return ok(warmProduct);
  }
  
  // Fetch from database
  const [dbError, product] = await fetchProductFromDB(id);
  if (dbError) return err("NotFoundError", "Product not found");
  
  // Store in both layers
  await warmCache.set(id, product);
  await hotCache.set(id, product);
  
  return ok(product);
}

Important Notes

TTL Validation

  • TTL must be a non-negative integer in milliseconds
  • ttl: 0 is treated as a valid TTL and passed to Redis as PX 0
  • If the TTL function throws, set() returns InvalidTTLError and triggers onError if configured
  • undefined or null TTL means no expiration (key persists indefinitely)

Lifecycle Management

The Cache class does not manage the Redis client lifecycle. You provide the client when creating the cache and are responsible for closing it when done:
const redis = new Bun.RedisClient("redis://localhost:6379");
const cache = new Cache({ redis });

// Use the cache...

// Clean up when done
await redis.quit();

Error Callback Behavior

onError is called for unexpected errors (CacheError, InvalidTTLError) but NOT for NotFoundError, which represents a normal cache miss and is not considered an error condition.

Build docs developers (and LLMs) love