← run

ts-09-typed-store

1.000
8/8 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" });
});
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 = initial;
  const subscribers = new Set<() => void>();

  return {
    getState(): S {
      return state;
    },
    
    dispatch(action: A): void {
      state = reducer(state, action);
      subscribers.forEach(subscriber => subscriber());
    },
    
    subscribe(fn: () => void): () => void {
      subscribers.add(fn);
      
      return function unsubscribe(): void {
        subscribers.delete(fn);
      };
    }
  };
}
```

Test output (stdout)

TAP version 13
# Subtest: starts at the initial state
ok 1 - starts at the initial state
  ---
  duration_ms: 0.391071
  type: 'test'
  ...
# Subtest: dispatch updates state via the reducer
ok 2 - dispatch updates state via the reducer
  ---
  duration_ms: 3.339183
  type: 'test'
  ...
# Subtest: subscribers fire on every dispatch
ok 3 - subscribers fire on every dispatch
  ---
  duration_ms: 0.09181
  type: 'test'
  ...
# Subtest: multiple subscribers all fire
ok 4 - multiple subscribers all fire
  ---
  duration_ms: 0.04839
  type: 'test'
  ...
# Subtest: unsubscribe stops notifications
ok 5 - unsubscribe stops notifications
  ---
  duration_ms: 0.055021
  type: 'test'
  ...
# Subtest: unsubscribing twice is harmless
ok 6 - unsubscribing twice is harmless
  ---
  duration_ms: 0.08536
  type: 'test'
  ...
# Subtest: subscriber sees the updated state when notified
ok 7 - subscriber sees the updated state when notified
  ---
  duration_ms: 0.324761
  type: 'test'
  ...
# Subtest: works with an object state shape
ok 8 - works with an object state shape
  ---
  duration_ms: 0.350761
  type: 'test'
  ...
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 78.684314