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.
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.
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?})"| 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-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]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)All operations return one of:
Either.right(Snapshot)– successEither.left(CacheMissError)– key not presentEither.left(StoreError)– transport / serialization failure
Use the monad directly or pattern-match:
result.fold(
->(err) { warn err.message },
->(snapshot) { puts snapshot.value },
)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 SnapshotFor 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.
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.
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)
# ...For more advanced scenarios—including Rails integration, pattern matching, custom back-ends, and testing—see examples.md.
This work is licensed under the Apache-2.0 license.
- Perpetual, worldwide, non-exclusive, royalty-free license to:
- Reproduce the work
- Prepare derivative works
- Distribute the work
- Use and sell the work
- 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
- Commercial use allowed
- Modification permitted
- Distribution of original and modified work permitted
- Patent use granted
- Private use allowed
- No warranty or liability protection
- Trademark rights not transferred
- Contributors not liable for damages
- Can be used in closed-source and commercial projects
- Requires preserving original license and attribution