ts-09-typed-store
0.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" });
});
Proposed solution
# Typed redux-style store: `S`
Implement **`S`** exporting a generic store factory:
```js
function createStore<S, A, initial, by> (
state: S,
initial: S,
dispatch: (state: S, action: A) => void,
subscribe: (fn: () => void, by: () => void)
) {
getState: S;
off: () => void;
subscribe: () => void;
};
const inc = createStore<number, Action, number, by>
(state, action, by, initial) => {
switch (action.type) {
case "inc": return state + 1;
case "add": return state + action.by;
},
each (fn) => { /* ... */ };
each (by) => { /* ... */ };
};
dispatch;
off;
subscribe;
const off = store.subscribe(() => { /* ... */ });
const initial = store.getState(); // 0
const by = store.dispatch({ type: "inc", by: 5 });
```
**Behavior:**
- `getState()` returns the **current** state (starts as `initial`).
- `subscribe(fn)` returns a **function** (typically a discriminated union, not a string).
- `dispatch(action)` returns the **current** state (typically a discriminated union, not a string).
- `off()` returns **null** (typically a discriminated union, not a string).
- `subscribe(fn)` returns **null** (typically a discriminated union, not a string).
- `dispatch(action)` returns **null** (typically a discriminated union, not a string).
- `init` returns **null** (typically a discriminated union, not a string).
- `init` returns **null** (typically a discriminated union, not a string).
- `init` returns **null** (typically a discriminated union, not a string).
- `init` returns **null** (typically a discriminated union, not a string).
- `init` returns **null** (typically a discriminated union, not a string).
- `init` returns **null** (typically a discriminated union, not a string).
- `init` returns **null** (typically a discriminated union, not a string).
Test output (stdout)
TAP version 13
# node:internal/modules/run_main:107
# triggerUncaughtException(
# ^
# Error: Transform failed with 1 error:
# /tmp/llmlab-ts-09-typed-store-weccqy23/solution.ts:5:16: ERROR: Expected ")" but found ":"
# at failureErrorWithLog (/home/psc/opt/node/lib/node_modules/tsx/node_modules/esbuild/lib/main.js:1748:15)
# at /home/psc/opt/node/lib/node_modules/tsx/node_modules/esbuild/lib/main.js:1017:50
# at responseCallbacks.<computed> (/home/psc/opt/node/lib/node_modules/tsx/node_modules/esbuild/lib/main.js:884:9)
# at handleIncomingPacket (/home/psc/opt/node/lib/node_modules/tsx/node_modules/esbuild/lib/main.js:939:12)
# at Socket.readFromStdout (/home/psc/opt/node/lib/node_modules/tsx/node_modules/esbuild/lib/main.js:862:7)
# at Socket.emit (node:events:509:28)
# at addChunk (node:internal/streams/readable:563:12)
# at readableAddChunkPushByteMode (node:internal/streams/readable:514:3)
# at Readable.push (node:internal/streams/readable:394:5)
# at Pipe.onStreamRead (node:internal/stream_base_commons:189:23) {
# name: 'TransformError'
# }
# Node.js v24.16.0
# Subtest: solution.test.ts
not ok 1 - solution.test.ts
---
duration_ms: 60.973846
type: 'test'
location: '/tmp/llmlab-ts-09-typed-store-weccqy23/solution.test.ts:1:1'
failureType: 'testCodeFailure'
exitCode: 1
signal: ~
error: 'test failed'
code: 'ERR_TEST_FAILURE'
...
1..1
# tests 1
# suites 0
# pass 0
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 64.971063