gof-patterns 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/dist/behavioral.d.ts +147 -0
- package/dist/behavioral.js +156 -0
- package/dist/creational.d.ts +44 -0
- package/dist/creational.js +57 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/structural.d.ts +62 -0
- package/dist/structural.js +89 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Tellez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# gof-patterns
|
|
2
|
+
|
|
3
|
+
The [Gang of Four catalog](https://refactoring.guru/design-patterns/catalog) as tiny, typed
|
|
4
|
+
helpers. You write the domain logic; the pattern plumbing is already done.
|
|
5
|
+
|
|
6
|
+
Zero dependencies. ESM. ~6 kB packed.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm i gof-patterns
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { stateMachine, chain, singleton } from 'gof-patterns';
|
|
14
|
+
|
|
15
|
+
const order = stateMachine({
|
|
16
|
+
initial: 'draft',
|
|
17
|
+
states: { draft: { pay: 'paid' }, paid: { ship: 'sent' }, sent: {} },
|
|
18
|
+
});
|
|
19
|
+
order.send('pay'); // 'paid'
|
|
20
|
+
order.send('pay'); // throws: "pay" is not allowed in "sent"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## When would I use this?
|
|
24
|
+
|
|
25
|
+
[**How to use it, and when**](https://github.com/Andrew-Tellez/patterns/blob/main/USE-CASES.md) walks through ten situations from real
|
|
26
|
+
code — a webhook with several payload shapes, an order that must not skip steps, a flaky
|
|
27
|
+
provider that needs retries — and says which helper each one calls for.
|
|
28
|
+
|
|
29
|
+
## The catalog
|
|
30
|
+
|
|
31
|
+
### Creational
|
|
32
|
+
|
|
33
|
+
| Pattern | Helper |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| Singleton | `singleton(factory)` — one lazy instance, `.reset()` for tests |
|
|
36
|
+
| Factory Method | `registry()` — `register(key, creator)` / `create(key, ...args)` |
|
|
37
|
+
| Abstract Factory | `registry()` — one registry per family |
|
|
38
|
+
| Builder | `builder<T>(defaults?, build?)` — fluent setters, no class |
|
|
39
|
+
| Prototype | `clone(value)` — thin alias for `structuredClone` |
|
|
40
|
+
|
|
41
|
+
### Structural
|
|
42
|
+
|
|
43
|
+
| Pattern | Helper |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| Adapter | `adapt(source, methods)` |
|
|
46
|
+
| Decorator | `decorate(fn, ...wrappers)` — retry/cache/log layers |
|
|
47
|
+
| Composite | `composite(value, children?)` — `add`, `walk()`, `sum()` |
|
|
48
|
+
| Flyweight | `flyweight(factory, key?)` — shared instances per key |
|
|
49
|
+
| Proxy | `lazy(loader)` — real object built on first access |
|
|
50
|
+
| Facade | *no helper* — an object that delegates. Just write it. |
|
|
51
|
+
| Bridge | *no helper* — pass the implementation in. Just write it. |
|
|
52
|
+
|
|
53
|
+
### Behavioral
|
|
54
|
+
|
|
55
|
+
| Pattern | Helper |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| Chain of Responsibility | `chain(handlers, fallback?)` — `(req, next) => res`, sync or async |
|
|
58
|
+
| Command | `commandBus()` — `run`, `undo`, `redo` |
|
|
59
|
+
| Observer | `subject<T>()` — `subscribe` returns the unsubscribe |
|
|
60
|
+
| Mediator | `mediator<Events>()` — typed `on` / `emit` hub |
|
|
61
|
+
| Memento | `history(initial, { limit?, snapshot? })` — undo/redo |
|
|
62
|
+
| State | `stateMachine({ initial, states })` |
|
|
63
|
+
| Strategy | `registry()` — swap the registered implementation |
|
|
64
|
+
| Visitor | `visitor(visitors, fallback?)` — dispatch on `node.type` |
|
|
65
|
+
| Template Method | `template(defaults, skeleton)` — override single steps |
|
|
66
|
+
| Iterator | *no helper* — generators and `for...of` are in the language |
|
|
67
|
+
|
|
68
|
+
Five entries have no helper on purpose: a wrapper there would be more code to read
|
|
69
|
+
than the pattern it hides. The table says what to write instead.
|
|
70
|
+
|
|
71
|
+
## Examples
|
|
72
|
+
|
|
73
|
+
**Decorator** — layers, outermost first:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const withRetry = (n: number) => (next) => async (...args) => {
|
|
77
|
+
for (let i = 0; ; i++) {
|
|
78
|
+
try { return await next(...args); } catch (e) { if (i >= n) throw e; }
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const fetchUser = decorate(rawFetchUser, withLog, withRetry(3));
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Chain of Responsibility** — first handler that answers wins:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const route = chain<Ticket, string>(
|
|
88
|
+
[(t, next) => (t.level === 1 ? 'bot' : next()),
|
|
89
|
+
(t, next) => (t.paid ? 'human' : next())],
|
|
90
|
+
() => 'queue',
|
|
91
|
+
);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Memento** — undo over snapshots (pass `snapshot` if you mutate state in place):
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
const h = history({ text: '' }, { limit: 50, snapshot: structuredClone });
|
|
98
|
+
h.save({ text: 'hi' });
|
|
99
|
+
h.undo(); // { text: '' }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm test # node --test, no framework
|
|
106
|
+
npm run build # tsc
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/** Behavioral patterns. */
|
|
2
|
+
/**
|
|
3
|
+
* Chain of Responsibility — each handler either answers or calls `next()`.
|
|
4
|
+
* Works for sync and async (`Res` can be a Promise).
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* const support = chain<Ticket, string>(
|
|
8
|
+
* [(t, next) => (t.level === 1 ? 'bot' : next()), (t, next) => (t.paid ? 'human' : next())],
|
|
9
|
+
* () => 'queue',
|
|
10
|
+
* );
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export type Handler<Req, Res> = (request: Req, next: () => Res) => Res;
|
|
14
|
+
export declare function chain<Req, Res>(handlers: Handler<Req, Res>[], fallback?: (request: Req) => Res): (request: Req) => Res;
|
|
15
|
+
/**
|
|
16
|
+
* Command — undoable operations with history.
|
|
17
|
+
*
|
|
18
|
+
* ```ts
|
|
19
|
+
* const bus = commandBus();
|
|
20
|
+
* bus.run({ do: () => doc.push('a'), undo: () => doc.pop() });
|
|
21
|
+
* bus.undo();
|
|
22
|
+
* bus.redo();
|
|
23
|
+
* ```
|
|
24
|
+
* A command without `undo` clears the redo stack and cannot be undone.
|
|
25
|
+
*/
|
|
26
|
+
export type Command<T = unknown> = {
|
|
27
|
+
do(): T;
|
|
28
|
+
undo?(): void;
|
|
29
|
+
};
|
|
30
|
+
export type CommandBus = {
|
|
31
|
+
run<T>(command: Command<T>): T;
|
|
32
|
+
undo(): boolean;
|
|
33
|
+
redo(): boolean;
|
|
34
|
+
canUndo(): boolean;
|
|
35
|
+
canRedo(): boolean;
|
|
36
|
+
};
|
|
37
|
+
export declare function commandBus(): CommandBus;
|
|
38
|
+
/**
|
|
39
|
+
* Observer — one typed channel. `subscribe` returns the unsubscribe function.
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* const priceChanged = subject<number>();
|
|
43
|
+
* const off = priceChanged.subscribe((p) => render(p));
|
|
44
|
+
* priceChanged.emit(9.99);
|
|
45
|
+
* off();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export type Subject<T> = {
|
|
49
|
+
subscribe(listener: (value: T) => void): () => void;
|
|
50
|
+
emit(value: T): void;
|
|
51
|
+
size(): number;
|
|
52
|
+
};
|
|
53
|
+
export declare function subject<T>(): Subject<T>;
|
|
54
|
+
/**
|
|
55
|
+
* Mediator — components talk to a typed hub, never to each other.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* const hub = mediator<{ login: { id: string }; logout: void }>();
|
|
59
|
+
* hub.on('login', ({ id }) => track(id));
|
|
60
|
+
* hub.emit('login', { id: 'u1' });
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export type Mediator<E extends Record<string, unknown>> = {
|
|
64
|
+
on<K extends keyof E>(event: K, listener: (payload: E[K]) => void): () => void;
|
|
65
|
+
emit<K extends keyof E>(event: K, payload: E[K]): void;
|
|
66
|
+
};
|
|
67
|
+
export declare function mediator<E extends Record<string, unknown>>(): Mediator<E>;
|
|
68
|
+
/**
|
|
69
|
+
* Memento — undo/redo over snapshots of state.
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* const h = history({ text: '' });
|
|
73
|
+
* h.save({ text: 'hi' });
|
|
74
|
+
* h.undo(); // { text: '' }
|
|
75
|
+
* ```
|
|
76
|
+
* Snapshots are stored by reference — pass a copy, or a `snapshot` function,
|
|
77
|
+
* if the state object is mutated in place.
|
|
78
|
+
*/
|
|
79
|
+
export type History<T> = {
|
|
80
|
+
current(): T;
|
|
81
|
+
save(state: T): void;
|
|
82
|
+
undo(): T | undefined;
|
|
83
|
+
redo(): T | undefined;
|
|
84
|
+
canUndo(): boolean;
|
|
85
|
+
canRedo(): boolean;
|
|
86
|
+
};
|
|
87
|
+
export declare function history<T>(initial: T, options?: {
|
|
88
|
+
limit?: number;
|
|
89
|
+
snapshot?: (state: T) => T;
|
|
90
|
+
}): History<T>;
|
|
91
|
+
/**
|
|
92
|
+
* State — a finite state machine from a transition table.
|
|
93
|
+
*
|
|
94
|
+
* ```ts
|
|
95
|
+
* const order = stateMachine({
|
|
96
|
+
* initial: 'draft',
|
|
97
|
+
* states: { draft: { pay: 'paid' }, paid: { ship: 'sent' }, sent: {} },
|
|
98
|
+
* });
|
|
99
|
+
* order.send('pay'); // 'paid'
|
|
100
|
+
* order.can('ship'); // true
|
|
101
|
+
* ```
|
|
102
|
+
* A transition value may be a target state or `(payload) => target`.
|
|
103
|
+
*/
|
|
104
|
+
export type Transitions<S extends string, E extends string> = Record<S, Partial<Record<E, S | ((payload: unknown) => S)>>>;
|
|
105
|
+
export type StateMachine<S extends string, E extends string> = {
|
|
106
|
+
state(): S;
|
|
107
|
+
can(event: E): boolean;
|
|
108
|
+
/** Returns the new state. Throws on an event the current state does not allow. */
|
|
109
|
+
send(event: E, payload?: unknown): S;
|
|
110
|
+
onChange(listener: (change: {
|
|
111
|
+
from: S;
|
|
112
|
+
to: S;
|
|
113
|
+
event: E;
|
|
114
|
+
}) => void): () => void;
|
|
115
|
+
};
|
|
116
|
+
export declare function stateMachine<S extends string, E extends string>(config: {
|
|
117
|
+
initial: S;
|
|
118
|
+
states: Transitions<S, E>;
|
|
119
|
+
}): StateMachine<S, E>;
|
|
120
|
+
/**
|
|
121
|
+
* Visitor — dispatch on a node's kind instead of a switch scattered everywhere.
|
|
122
|
+
*
|
|
123
|
+
* ```ts
|
|
124
|
+
* const area = visitor<Shape, number>({
|
|
125
|
+
* circle: (c) => Math.PI * c.r ** 2,
|
|
126
|
+
* square: (s) => s.side ** 2,
|
|
127
|
+
* });
|
|
128
|
+
* area({ type: 'circle', r: 1 });
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export declare function visitor<N extends {
|
|
132
|
+
type: string;
|
|
133
|
+
}, R>(visitors: {
|
|
134
|
+
[K in N['type']]?: (node: Extract<N, {
|
|
135
|
+
type: K;
|
|
136
|
+
}>) => R;
|
|
137
|
+
}, fallback?: (node: N) => R): (node: N) => R;
|
|
138
|
+
/**
|
|
139
|
+
* Template Method — a fixed skeleton with replaceable steps.
|
|
140
|
+
*
|
|
141
|
+
* ```ts
|
|
142
|
+
* const mine = template({ read: () => csv(), parse: (s: string) => s.split(',') },
|
|
143
|
+
* (hooks) => hooks.parse(hooks.read()));
|
|
144
|
+
* mine({ read: () => 'a,b' })(); // ['a', 'b']
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export declare function template<H extends Record<string, (...args: never[]) => unknown>, A extends unknown[], R>(defaults: H, skeleton: (hooks: H, ...args: A) => R): (overrides?: Partial<H>) => (...args: A) => R;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** Behavioral patterns. */
|
|
2
|
+
export function chain(handlers, fallback) {
|
|
3
|
+
return (request) => {
|
|
4
|
+
const step = (i) => {
|
|
5
|
+
const handler = handlers[i];
|
|
6
|
+
if (!handler) {
|
|
7
|
+
if (!fallback)
|
|
8
|
+
throw new Error('chain: no handler answered and no fallback was given');
|
|
9
|
+
return fallback(request);
|
|
10
|
+
}
|
|
11
|
+
return handler(request, () => step(i + 1));
|
|
12
|
+
};
|
|
13
|
+
return step(0);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function commandBus() {
|
|
17
|
+
const done = [];
|
|
18
|
+
let undone = [];
|
|
19
|
+
return {
|
|
20
|
+
run(command) {
|
|
21
|
+
const result = command.do();
|
|
22
|
+
undone = [];
|
|
23
|
+
if (command.undo)
|
|
24
|
+
done.push(command);
|
|
25
|
+
return result;
|
|
26
|
+
},
|
|
27
|
+
undo() {
|
|
28
|
+
const command = done.pop();
|
|
29
|
+
if (!command)
|
|
30
|
+
return false;
|
|
31
|
+
command.undo?.();
|
|
32
|
+
undone.push(command);
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
redo() {
|
|
36
|
+
const command = undone.pop();
|
|
37
|
+
if (!command)
|
|
38
|
+
return false;
|
|
39
|
+
command.do();
|
|
40
|
+
done.push(command);
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
canUndo: () => done.length > 0,
|
|
44
|
+
canRedo: () => undone.length > 0,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function subject() {
|
|
48
|
+
const listeners = new Set();
|
|
49
|
+
return {
|
|
50
|
+
subscribe(listener) {
|
|
51
|
+
listeners.add(listener);
|
|
52
|
+
return () => void listeners.delete(listener);
|
|
53
|
+
},
|
|
54
|
+
// Iterate a copy: a listener may unsubscribe during emit.
|
|
55
|
+
emit: (value) => [...listeners].forEach((listener) => listener(value)),
|
|
56
|
+
size: () => listeners.size,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function mediator() {
|
|
60
|
+
const channels = new Map();
|
|
61
|
+
const channel = (event) => {
|
|
62
|
+
let existing = channels.get(event);
|
|
63
|
+
if (!existing)
|
|
64
|
+
channels.set(event, (existing = subject()));
|
|
65
|
+
return existing;
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
on: (event, listener) => channel(event).subscribe(listener),
|
|
69
|
+
emit: (event, payload) => channel(event).emit(payload),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function history(initial, options = {}) {
|
|
73
|
+
const { limit = Infinity, snapshot = (state) => state } = options;
|
|
74
|
+
const past = [];
|
|
75
|
+
const future = [];
|
|
76
|
+
let present = snapshot(initial);
|
|
77
|
+
return {
|
|
78
|
+
current: () => present,
|
|
79
|
+
save(state) {
|
|
80
|
+
past.push(present);
|
|
81
|
+
if (past.length > limit)
|
|
82
|
+
past.shift();
|
|
83
|
+
future.length = 0;
|
|
84
|
+
present = snapshot(state);
|
|
85
|
+
},
|
|
86
|
+
undo() {
|
|
87
|
+
if (!past.length)
|
|
88
|
+
return undefined;
|
|
89
|
+
future.push(present);
|
|
90
|
+
return (present = past.pop());
|
|
91
|
+
},
|
|
92
|
+
redo() {
|
|
93
|
+
if (!future.length)
|
|
94
|
+
return undefined;
|
|
95
|
+
past.push(present);
|
|
96
|
+
return (present = future.pop());
|
|
97
|
+
},
|
|
98
|
+
canUndo: () => past.length > 0,
|
|
99
|
+
canRedo: () => future.length > 0,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function stateMachine(config) {
|
|
103
|
+
let state = config.initial;
|
|
104
|
+
const changes = subject();
|
|
105
|
+
const target = (event) => config.states[state]?.[event];
|
|
106
|
+
return {
|
|
107
|
+
state: () => state,
|
|
108
|
+
can: (event) => target(event) !== undefined,
|
|
109
|
+
send(event, payload) {
|
|
110
|
+
const to = target(event);
|
|
111
|
+
if (to === undefined)
|
|
112
|
+
throw new Error(`stateMachine: "${event}" is not allowed in "${state}"`);
|
|
113
|
+
const from = state;
|
|
114
|
+
// S extends string, so a function value is always the dynamic-target form.
|
|
115
|
+
state = typeof to === 'function' ? to(payload) : to;
|
|
116
|
+
changes.emit({ from, to: state, event });
|
|
117
|
+
return state;
|
|
118
|
+
},
|
|
119
|
+
onChange: (listener) => changes.subscribe(listener),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Visitor — dispatch on a node's kind instead of a switch scattered everywhere.
|
|
124
|
+
*
|
|
125
|
+
* ```ts
|
|
126
|
+
* const area = visitor<Shape, number>({
|
|
127
|
+
* circle: (c) => Math.PI * c.r ** 2,
|
|
128
|
+
* square: (s) => s.side ** 2,
|
|
129
|
+
* });
|
|
130
|
+
* area({ type: 'circle', r: 1 });
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
export function visitor(visitors, fallback) {
|
|
134
|
+
return (node) => {
|
|
135
|
+
const visit = visitors[node.type];
|
|
136
|
+
if (visit)
|
|
137
|
+
return visit(node);
|
|
138
|
+
if (fallback)
|
|
139
|
+
return fallback(node);
|
|
140
|
+
throw new Error(`visitor: no visitor for "${node.type}"`);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Template Method — a fixed skeleton with replaceable steps.
|
|
145
|
+
*
|
|
146
|
+
* ```ts
|
|
147
|
+
* const mine = template({ read: () => csv(), parse: (s: string) => s.split(',') },
|
|
148
|
+
* (hooks) => hooks.parse(hooks.read()));
|
|
149
|
+
* mine({ read: () => 'a,b' })(); // ['a', 'b']
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export function template(defaults, skeleton) {
|
|
153
|
+
return (overrides) => (...args) => skeleton({ ...defaults, ...overrides }, ...args);
|
|
154
|
+
}
|
|
155
|
+
// ponytail: Iterator is a generator function plus `for...of` — already in the
|
|
156
|
+
// language, so there is no helper for it.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** Creational patterns. */
|
|
2
|
+
/**
|
|
3
|
+
* Singleton — one lazily created instance, shared.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const db = singleton(() => connect(url));
|
|
7
|
+
* db() === db(); // same instance
|
|
8
|
+
* db.reset(); // drop it (tests)
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export declare function singleton<T>(factory: () => T): (() => T) & {
|
|
12
|
+
reset(): void;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Factory Method / Abstract Factory / Strategy — a registry keyed by name.
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* const shapes = registry<{ circle: (r: number) => Shape }>();
|
|
19
|
+
* shapes.register('circle', (r) => new Circle(r));
|
|
20
|
+
* shapes.create('circle', 2);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export type Registry<M extends Record<string, (...args: never[]) => unknown>> = {
|
|
24
|
+
register<K extends keyof M>(key: K, creator: M[K]): void;
|
|
25
|
+
create<K extends keyof M>(key: K, ...args: Parameters<M[K]>): ReturnType<M[K]>;
|
|
26
|
+
has(key: PropertyKey): boolean;
|
|
27
|
+
keys(): (keyof M)[];
|
|
28
|
+
};
|
|
29
|
+
export declare function registry<M extends Record<string, (...args: never[]) => unknown>>(initial?: Partial<M>): Registry<M>;
|
|
30
|
+
/**
|
|
31
|
+
* Builder — fluent setters for a plain object, no class boilerplate.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* const pizza = builder<Pizza>({ size: 'M' }).cheese(true).size('L').build();
|
|
35
|
+
* ```
|
|
36
|
+
* Pass `build` to validate or construct something else at the end.
|
|
37
|
+
*/
|
|
38
|
+
export type Builder<T, R = T> = {
|
|
39
|
+
[K in keyof T]-?: (value: T[K]) => Builder<T, R>;
|
|
40
|
+
} & {
|
|
41
|
+
build(): R;
|
|
42
|
+
};
|
|
43
|
+
export declare function builder<T extends object, R = T>(defaults?: Partial<T>, build?: (draft: Partial<T>) => R): Builder<T, R>;
|
|
44
|
+
export declare const clone: <T>(value: T) => T;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Creational patterns. */
|
|
2
|
+
/**
|
|
3
|
+
* Singleton — one lazily created instance, shared.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const db = singleton(() => connect(url));
|
|
7
|
+
* db() === db(); // same instance
|
|
8
|
+
* db.reset(); // drop it (tests)
|
|
9
|
+
* ```
|
|
10
|
+
*/
|
|
11
|
+
export function singleton(factory) {
|
|
12
|
+
let value;
|
|
13
|
+
let made = false;
|
|
14
|
+
const get = () => {
|
|
15
|
+
if (!made) {
|
|
16
|
+
value = factory();
|
|
17
|
+
made = true;
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
get.reset = () => {
|
|
22
|
+
made = false;
|
|
23
|
+
value = undefined;
|
|
24
|
+
};
|
|
25
|
+
return get;
|
|
26
|
+
}
|
|
27
|
+
export function registry(initial) {
|
|
28
|
+
const map = new Map(Object.entries(initial ?? {}));
|
|
29
|
+
return {
|
|
30
|
+
register: (key, creator) => void map.set(key, creator),
|
|
31
|
+
create: (key, ...args) => {
|
|
32
|
+
const creator = map.get(key);
|
|
33
|
+
if (!creator)
|
|
34
|
+
throw new Error(`registry: nothing registered for "${String(key)}"`);
|
|
35
|
+
return creator(...args);
|
|
36
|
+
},
|
|
37
|
+
has: (key) => map.has(key),
|
|
38
|
+
keys: () => [...map.keys()],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function builder(defaults, build) {
|
|
42
|
+
const draft = { ...defaults };
|
|
43
|
+
const proxy = new Proxy({}, {
|
|
44
|
+
get(_t, prop) {
|
|
45
|
+
if (prop === 'build')
|
|
46
|
+
return () => (build ? build(draft) : { ...draft });
|
|
47
|
+
return (value) => {
|
|
48
|
+
draft[prop] = value;
|
|
49
|
+
return proxy;
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return proxy;
|
|
54
|
+
}
|
|
55
|
+
// ponytail: Prototype is `structuredClone` in the stdlib. Re-exported so the
|
|
56
|
+
// catalog is complete; it does not copy functions or class prototypes.
|
|
57
|
+
export const clone = (value) => structuredClone(value);
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Structural patterns. */
|
|
2
|
+
/**
|
|
3
|
+
* Adapter — expose an incompatible object through the interface you want.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const logger = adapt(winston, { log: (w) => (m: string) => w.info(m) });
|
|
7
|
+
* ```
|
|
8
|
+
*/
|
|
9
|
+
export declare function adapt<S, A extends Record<string, (source: S) => unknown>>(source: S, methods: A): {
|
|
10
|
+
[K in keyof A]: ReturnType<A[K]>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Decorator — wrap a function in layers (retry, cache, log) without touching it.
|
|
14
|
+
* Applied left to right: the first wrapper is the outermost.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* const fetchUser = decorate(rawFetchUser, withLog, withRetry(3));
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export type Wrapper<F extends (...args: never[]) => unknown> = (next: F) => F;
|
|
21
|
+
export declare function decorate<F extends (...args: never[]) => unknown>(fn: F, ...wrappers: Wrapper<F>[]): F;
|
|
22
|
+
/**
|
|
23
|
+
* Composite — treat a tree of nodes like a single node.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* const box = composite({ price: 10 });
|
|
27
|
+
* box.add(composite({ price: 5 }));
|
|
28
|
+
* box.sum((n) => n.price); // 15
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export type Composite<T> = {
|
|
32
|
+
readonly value: T;
|
|
33
|
+
readonly children: Composite<T>[];
|
|
34
|
+
add(...children: Composite<T>[]): Composite<T>;
|
|
35
|
+
remove(child: Composite<T>): boolean;
|
|
36
|
+
/** Depth-first, self first. */
|
|
37
|
+
walk(): Generator<Composite<T>>;
|
|
38
|
+
sum(of: (value: T) => number): number;
|
|
39
|
+
};
|
|
40
|
+
export declare function composite<T>(value: T, children?: Composite<T>[]): Composite<T>;
|
|
41
|
+
/**
|
|
42
|
+
* Flyweight — share immutable instances instead of re-creating them.
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* const treeType = flyweight((name: string, color: string) => ({ name, color }));
|
|
46
|
+
* treeType('oak', 'green') === treeType('oak', 'green'); // true
|
|
47
|
+
* ```
|
|
48
|
+
* Default key is `JSON.stringify(args)`; pass `key` for anything richer.
|
|
49
|
+
*/
|
|
50
|
+
export declare function flyweight<A extends unknown[], T>(factory: (...args: A) => T, key?: (...args: A) => string): ((...args: A) => T) & {
|
|
51
|
+
size(): number;
|
|
52
|
+
clear(): void;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Proxy (virtual) — build the real object on first property access.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* const heavy = lazy(() => loadHugeThing()); // nothing loaded yet
|
|
59
|
+
* heavy.query('x'); // loads now, once
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export declare function lazy<T extends object>(loader: () => T): T;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Structural patterns. */
|
|
2
|
+
/**
|
|
3
|
+
* Adapter — expose an incompatible object through the interface you want.
|
|
4
|
+
*
|
|
5
|
+
* ```ts
|
|
6
|
+
* const logger = adapt(winston, { log: (w) => (m: string) => w.info(m) });
|
|
7
|
+
* ```
|
|
8
|
+
*/
|
|
9
|
+
export function adapt(source, methods) {
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const key of Object.keys(methods)) {
|
|
12
|
+
out[key] = methods[key](source);
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
export function decorate(fn, ...wrappers) {
|
|
17
|
+
return wrappers.reduceRight((next, wrap) => wrap(next), fn);
|
|
18
|
+
}
|
|
19
|
+
export function composite(value, children = []) {
|
|
20
|
+
const node = {
|
|
21
|
+
value,
|
|
22
|
+
children,
|
|
23
|
+
add(...kids) {
|
|
24
|
+
children.push(...kids);
|
|
25
|
+
return node;
|
|
26
|
+
},
|
|
27
|
+
remove(child) {
|
|
28
|
+
const i = children.indexOf(child);
|
|
29
|
+
if (i < 0)
|
|
30
|
+
return false;
|
|
31
|
+
children.splice(i, 1);
|
|
32
|
+
return true;
|
|
33
|
+
},
|
|
34
|
+
*walk() {
|
|
35
|
+
yield node;
|
|
36
|
+
for (const child of children)
|
|
37
|
+
yield* child.walk();
|
|
38
|
+
},
|
|
39
|
+
sum(of) {
|
|
40
|
+
let total = 0;
|
|
41
|
+
for (const n of node.walk())
|
|
42
|
+
total += of(n.value);
|
|
43
|
+
return total;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
return node;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Flyweight — share immutable instances instead of re-creating them.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* const treeType = flyweight((name: string, color: string) => ({ name, color }));
|
|
53
|
+
* treeType('oak', 'green') === treeType('oak', 'green'); // true
|
|
54
|
+
* ```
|
|
55
|
+
* Default key is `JSON.stringify(args)`; pass `key` for anything richer.
|
|
56
|
+
*/
|
|
57
|
+
export function flyweight(factory, key = (...args) => JSON.stringify(args)) {
|
|
58
|
+
const cache = new Map();
|
|
59
|
+
const get = (...args) => {
|
|
60
|
+
const k = key(...args);
|
|
61
|
+
if (!cache.has(k))
|
|
62
|
+
cache.set(k, factory(...args));
|
|
63
|
+
return cache.get(k);
|
|
64
|
+
};
|
|
65
|
+
get.size = () => cache.size;
|
|
66
|
+
get.clear = () => cache.clear();
|
|
67
|
+
return get;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Proxy (virtual) — build the real object on first property access.
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* const heavy = lazy(() => loadHugeThing()); // nothing loaded yet
|
|
74
|
+
* heavy.query('x'); // loads now, once
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function lazy(loader) {
|
|
78
|
+
let target;
|
|
79
|
+
const get = () => (target ??= loader());
|
|
80
|
+
return new Proxy({}, {
|
|
81
|
+
get: (_t, prop, receiver) => Reflect.get(get(), prop, receiver),
|
|
82
|
+
set: (_t, prop, value) => Reflect.set(get(), prop, value),
|
|
83
|
+
has: (_t, prop) => Reflect.has(get(), prop),
|
|
84
|
+
ownKeys: () => Reflect.ownKeys(get()),
|
|
85
|
+
getOwnPropertyDescriptor: (_t, prop) => Reflect.getOwnPropertyDescriptor(get(), prop),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// ponytail: Facade and Bridge are plain composition — an object or class that
|
|
89
|
+
// delegates. No runtime helper can make that shorter than writing it. See README.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gof-patterns",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GoF design patterns as tiny, typed helpers. You supply the domain logic; the pattern plumbing is done.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Andrew Tellez",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Andrew-Tellez/patterns.git",
|
|
10
|
+
"directory": "packages/ts"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Andrew-Tellez/patterns#readme",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"design-patterns",
|
|
15
|
+
"gof",
|
|
16
|
+
"typescript",
|
|
17
|
+
"singleton",
|
|
18
|
+
"builder",
|
|
19
|
+
"state-machine",
|
|
20
|
+
"observer",
|
|
21
|
+
"strategy"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json",
|
|
38
|
+
"test": "node --test src/*.test.ts",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^26.4.1",
|
|
43
|
+
"typescript": "^5.7.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|