← run

ts-09-typed-store

0.000
0/1 tests· typing
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