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
```ts
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: S = initial;
let subscribers: (() => void)[] = [];
function getState(): S {
return state;
}
function dispatch(action: A): void {
state = reducer(state, action);
subscribers.forEach(fn => fn());
}
function subscribe(fn: () => void): () => void {
subscribers.push(fn);
return () => {
const index = subscribers.indexOf(fn);
if (index !== -1) {
subscribers.splice(index, 1);
}
};
}
return {
getState,
dispatch,
subscribe,
};
}
```Test output (stdout)
TAP version 13 # Subtest: starts at the initial state ok 1 - starts at the initial state --- duration_ms: 0.373891 type: 'test' ... # Subtest: dispatch updates state via the reducer ok 2 - dispatch updates state via the reducer --- duration_ms: 0.07915 type: 'test' ... # Subtest: subscribers fire on every dispatch ok 3 - subscribers fire on every dispatch --- duration_ms: 0.06063 type: 'test' ... # Subtest: multiple subscribers all fire ok 4 - multiple subscribers all fire --- duration_ms: 0.04523 type: 'test' ... # Subtest: unsubscribe stops notifications ok 5 - unsubscribe stops notifications --- duration_ms: 0.05646 type: 'test' ... # Subtest: unsubscribing twice is harmless ok 6 - unsubscribing twice is harmless --- duration_ms: 0.07714 type: 'test' ... # Subtest: subscriber sees the updated state when notified ok 7 - subscriber sees the updated state when notified --- duration_ms: 0.31363 type: 'test' ... # Subtest: works with an object state shape ok 8 - works with an object state shape --- duration_ms: 0.07782 type: 'test' ... 1..8 # tests 8 # suites 0 # pass 8 # fail 0 # cancelled 0 # skipped 0 # todo 0 # duration_ms 79.831996
Reasoning (chain-of-thought)
The user wants me to implement a typed redux-style store in TypeScript. Let me break down the requirements:
1. Export a function `createStore` that is generic over state type `S` and action type `A`
2. Takes a reducer function `(state: S, action: A) => S` and initial state `S`
3. Returns an object with:
- `getState()`: returns current state `S`
- `dispatch(action: A)`: updates state and notifies subscribers
- `subscribe(fn: () => void)`: registers callback and returns unsubscribe function
Key requirements:
- Must be fully typed (no `any` in public API)
- Must pass `tsc --noEmit` in strict mode
- Subscribers should be called after state is updated
- Unsubscribing twice should be harmless
- Generic types should be inferred from reducer and initial state
Let me write the solution:
```ts
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: S = initial;
let subscribers: (() => void)[] = [];
function getState(): S {
return state;
}
function dispatch(action: A): void {
state = reducer(state, action);
subscribers.forEach(fn => fn());
}
function subscribe(fn: () => void): () => void {
subscribers.push(fn);
return () => {
const index = subscribers.indexOf(fn);
if (index !== -1) {
subscribers.splice(index, 1);
}
};
}
return {
getState,
dispatch,
subscribe,
};
}
```
Wait, I need to think about the typing more carefully. The `subscribe` function should return an unsubscribe function that can be called multiple times without error.
I'm considering the unsubscribe function's type. The current implementation allows multiple calls, which matches the requirement of being harmless when called twice. The `indexOf` and `splice` operations ensure we only remove the function once, preventing potential errors from repeated unsubscribing.
The implementation looks solid for handling the subscription and state management requirements. Each subscriber is tracked, and the unsubscribe function safely removes the callback from the subscribers array.