standard-typed-config 0.0.1
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/.claude/homunculus/observations.jsonl +79 -0
- package/API.md +124 -0
- package/DESIGN.md +244 -0
- package/HOWTO.md +214 -0
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/TUTORIAL.md +152 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +14 -0
- package/package.json +27 -0
- package/src/index.ts +49 -0
- package/test/types.test-d.ts +199 -0
- package/tsconfig.json +16 -0
package/HOWTO.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# How-to Guides
|
|
2
|
+
|
|
3
|
+
Practical tasks for working with `typed-builder`. Each guide assumes you understand the core concepts in [DESIGN.md](./DESIGN.md).
|
|
4
|
+
|
|
5
|
+
## How to Create a Builder for a New Domain
|
|
6
|
+
|
|
7
|
+
1. **Define your item configuration type.**
|
|
8
|
+
This is the shape each item in your domain has:
|
|
9
|
+
|
|
10
|
+
```typescript
|
|
11
|
+
type MyItemConfig = {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
priority: number;
|
|
14
|
+
};
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
2. **Define your hooks map.**
|
|
18
|
+
Each hook declares its `input` (what it receives) and `output` (what it returns). Use `output: void` for terminal hooks that don't accumulate data:
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
type MyHooks = {
|
|
22
|
+
beforeProcess: { input: { items: Record<string, MyItemConfig> }; output: { cost: number } };
|
|
23
|
+
report: { input: { items: Record<string, MyItemConfig> } & { cost: number }; output: void };
|
|
24
|
+
execute: { input: { items: Record<string, MyItemConfig> } & { cost: number }; output: string };
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
3. **Create the builder factory.**
|
|
29
|
+
Pass your hooks map and an array of hook names to `createBuilder`:
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
const { define } = createBuilder<MyItemConfig, MyHooks>()([
|
|
33
|
+
'beforeProcess',
|
|
34
|
+
'report',
|
|
35
|
+
'execute',
|
|
36
|
+
]);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
4. **Define items.**
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
const builder = define({
|
|
43
|
+
itemA: { enabled: true, priority: 1 },
|
|
44
|
+
itemB: { enabled: false, priority: 2 },
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
5. **Chain hooks to build your workflow.**
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
builder
|
|
52
|
+
.beforeProcess((ctx) => ({ cost: 42 }))
|
|
53
|
+
.report((ctx) => {
|
|
54
|
+
// ctx.cost is visible here because beforeProcess accumulated it
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## How to Add a New Hook to an Existing Builder
|
|
61
|
+
|
|
62
|
+
1. **Add a new entry to your `THooksMap`.**
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
type MyHooks = {
|
|
66
|
+
beforeProcess: { input: { items: Record<string, MyItemConfig> }; output: { cost: number } };
|
|
67
|
+
report: { input: { items: Record<string, MyItemConfig> } & { cost: number }; output: void };
|
|
68
|
+
onRetry: { input: { items: Record<string, MyItemConfig> } & { cost: number }; output: { attempt: number } }; // ← new
|
|
69
|
+
};
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
2. **Add the hook name to the array passed to `createBuilder`.**
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
const { define } = createBuilder<MyItemConfig, MyHooks>()([
|
|
76
|
+
'beforeProcess',
|
|
77
|
+
'report',
|
|
78
|
+
'onRetry', // ← added
|
|
79
|
+
]);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
3. **Chain the new hook on the builder.**
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
builder
|
|
86
|
+
.beforeProcess((ctx) => ({ cost: 10 }))
|
|
87
|
+
.onRetry((ctx) => ({ attempt: 1 }))
|
|
88
|
+
.report((ctx) => {
|
|
89
|
+
ctx.cost; // number
|
|
90
|
+
ctx.attempt; // number
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The mapped types automatically generate the new method — no changes to the library code needed.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## How to Compose Two Builders
|
|
99
|
+
|
|
100
|
+
Use `.use()` to merge two builders when building a larger system from smaller ones.
|
|
101
|
+
|
|
102
|
+
**Prerequisites:** Both builders must share the same `TConfig`.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// Builder A: CI/CD runners
|
|
106
|
+
type RunnerConfig = { capabilities: string[]; region: string };
|
|
107
|
+
type RunnerHooks = {
|
|
108
|
+
beforeJob: { input: { items: Record<string, RunnerConfig> }; output: { estimatedCost: number } };
|
|
109
|
+
afterJob: { input: { items: Record<string, RunnerConfig> } & { estimatedCost: number }; output: void };
|
|
110
|
+
};
|
|
111
|
+
const { define: defineRunners } = createBuilder<RunnerConfig, RunnerHooks>()(['beforeJob', 'afterJob']);
|
|
112
|
+
|
|
113
|
+
// Builder B: notifications (same TConfig)
|
|
114
|
+
type NotifyHooks = {
|
|
115
|
+
notify: { input: { items: Record<string, RunnerConfig> } & { estimatedCost: number }; output: void };
|
|
116
|
+
};
|
|
117
|
+
const { define: defineNotify } = createBuilder<RunnerConfig, NotifyHooks>()(['notify']);
|
|
118
|
+
|
|
119
|
+
// Compose — types merge via intersection
|
|
120
|
+
defineRunners({
|
|
121
|
+
gpu: { capabilities: ['cuda'], region: 'us-east' },
|
|
122
|
+
})
|
|
123
|
+
.beforeJob((ctx) => ({ estimatedCost: 2.5 }))
|
|
124
|
+
.use(defineNotify({
|
|
125
|
+
gpu: { capabilities: ['cuda'], region: 'us-east' }, // same item keys
|
|
126
|
+
}))
|
|
127
|
+
.notify((ctx) => {
|
|
128
|
+
ctx.estimatedCost; // number — from beforeJob, intersected from both builders
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Rule:** Both builders must have the same `TConfig` to compose. Different hook sets are fine — the `THooksMap` types are independent per builder.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## How to Use Accumulated Data Across Multiple Hooks
|
|
137
|
+
|
|
138
|
+
Hooks with non-void `output` merge their return values into `TContext`, making data available to all downstream hooks.
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
type ItemConfig = { base: number };
|
|
142
|
+
type MyHooks = {
|
|
143
|
+
step1: { input: { items: Record<string, ItemConfig> }; output: { value: number } };
|
|
144
|
+
step2: { input: { items: Record<string, ItemConfig> } & { value: number }; output: { total: number } };
|
|
145
|
+
finalize: { input: { items: Record<string, ItemConfig> } & { value: number } & { total: number }; output: void };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const { define } = createBuilder<ItemConfig, MyHooks>()(['step1', 'step2', 'finalize']);
|
|
149
|
+
|
|
150
|
+
define({
|
|
151
|
+
item: { base: 10 },
|
|
152
|
+
})
|
|
153
|
+
.step1((ctx) => ({ value: ctx.items.item.base * 2 }))
|
|
154
|
+
.step2((ctx) => ({
|
|
155
|
+
// ctx.value (from step1) is visible here
|
|
156
|
+
total: ctx.value + ctx.items.item.base,
|
|
157
|
+
}))
|
|
158
|
+
.finalize((ctx) => {
|
|
159
|
+
ctx.value; // number — from step1
|
|
160
|
+
ctx.total; // number — from step2
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Rule:** Each hook sees types from hooks declared *before* it in the chain, not after. Order matters.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## How to Debug Type Errors in a Builder
|
|
169
|
+
|
|
170
|
+
When TypeScript reports an error on a builder chain, read the type from right to left — each step narrows the types for the next.
|
|
171
|
+
|
|
172
|
+
**Common error: property not in context**
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
.beforeHook((ctx) => ({ cost: 10 }))
|
|
176
|
+
.finalize((ctx) => {
|
|
177
|
+
ctx.total; // Error: Property 'total' does not exist
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Fix:** `finalize` only sees types accumulated *before* it. Either add a hook that returns `total` before `finalize`, or access it in a different hook.
|
|
182
|
+
|
|
183
|
+
**Common error: key not in items**
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
builder.items['nonexistent']; // Error: Property 'nonexistent' does not exist
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
This is correct — the builder only knows about keys declared in `items`. Add the key to `items` or use a key from the routes.
|
|
190
|
+
|
|
191
|
+
**Common error: TConfig mismatch on `.use()`**
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
type ConfigA = { a: string };
|
|
195
|
+
type ConfigB = { b: number };
|
|
196
|
+
// Both builders must have identical TConfig to compose
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The `TConfig` types must match exactly for `.use()` to work. This is intentional — it prevents cross-domain contamination.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## How to Mark a Hook as Terminal (Non-accumulating)
|
|
204
|
+
|
|
205
|
+
Use `output: void` to indicate a hook that doesn't pass data downstream:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
type MyHooks = {
|
|
209
|
+
accumulating: { input: { items: Record<string, Config> }; output: { cost: number } };
|
|
210
|
+
terminal: { input: { items: Record<string, Config> } & { cost: number }; output: void }; // ← void means no accumulation
|
|
211
|
+
};
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Any `output` that is not `Record<string, unknown>` won't merge into `TContext`. The type guard `THooksMap[K]['output'] extends Record<string, unknown> ? THooksMap[K]['output'] : {}` handles this.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
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,85 @@
|
|
|
1
|
+
# typed-builder
|
|
2
|
+
|
|
3
|
+
A TypeScript library implementing a **builder pattern with accumulating type parameters**. Each method call narrows the types available to subsequent calls, giving you end-to-end type safety without runtime overhead.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npm install typed-builder
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## What Problem It Solves
|
|
10
|
+
|
|
11
|
+
When you chain builder methods, you often lose type information from earlier steps. `typed-builder` solves this by carrying **generic type parameters that narrow with each method call** — the types from early calls are preserved and available downstream.
|
|
12
|
+
|
|
13
|
+
For example, if a `beforeEach` hook returns `{ cost: number }`, that type is visible in every subsequent hook without you re-declaring it.
|
|
14
|
+
|
|
15
|
+
## Core Concepts
|
|
16
|
+
|
|
17
|
+
| Concept | Role |
|
|
18
|
+
|---------|------|
|
|
19
|
+
| **Accumulating hooks** | Return data that flows downstream to all later hooks |
|
|
20
|
+
| **Terminal hooks** | Consume accumulated data; produce final output |
|
|
21
|
+
| **Composition (`.use()`)** | Merge two builders — their type parameters intersect |
|
|
22
|
+
|
|
23
|
+
See [DESIGN.md](./DESIGN.md) for the full design rationale and type mechanics.
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { createBuilder } from 'typed-builder';
|
|
29
|
+
|
|
30
|
+
// 1. Define your domain's item shape
|
|
31
|
+
type MenuItemConfig = {
|
|
32
|
+
inStock: boolean;
|
|
33
|
+
price: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// 2. Create a builder factory for your domain
|
|
37
|
+
const { define } = createBuilder<MenuItemConfig>()({
|
|
38
|
+
accumulating: ['beforePrep'],
|
|
39
|
+
terminal: ['receipt'],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// 3. Define items and routes
|
|
43
|
+
define({
|
|
44
|
+
items: {
|
|
45
|
+
burger: { inStock: true, price: 12.99 },
|
|
46
|
+
salad: { inStock: false, price: 8.99 },
|
|
47
|
+
},
|
|
48
|
+
routes: {
|
|
49
|
+
// Routes receive { key, items }
|
|
50
|
+
list: (ctx) => Object.entries(ctx.items).map(([k, v]) => k),
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
// 4. Accumulating hook — return data is available downstream
|
|
54
|
+
.beforePrep((key, ctx) => ({ totalPrice: ctx.items[key].price }))
|
|
55
|
+
// 5. Terminal hook — reads accumulated data, produces final output
|
|
56
|
+
.receipt((ctx) => {
|
|
57
|
+
ctx.totalPrice; // number ✓
|
|
58
|
+
return `Total: $${ctx.totalPrice}`;
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
| Doc | Type | What it covers |
|
|
65
|
+
|-----|------|----------------|
|
|
66
|
+
| [TUTORIAL.md](./TUTORIAL.md) | Tutorial | Build a CI/CD pipeline builder from scratch |
|
|
67
|
+
| [HOWTO.md](./HOWTO.md) | How-to guides | Build a new builder, compose builders, add hooks |
|
|
68
|
+
| [API.md](./API.md) | Reference | `createBuilder`, hook signatures, type parameters |
|
|
69
|
+
| [DESIGN.md](./DESIGN.md) | Explanation | How the type machinery works, why it exists |
|
|
70
|
+
|
|
71
|
+
## Installation
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm install typed-builder
|
|
75
|
+
# or
|
|
76
|
+
yarn add typed-builder
|
|
77
|
+
# or
|
|
78
|
+
pnpm add typed-builder
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Requires TypeScript 5.0+.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
package/TUTORIAL.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Build a CI/CD Pipeline Builder from Scratch
|
|
2
|
+
|
|
3
|
+
In this tutorial, you will build a typed builder for a CI/CD pipeline domain. By the end, you will have a working builder that tracks estimated cost through job steps and produces a final execution report.
|
|
4
|
+
|
|
5
|
+
No prior experience with `typed-builder` is needed. Familiarity with TypeScript generics is helpful.
|
|
6
|
+
|
|
7
|
+
## What you're building
|
|
8
|
+
|
|
9
|
+
A pipeline builder for a CI/CD system where:
|
|
10
|
+
- Each **job step** (like `beforeJob`, `afterJob`) can accumulate cost data
|
|
11
|
+
- The `execute` terminal hook produces a final report using all accumulated data
|
|
12
|
+
|
|
13
|
+
## Step 1 — Define the item configuration type
|
|
14
|
+
|
|
15
|
+
CI/CD runners have a configuration that all jobs share:
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
type RunnerConfig = {
|
|
19
|
+
capabilities: string[];
|
|
20
|
+
maxParallel: number;
|
|
21
|
+
region: string;
|
|
22
|
+
};
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Every runner in your pipeline has this shape.
|
|
26
|
+
|
|
27
|
+
## Step 2 — Define the hooks map
|
|
28
|
+
|
|
29
|
+
Each hook declares what it **receives** (`input`) and what it **returns** (`output`). Hooks that return `void` are terminal — they don't accumulate data downstream.
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
type PipelineHooks = {
|
|
33
|
+
// Accumulating: returns cost data that flows to later hooks
|
|
34
|
+
beforeJob: {
|
|
35
|
+
input: { items: Record<string, RunnerConfig> };
|
|
36
|
+
output: { estimatedCost: number };
|
|
37
|
+
};
|
|
38
|
+
// Terminal: receives cost data, produces a final report
|
|
39
|
+
report: {
|
|
40
|
+
input: { items: Record<string, RunnerConfig> } & { estimatedCost: number };
|
|
41
|
+
output: void;
|
|
42
|
+
};
|
|
43
|
+
// Terminal: receives cost data, returns the final result string
|
|
44
|
+
execute: {
|
|
45
|
+
input: { items: Record<string, RunnerConfig> } & { estimatedCost: number };
|
|
46
|
+
output: string;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Step 3 — Create the builder factory
|
|
52
|
+
|
|
53
|
+
Pass your config type, hooks map, and an array of hook names:
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
const { define } = createBuilder<RunnerConfig, PipelineHooks>()([
|
|
57
|
+
'beforeJob',
|
|
58
|
+
'report',
|
|
59
|
+
'execute',
|
|
60
|
+
]);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Step 4 — Define your pipeline runners
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
const pipeline = define({
|
|
67
|
+
'gpu-runner': {
|
|
68
|
+
capabilities: ['cuda', 'tensor cores'],
|
|
69
|
+
maxParallel: 4,
|
|
70
|
+
region: 'us-east-1',
|
|
71
|
+
},
|
|
72
|
+
'cpu-runner': {
|
|
73
|
+
capabilities: ['x86_64'],
|
|
74
|
+
maxParallel: 8,
|
|
75
|
+
region: 'us-west-2',
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Step 5 — Chain hooks to build the pipeline
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
const result = pipeline
|
|
84
|
+
// beforeJob accumulates { estimatedCost }
|
|
85
|
+
.beforeJob((ctx) => ({
|
|
86
|
+
estimatedCost: ctx.items['gpu-runner'].maxParallel * 0.25,
|
|
87
|
+
}))
|
|
88
|
+
// execute receives ctx.estimatedCost from beforeJob
|
|
89
|
+
.execute((ctx) => {
|
|
90
|
+
return `Pipeline complete. Estimated cost: $${ctx.estimatedCost}`;
|
|
91
|
+
});
|
|
92
|
+
// result: "Pipeline complete. Estimated cost: $1.00"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## What just happened
|
|
96
|
+
|
|
97
|
+
| Phase | What changed |
|
|
98
|
+
|-------|-------------|
|
|
99
|
+
| `define({...})` | `TKeys` = `"gpu-runner" \| "cpu-runner"`, `TItems` = the item map, `TContext` = `{}` |
|
|
100
|
+
| `.beforeJob(fn)` | `TContext` narrowed to `{ estimatedCost: number }` |
|
|
101
|
+
| `.execute(fn)` | Receives `{ key: TKeys } & { items: TItems } & { estimatedCost: number }` |
|
|
102
|
+
|
|
103
|
+
TypeScript validates each step. If you try to access `.estimatedCost` inside `beforeJob`, it won't exist yet — that's the point.
|
|
104
|
+
|
|
105
|
+
## Step 6 — Add a second accumulating hook
|
|
106
|
+
|
|
107
|
+
Suppose you want to track memory usage separately. Add it to the hooks map and re-create the factory:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
type PipelineHooks = {
|
|
111
|
+
beforeJob: { input: { items: Record<string, RunnerConfig> }; output: { estimatedCost: number } };
|
|
112
|
+
beforeJobMemory: { input: { items: Record<string, RunnerConfig> } & { estimatedCost: number }; output: { memoryMB: number } };
|
|
113
|
+
report: { input: { items: Record<string, RunnerConfig> } & { estimatedCost: number } & { memoryMB: number }; output: void };
|
|
114
|
+
execute: { input: { items: Record<string, RunnerConfig> } & { estimatedCost: number } & { memoryMB: number }; output: string };
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const { define } = createBuilder<RunnerConfig, PipelineHooks>()([
|
|
118
|
+
'beforeJob',
|
|
119
|
+
'beforeJobMemory',
|
|
120
|
+
'report',
|
|
121
|
+
'execute',
|
|
122
|
+
]);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Now chain both accumulating hooks:
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
pipeline
|
|
129
|
+
.beforeJob((ctx) => ({ estimatedCost: 1.0 }))
|
|
130
|
+
.beforeJobMemory((ctx) => ({ memoryMB: 512 }))
|
|
131
|
+
.execute((ctx) => {
|
|
132
|
+
ctx.estimatedCost; // number
|
|
133
|
+
ctx.memoryMB; // number — accumulated from beforeJobMemory
|
|
134
|
+
return `Cost: $${ctx.estimatedCost}, Memory: ${ctx.memoryMB}MB`;
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Each accumulating hook adds its output to `TContext`. Later hooks see the union of all accumulated data.
|
|
139
|
+
|
|
140
|
+
## What you learned
|
|
141
|
+
|
|
142
|
+
- How to define a domain with `TConfig` and `THooksMap`
|
|
143
|
+
- How `createBuilder` generates typed methods from hook names
|
|
144
|
+
- How accumulating hooks narrow `TContext` for downstream hooks
|
|
145
|
+
- How terminal hooks with `output: void` consume but don't further accumulate
|
|
146
|
+
- How TypeScript enforces type narrowing at each step in the chain
|
|
147
|
+
|
|
148
|
+
## Next steps
|
|
149
|
+
|
|
150
|
+
- Read [API.md](./API.md) for the full hook signature reference
|
|
151
|
+
- Read [HOWTO.md](./HOWTO.md) to learn how to compose two builders together
|
|
152
|
+
- Read [DESIGN.md](./DESIGN.md) to understand the type machinery under the hood
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type THookDef = {
|
|
2
|
+
input: unknown;
|
|
3
|
+
output: unknown;
|
|
4
|
+
};
|
|
5
|
+
type TerminalResult<TConfig, TItems extends Record<string, TConfig>, TContext extends Record<string, unknown>> = {
|
|
6
|
+
config: TConfig;
|
|
7
|
+
items: TItems;
|
|
8
|
+
context: TContext;
|
|
9
|
+
};
|
|
10
|
+
type Builder<TConfig, TKeys extends string, TItems extends Record<string, TConfig>, TContext extends Record<string, unknown>, THooksMap extends Record<string, THookDef>> = {
|
|
11
|
+
use<UKeys extends string, UItems extends Record<string, TConfig>, UContext extends Record<string, unknown>, UHooksMap extends Record<string, THookDef>>(source: Builder<TConfig, UKeys, UItems, UContext, UHooksMap>): Builder<TConfig, TKeys | UKeys, TItems & UItems, TContext & UContext, THooksMap>;
|
|
12
|
+
} & {
|
|
13
|
+
[K in keyof THooksMap]: (fn: (ctx: THooksMap[K]['input'] & {
|
|
14
|
+
key: TKeys;
|
|
15
|
+
} & TItems & TContext) => void | THooksMap[K]['output']) => [THooksMap[K]['output']] extends [never] ? TerminalResult<TConfig, TItems, TContext> : THooksMap[K]['output'] extends Record<string, unknown> ? Builder<TConfig, TKeys, TItems, TContext & THooksMap[K]['output'], THooksMap> : TerminalResult<TConfig, TItems, TContext>;
|
|
16
|
+
};
|
|
17
|
+
declare function createBuilder<TConfig, THooksMap extends Record<string, THookDef>>(hookNames: Array<keyof THooksMap>): {
|
|
18
|
+
define: {
|
|
19
|
+
<TItems extends Record<string, TConfig>>(items: TItems): Builder<TConfig, keyof TItems & string, TItems, {}, THooksMap>;
|
|
20
|
+
(): Builder<TConfig, never, {}, {}, THooksMap>;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export { createBuilder, type Builder };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// ══════════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Typed Builder — domain-agnostic builder with accumulating type parameters
|
|
3
|
+
// ══════════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
function createBuilder(hookNames) {
|
|
5
|
+
function define(items) {
|
|
6
|
+
const instance = {};
|
|
7
|
+
instance.use = (source) => instance;
|
|
8
|
+
for (const hookName of hookNames)
|
|
9
|
+
instance[hookName] = (fn) => instance;
|
|
10
|
+
return instance;
|
|
11
|
+
}
|
|
12
|
+
return { define };
|
|
13
|
+
}
|
|
14
|
+
export { createBuilder };
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "standard-typed-config",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "TypeScript pattern: builder with accumulating type parameters, domain-defined hooks, Elysia-style .use() composition",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"test": "tsc --noEmit --strict --skipLibCheck test/types.test-d.ts",
|
|
11
|
+
"prepublishOnly": "npm run build"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"typescript",
|
|
15
|
+
"builder-pattern",
|
|
16
|
+
"type-level",
|
|
17
|
+
"generic",
|
|
18
|
+
"fluent-api",
|
|
19
|
+
"accumulating-types",
|
|
20
|
+
"mapped-types"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"tsd": "^0.33.0",
|
|
25
|
+
"typescript": "^5.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ══════════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// Typed Builder — domain-agnostic builder with accumulating type parameters
|
|
3
|
+
// ══════════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
type THookDef = { input: unknown; output: unknown };
|
|
6
|
+
|
|
7
|
+
type TerminalResult<TConfig, TItems extends Record<string, TConfig>, TContext extends Record<string, unknown>> = {
|
|
8
|
+
config: TConfig;
|
|
9
|
+
items: TItems;
|
|
10
|
+
context: TContext;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Builder<
|
|
14
|
+
TConfig,
|
|
15
|
+
TKeys extends string,
|
|
16
|
+
TItems extends Record<string, TConfig>,
|
|
17
|
+
TContext extends Record<string, unknown>,
|
|
18
|
+
THooksMap extends Record<string, THookDef>
|
|
19
|
+
> = {
|
|
20
|
+
use<UKeys extends string, UItems extends Record<string, TConfig>, UContext extends Record<string, unknown>, UHooksMap extends Record<string, THookDef>>(
|
|
21
|
+
source: Builder<TConfig, UKeys, UItems, UContext, UHooksMap>
|
|
22
|
+
): Builder<TConfig, TKeys | UKeys, TItems & UItems, TContext & UContext, THooksMap>;
|
|
23
|
+
} & {
|
|
24
|
+
[K in keyof THooksMap]: (
|
|
25
|
+
fn: (ctx: THooksMap[K]['input'] & { key: TKeys } & TItems & TContext) => void | THooksMap[K]['output']
|
|
26
|
+
) => [THooksMap[K]['output']] extends [never]
|
|
27
|
+
? TerminalResult<TConfig, TItems, TContext>
|
|
28
|
+
: THooksMap[K]['output'] extends Record<string, unknown>
|
|
29
|
+
? Builder<TConfig, TKeys, TItems, TContext & THooksMap[K]['output'], THooksMap>
|
|
30
|
+
: TerminalResult<TConfig, TItems, TContext>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function createBuilder<TConfig, THooksMap extends Record<string, THookDef>>(hookNames: Array<keyof THooksMap>) {
|
|
34
|
+
|
|
35
|
+
function define<TItems extends Record<string, TConfig>>(
|
|
36
|
+
items: TItems
|
|
37
|
+
): Builder<TConfig, keyof TItems & string, TItems, {}, THooksMap>;
|
|
38
|
+
function define(): Builder<TConfig, never, {}, {}, THooksMap>;
|
|
39
|
+
function define(items?: any): any {
|
|
40
|
+
const instance: any = {};
|
|
41
|
+
instance.use = (source: any) => instance;
|
|
42
|
+
for (const hookName of hookNames) instance[hookName] = (fn: any) => instance;
|
|
43
|
+
return instance;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { define };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { createBuilder, type Builder };
|