TypedCache
TypedCache is a lightweight, type-safe façade around your favourite Ruby cache stores. It adds three things on top of the raw back-end implementation:
-
Namespacing – hierarchical
Namespacehelpers prevent key collisions. You can create nested namespaces easily, likeNamespace.at("users", "profiles", "avatars"). -
Stronger types – RBS signatures as well as monadic types like
Either,Maybe, andSnapshotwrap cache results so you always know whether you have a value, an error, or a cache-miss. - Composable decorators – behaviours like instrumentation can be layered on without touching the underlying store.
TL;DR – Think Faraday or Rack middlewares, but for caching.
Installation
bundle add typed_cache && bundle install
# or
gem install typed_cacheThis gem does is also cryptographically signed, if you want to ensure the gem was not tampered with, make sure to use these commands:
bundle add typed_cache && bundle install --trust-policy=HighSecurity
# or
gem install typed_cache -P HighSecurityIf there are issues with unsigned gems, use MediumSecurity instead.
Quick start
require "typed_cache"
# 1. Build a store
store = TypedCache.builder
.with_backend(:memory, shared: true)
.with_instrumentation(:rails) # e.g. using ActiveSupport
.build
.value # unwrap Either for brevity
# 2. Get a reference to a key
user_ref = store.ref("users:123") # => CacheRef
# 3. Fetch and compute if absent
user_snapshot = user_ref.fetch do
puts "Cache miss! Computing..."
{ id: 123, name: "Jane" }
end.value # => Snapshot
puts "Found: #{user_snapshot.value} (from_cache?=#{user_snapshot.from_cache?})"Builder API
| Step | Purpose |
|---|---|
with_backend(:name, **opts) |
Mandatory. Configure the concrete Backend and its options. |
with_decorator(:key) |
Optional. Add a decorator by registry key. |
with_instrumentation(:source) |
Optional. Add instrumentation, e.g. :rails or :dry. |
build |
Returns Either[Error, Store]. |
Back-ends vs Decorators
-
Back-end (
TypedCache::Backend) – persists data (Memory, Redis, etc.). -
Decorator (
TypedCache::Decorator) – wraps an existing store to add behaviour (Instrumentation, Logging, Circuit-Breaker …).
Both include the same public Store interface, so they can be composed
freely. Registries keep them separate:
TypedCache::Backends.available # => [:memory, :active_support]
TypedCache::Decorators.available # => [:instrumented]Register your own
class RedisBackend
include TypedCache::Backend
# … implement #read, #write, etc.
end
TypedCache::Backends.register(:redis, RedisBackend)class LogDecorator
include TypedCache::Decorator
def initialize(store) = @store = store
def write(key, value)
puts "[cache] WRITE #{key}"
@store.write(key, value)
end
# delegate the rest …
end
TypedCache::Decorators.register(:logger, LogDecorator)Error handling
All operations return one of:
-
Either.right(Snapshot)– success -
Either.left(CacheMissError)– key not present -
Either.left(StoreError)– transport / serialization failure
Use the monad directly or pattern-match:
result.fold(
->(err) { warn err.message },
->(snapshot) { puts snapshot.value },
)The CacheRef and Store APIs
While you can call read, write, and fetch directly on the store, the more powerful way to work with TypedCache is via the CacheRef object. It provides a rich, monadic API for a single cache key. The Store also provides fetch_all for batch operations.
You get a CacheRef by calling store.ref(key):
user_ref = store.ref("users:123") # => #<TypedCache::CacheRef ...>Now you can operate on it:
# Fetch a value, computing it if it's missing
snapshot_either = user_ref.fetch do
{ id: 123, name: "Jane Doe" }
end
# The result is always an Either[Error, Snapshot]
snapshot_either.fold(
->(err) { warn "Something went wrong: #{err.message}" },
->(snapshot) { puts "Got value: #{snapshot.value} (from cache: #{snapshot.from_cache?})" }
)
# You can also map over values
name_either = user_ref.map { |user| user[:name] }
puts "User name is: #{name_either.value.value}" # unwrap Either, then SnapshotBatch Operations with fetch_all
For retrieving multiple keys at once, the Store provides a fetch_all method. This is more efficient than fetching keys one by one, especially with remote back-ends like Redis.
It takes a list of keys and a block to compute the values for any missing keys.
user_refs = store.fetch_all("users:123", "users:456") do |missing_key|
# This block is called for each cache miss
user_id = missing_key.split(":").last
puts "Cache miss for #{missing_key}! Computing..."
{ id: user_id, name: "Fetched User #{user_id}" }
end
user_refs.each do |key, snapshot_either|
snapshot_either.fold(
->(err) { warn "Error for #{key}: #{err.message}" },
->(snapshot) { puts "Got value for #{key}: #{snapshot.value}" }
)
endThe CacheRef API encourages a functional style and makes composing cache operations safe and predictable.
Instrumentation
TypedCache can publish events about cache operations using different instrumenters. To enable it, use the with_instrumentation method on the builder, specifying an instrumentation backend:
# For ActiveSupport::Notifications (e.g. in Rails)
store = TypedCache.builder
.with_backend(:memory)
.with_instrumentation(:rails)
.build.value
# For Dry::Monitor
store = TypedCache.builder
.with_backend(:memory)
.with_instrumentation(:dry)
.build.valueEvents are published to a topic like typed_cache.<operation> (e.g., typed_cache.write). The topic namespace can be configured.
Payload keys include: :namespace, :key, :operation, :duration, and cache_hit.
You can subscribe to these events like so:
# Example for ActiveSupport
ActiveSupport::Notifications.subscribe("typed_cache.write") do |name, start, finish, id, payload|
# Or you can subscribe via the store object itself
instrumenter = store.instrumenter
instrumenter.subscribe("write") do |event|
payload = event.payload
puts "Cache WRITE for key #{payload[:key]} took #{payload[:duration]}ms. Hit? #{payload[:cache_hit]}"
endIf you call with_instrumentation with no arguments, it uses a Null instrumenter, which has no overhead.
Custom Instrumenters
Just like with back-ends and decorators, you can write and register your own instrumenters. An instrumenter must implement an instrument and a subscribe method.
class MyCustomInstrumenter
include TypedCache::Instrumenter
def instrument(operation, key, **payload, &block)
# ... your logic ...
end
def subscribe(event_name, **filters, &block)
# ... your logic ...
end
end
# Register it
TypedCache::Instrumenters.register(:custom, MyCustomInstrumenter)
# Use it
store = TypedCache.builder
.with_instrumentation(:custom)
# ...Further examples
For more advanced scenarios—including Rails integration, pattern matching, custom back-ends, and testing—see examples.md.
License
This work is licensed under the Apache-2.0 license.
Apache 2.0 License Key Terms
Grants
- Perpetual, worldwide, non-exclusive, royalty-free license to:
- Reproduce the work
- Prepare derivative works
- Distribute the work
- Use and sell the work
Requirements
- Include a copy of the Apache 2.0 License with any distribution
- Provide attribution
- Clearly mark any modifications made to the original work
- Retain all original copyright and license notices
Permissions
- Commercial use allowed
- Modification permitted
- Distribution of original and modified work permitted
- Patent use granted
- Private use allowed
Limitations
- No warranty or liability protection
- Trademark rights not transferred
- Contributors not liable for damages
Compatibility
- Can be used in closed-source and commercial projects
- Requires preserving original license and attribution