Building a Redis Clone in Rust: Refactoring for Data Structures
Phase 3 wrapped up TTL and active expiration, but I’d been putting off something that was becoming obvious: Rudis could only store strings. Everything was Vec<u8>. That was fine when the only commands were GET, SET, and INCR, but Redis supports lists, sets, hashes, sorted sets — and I wanted to start adding those.
Before I could write a single LPUSH, I needed to rethink how values are stored. This post is about that refactoring. The next one covers the actual list, set, and hash implementations.
The problem with Vec<u8>
Here’s what StoredValue looked like through Phase 3:
pub struct StoredValue {
pub data: Vec<u8>,
pub expires_at: Option<Instant>,
}
Every value was just bytes. INCR would parse them to an integer, increment, stringify, and store again. It worked, but there was no way to tell a string key apart from a list key. If someone did SET mykey "hello" followed by LPUSH mykey "world", both would happily operate on the same key.
Redis doesn’t allow this. Try a list operation on a string key:
(error) WRONGTYPE Operation against a key holding the wrong kind of value
To enforce that, the store needs to know what type each value holds.
The DataType enum
I went with an enum that wraps the underlying data structure for each type:
pub enum DataType {
String(Vec<u8>),
List(VecDeque<Vec<u8>>),
Set(HashSet<Vec<u8>>),
Hash(HashMap<Vec<u8>, Vec<u8>>),
}
And StoredValue now holds a DataType instead of raw bytes:
pub struct StoredValue {
pub data: DataType,
pub expires_at: Option<Instant>,
}
Each variant holds whatever Rust collection makes sense for that Redis type — VecDeque for lists (push/pop at both ends), HashSet for sets, HashMap for hashes. Strings stay as Vec<u8> since that’s what they already were.
Enums also mean the compiler won’t let you forget to handle a variant. When (or maybe if) I add sorted sets, any match on DataType that doesn’t cover the new variant will fail to compile.
Type checking with expect_* methods
Every command that targets a specific type needs to verify it’s operating on the right one. Rather than scattering match statements across every operation, I added accessor methods on StoredValue:
pub const WRONGTYPE_ERR: &str =
"WRONGTYPE Operation against a key holding the wrong kind of value";
impl StoredValue {
pub fn expect_string(&self) -> Result<&Vec<u8>, String> {
match &self.data {
DataType::String(bytes) => Ok(bytes),
_ => Err(WRONGTYPE_ERR.to_string()),
}
}
pub fn expect_list_mut(&mut self) -> Result<&mut VecDeque<Vec<u8>>, String> {
match &mut self.data {
DataType::List(list) => Ok(list),
_ => Err(WRONGTYPE_ERR.to_string()),
}
}
// Same pattern for expect_set, expect_hash, and their _mut variants
}
Each method returns either a reference to the inner data or a WRONGTYPE error. There’s an immutable and mutable variant for each type, depending on whether the command is reading or writing.
This keeps the command implementations clean. Here’s what LPUSH looks like:
pub async fn lpush(&self, key: String, elements: Vec<Vec<u8>>) -> Result<i64, String> {
let mut write_guard = self.data.write().await;
// Handle expired keys...
let stored = write_guard
.entry(key)
.or_insert_with(|| StoredValue::new(DataType::List(VecDeque::new())));
let list = stored.expect_list_mut()?; // type check here
for elem in elements {
list.push_front(elem);
}
Ok(list.len() as i64)
}
The ? propagates the WRONGTYPE error up to the command handler. If the key already exists and holds a string, expect_list_mut() returns Err, the function bails, and the client gets the same error message Redis would give them.
Updating existing string operations
This touched every existing operation. get, for example, went from returning Option<Vec<u8>> to Result<Option<Vec<u8>>, String>, with data access going through expect_string():
pub async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, String> {
// ...
let bytes = value.expect_string()?;
Ok(Some(bytes.clone()))
// ...
}
Every string operation got the same treatment — incr_by, mget, set_nx, all of them. The exceptions are commands like SET and DEL that don’t care about the existing type. SET overwrites unconditionally, DEL just removes the key.
Tests also needed updating since get now returns Result:
// Before
assert_eq!(store.get("key").await, Some(b"value".to_vec()));
// After
assert_eq!(store.get("key").await, Ok(Some(b"value".to_vec())));
Splitting into modules
By Phase 3, store.rs was doing too much. I split it and the command parser into module directories:
src/store/
├── mod.rs # Store struct, active expiration
├── value.rs # DataType enum, StoredValue
├── glob.rs # Pattern matching (moved from store.rs)
├── string_ops.rs # GET, SET, INCR, MGET, etc.
├── ttl_ops.rs # EXPIRE, TTL, PERSIST, KEYS
├── list_ops.rs # LPUSH, RPUSH, LPOP, RPOP, LRANGE, LLEN
├── set_ops.rs # SADD, SREM, SMEMBERS, SISMEMBER, SCARD
└── hash_ops.rs # HSET, HGET, HDEL, HGETALL, HLEN
src/command/
├── mod.rs # Command enum, dispatch, execute
├── parse.rs # Shared helpers (extract_bulk_string, etc.)
├── string_cmds.rs # Parsing for string commands
├── ttl_cmds.rs # Parsing for TTL commands
├── list_cmds.rs # Parsing for list commands
├── set_cmds.rs # Parsing for set commands
└── hash_cmds.rs # Parsing for hash commands
When I later implemented lists, I created list_ops.rs and list_cmds.rs, added the variants to Command, wired up the dispatch, and the existing code didn’t need to change. I also pulled repeated RESP extraction logic into parse.rs, since every command parser was doing the same RespValue::BulkString unpacking.
Things I learned
I initially considered adding a type_tag: &str field to StoredValue and checking it at the start of each operation. The enum is better because you can’t access the inner VecDeque without matching on DataType::List — the compiler enforces it. With a type tag, a bug could bypass the check and mess things up.
I also spent a while on this refactoring before writing any new functionality, which felt slow at the time. But once the structure was in place, lists, sets, and hashes code fell quickly into place. They all followed the same pattern.
And moving code into a module directory doesn’t change the public API at all in Rust. The store still exposes the same methods, they’re just organized across files. store.rs was already getting long by Phase 2 — I should have split it earlier.
This kind of refactoring is also where LLMs actually earn their keep. The work is mechanical — the behavior shouldn’t change, but dozens of call sites need updating to match a new type signature. You know exactly what the result should look like, you just need someone to do it across 30 functions and their tests. That’s a terrible use of your afternoon and a great use of a code assistant. I had Claude handle most of the Option to Result migration and the module splits, then reviewed the diffs. The places where I caught real mistakes were few, because there wasn’t much room for creative interpretation — the compiler would’ve caught most of it anyway.
What’s next
Next post: the actual list, set, and hash implementations, also auto-deletion when collections become empty, and integration tests through redis-cli.
Code is here: github.com/aleksandar-had/rudis