Dojo · TypeScript · AI off

One key-value store, ten modules

TYPESCRIPT,
COLD

Across ten modules you build one real system, a key-value store, one part at a time, the way real requirements arrive. Each module extends what the last one built. No agents, no autocomplete, just you, the editor, and the tests.

Runs in your browserFix · extend · designPairs with Hands On
01 · What the ledger says

Where you actually are (Sep 26)

Before writing a single challenge, I read your practice history. Your last session was August 12: four TypeScript challenges, all four eventually green. You pressed “I'm stuck” eight times and revealed two solutions. Forty-four days later, recall on what you solved has decayed to about 23%.

4/4
solved on Aug 12
23%
recall today
8
rusty · drill these
5
shaky · never landed

Rusty

You knew these, and they've faded. They get short drills with no re-explaining.

Shaky

These never landed. They get re-taught from a different angle before the test.

Bars show recall today. Module 1 goes after api-boundaries, from a new angle.

02 · The store you're building

Ten modules, one system

The order follows how real systems usually grow: core operations, then time, then ordering, then undo, then concurrency. Your shaky skills (async coordination, debouncing, listener hygiene) sit in modules 7–9, once the basics are fluent again. I write each module after reading how the last one went.

03 · House rules

How to use a challenge

Timer on, AI off

45 minutes per challenge, with no autocomplete or agents. That includes the editor's own suggestions: read them if they appear, but don't lean on them.

Stuck? Press the button

After about 15 minutes with no progress, press “I'm stuck”. I read your actual attempts and give the smallest hint that unblocks you. You don't need to paste anything.

Aim for one or two runs

The ledger rewards solving it cold. Think, write, then run. Running after every line teaches the tests, not the skill.

04 · Module 01 · Core store

A boundary is a promise about what can't happen

Why this module exists

Last time, api-boundaries and abstraction-design didn't land: two challenges ended in a reveal. The explanation then was about layering, meaning which function calls which. This time we come at it from the other side: what must always be true about your data, and who is allowed to break it.

Start with the promise, not the functions

Our store holds string keys and string values. It also answers count(value): how many keys hold this value right now? You could loop over every key each time, but on a big store you'd keep a second map, a value index, from value to count. Now you have two structures that must agree:

The invariant: for every value v, counts.get(v) equals the number of keys whose value is v. It holds after every single operation, not just usually.

That sentence is the design. A boundary is the small set of places allowed to change state. The fewer there are, the fewer places you have to check the promise. If set and delete are the only ways in, you verify the invariant in two functions and you're done. If a caller can reach in and touch the map directly, you can't promise anything.

Walk it through

Step through a correct store. Watch what each operation has to do to keep both tables agreeing. Three of these steps are the ones people forget.

Interactive · step through the bookkeeping
data · key → value
counts · value → count

The misconception: “validation is a separate layer I can add later”

Validation is the boundary. If input gets checked after you've started changing state, a bad input leaves you half-changed: three keys written, the fourth rejected, and the index now reflects a batch that half happened. The rule is check everything, then change anything. Parse and stage first, then commit in one pass that can't fail.

The edge case: one key, two spellings

If " a " and "a" are meant to be the same key, normalisation has to happen at every entry point: set, get, delete, and bulk writes. Normalise in three of the four and you've created a key that can be written but never read back. One normaliseKey function called by every public method is how you make that impossible, rather than just unlikely.

Where this goes next

Once every write goes through one path and every read goes through one path, module 2's expiry turns into a change to the read path plus an injected clock. That's the payoff for being strict now.

Check · Why does set("a", "x") on a key that already holds "x" need special care?

Because a naive “decrement old, increment new” is fine, but a naive “always increment new” counts "x" twice. Either compare first and return early, or always undo the old value before applying the new one. Both keep the promise.

01Keep the count index honestFix

A teammate shipped this store, and dashboards built on count() slowly drift away from reality. Find where the invariant breaks and fix it. Every test must pass, and count, size and get must agree after any sequence of operations.

Budget · 20 minutes · aim for one run
02All-or-nothing setManyExtend

Bulk imports are where stores get corrupted: half the batch lands, then row 37 turns out to be bad. Do challenge 1 first, because this starter contains its fix. Then extend the store:

  • setMany(entries) takes [key, value] pairs and writes all of them or none of them. It returns the number of distinct keys written, and a later entry for the same key wins.
  • Keys are trimmed, and must be non-empty after trimming. Values must be strings. A bad entry throws an Error whose message starts with entry <index>:.
  • Trimming applies everywhere, so " a " and "a" are the same key for set, get, delete and setMany. set throws on an empty key too.
Budget · 30 minutes · aim for two runs or fewer

After both are green

Tomorrow's cold rewrite: delete everything and rebuild challenge 2's solution from a blank file in under 20 minutes. Then tell me you're done. I'll read the ledger and write module 2, expiry with an injected clock, based on how these two actually went.