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>)
Cache configuration optionsBun 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.
Enable/disable caching. When false, all operations become no-ops.
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]>
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]>
Cache key (will be prefixed if prefix option is set)
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]>
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";
General cache operation error
Invalid TTL value (negative, non-integer, non-finite, or function threw)
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.