ts-09-typed-store
1.000
Challenge · difficulty 5/5
# Typed redux-style store
Implement **`solution.ts`** exporting a generic store factory (a tiny redux).
Export exactly one function. It is generic over the state type `S` and the action type
`A`, and returns a store object:
```ts
export function createStore<S, A>(
reducer: (state: S, action: A) => S,
initial: S,
): {
getState(): S;
dispatch(action: A): void;
subscribe(fn: () => void): () => void;
};
```
Behavior:
- `getState()` returns the **current** state (starts as `initial`).
- `dispatch(action)` computes the next state as `reducer(currentState, action)`,
replaces the current state with it, then notifies **all** current subscribers (calls
each subscriber function once, with no arguments).
- `subscribe(fn)` registers `fn` and returns an **unsubscribe** function. Calling the
returned function removes `fn` so it is no longer notified on future dispatches.
Unsubscribing the same listener twice is harmless.
The store must be fully generic: `S` and `A` are inferred from the `reducer` and
`initial` arguments, and `dispatch` must only accept values of the action type `A`
(typically a discriminated union).
Example:
```ts
type Action = { type: "inc" } | { type: "add"; by: number };
const store = createStore<number, Action>((state, action) => {
switch (action.type) {
case "inc": return state + 1;
case "add": return state + action.by;
}
}, 0);
store.getState(); // 0
const off = store.subscribe(() => { /* ... */ });
store.dispatch({ type: "inc" }); // state -> 1, subscriber fired
store.dispatch({ type: "add", by: 5 }); // state -> 6
off(); // unsubscribe
```
Keep it fully typed (must pass `tsc --noEmit` in strict mode). Do not use `any` in the
public API.
tests/solution.test.ts
import { test } from "node:test";
import { strict as assert } from "node:assert";
import { createStore } from "./solution.ts";
type Action = { type: "inc" } | { type: "add"; by: number } | { type: "reset" };
function counterReducer(state: number, action: Action): number {
switch (action.type) {
case "inc":
return state + 1;
case "add":
return state + action.by;
case "reset":
return 0;
}
}
function makeCounter(initial = 0) {
return createStore<number, Action>(counterReducer, initial);
}
test("starts at the initial state", () => {
assert.equal(makeCounter(5).getState(), 5);
});
test("dispatch updates state via the reducer", () => {
const s = makeCounter();
s.dispatch({ type: "inc" });
assert.equal(s.getState(), 1);
s.dispatch({ type: "add", by: 5 });
assert.equal(s.getState(), 6);
s.dispatch({ type: "reset" });
assert.equal(s.getState(), 0);
});
test("subscribers fire on every dispatch", () => {
const s = makeCounter();
let calls = 0;
s.subscribe(() => {
calls += 1;
});
s.dispatch({ type: "inc" });
s.dispatch({ type: "inc" });
assert.equal(calls, 2);
});
test("multiple subscribers all fire", () => {
const s = makeCounter();
let a = 0;
let b = 0;
s.subscribe(() => {
a += 1;
});
s.subscribe(() => {
b += 1;
});
s.dispatch({ type: "inc" });
assert.equal(a, 1);
assert.equal(b, 1);
});
test("unsubscribe stops notifications", () => {
const s = makeCounter();
let calls = 0;
const off = s.subscribe(() => {
calls += 1;
});
s.dispatch({ type: "inc" });
off();
s.dispatch({ type: "inc" });
assert.equal(calls, 1);
assert.equal(s.getState(), 2);
});
test("unsubscribing twice is harmless", () => {
const s = makeCounter();
const off = s.subscribe(() => {});
off();
assert.doesNotThrow(() => off());
});
test("subscriber sees the updated state when notified", () => {
const s = makeCounter();
const seen: number[] = [];
s.subscribe(() => {
seen.push(s.getState());
});
s.dispatch({ type: "inc" });
s.dispatch({ type: "add", by: 10 });
assert.deepEqual(seen, [1, 11]);
});
test("works with an object state shape", () => {
type S = { count: number; label: string };
type A = { type: "bump" } | { type: "label"; text: string };
const s = createStore<S, A>((state, action) => {
switch (action.type) {
case "bump":
return { ...state, count: state.count + 1 };
case "label":
return { ...state, label: action.text };
}
}, { count: 0, label: "" });
s.dispatch({ type: "bump" });
s.dispatch({ type: "label", text: "hi" });
assert.deepEqual(s.getState(), { count: 1, label: "hi" });
});
System prompt
You are an expert programmer. Solve the task exactly as specified. Output your solution as fenced code blocks using the required file name(s) and the exact function/type signatures requested. Prefer correctness; do not include prose outside code unless asked.
Proposed solution
```typescript
export function createStore<S, A>(
reducer: (state: S, action: A) => S,
initial: S,
): {
getState(): S;
dispatch(action: A): void;
subscribe(fn: () => void): () => void;
} {
let state = initial;
const listeners = new Set<() => void>();
return {
getState() {
return state;
},
dispatch(action: A) {
state = reducer(state, action);
for (const listener of listeners) {
listener();
}
},
subscribe(fn: () => void) {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
},
};
}
```Test output (stdout)
TAP version 13 # Subtest: starts at the initial state ok 1 - starts at the initial state --- duration_ms: 0.372223 type: 'test' ... # Subtest: dispatch updates state via the reducer ok 2 - dispatch updates state via the reducer --- duration_ms: 0.08269 type: 'test' ... # Subtest: subscribers fire on every dispatch ok 3 - subscribers fire on every dispatch --- duration_ms: 0.085881 type: 'test' ... # Subtest: multiple subscribers all fire ok 4 - multiple subscribers all fire --- duration_ms: 0.046431 type: 'test' ... # Subtest: unsubscribe stops notifications ok 5 - unsubscribe stops notifications --- duration_ms: 0.0542 type: 'test' ... # Subtest: unsubscribing twice is harmless ok 6 - unsubscribing twice is harmless --- duration_ms: 0.07907 type: 'test' ... # Subtest: subscriber sees the updated state when notified ok 7 - subscriber sees the updated state when notified --- duration_ms: 0.314563 type: 'test' ... # Subtest: works with an object state shape ok 8 - works with an object state shape --- duration_ms: 0.08168 type: 'test' ... 1..8 # tests 8 # suites 0 # pass 8 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 74.611668