@martinffx/atelier 2.0.0 → 3.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/CHANGELOG.md +21 -0
- package/README.md +94 -253
- package/package.json +1 -1
- package/skills/{spec-orchestrator → atelier-orchestrator}/SKILL.md +3 -3
- package/skills/atelier-setup/SKILL.md +80 -0
- package/skills/code-review/references/reviewers.md +3 -3
- package/skills/code-review/references/rq.md +6 -6
- package/skills/spec-brainstorm/SKILL.md +5 -5
- package/skills/spec-implement/SKILL.md +2 -3
- package/skills/spec-plan/SKILL.md +21 -29
- package/skills/spec-plan/references/plan_template.md +77 -0
- package/skills/python-architecture/SKILL.md +0 -148
- package/skills/python-architecture/references/data-modeling.md +0 -558
- package/skills/python-architecture/references/ddd.md +0 -569
- package/skills/python-architecture/references/functional-core.md +0 -337
- package/skills/python-build-tools/SKILL.md +0 -153
- package/skills/python-build-tools/references/basedpyright.md +0 -321
- package/skills/python-build-tools/references/ruff.md +0 -225
- package/skills/python-build-tools/references/uv.md +0 -257
- package/skills/python-fastapi/SKILL.md +0 -184
- package/skills/python-fastapi/references/api-design.md +0 -563
- package/skills/python-fastapi/references/dependencies.md +0 -479
- package/skills/python-fastapi/references/middleware.md +0 -475
- package/skills/python-fastapi/references/validation.md +0 -497
- package/skills/python-modern-python/SKILL.md +0 -201
- package/skills/python-modern-python/references/async.md +0 -76
- package/skills/python-modern-python/references/pattern-matching.md +0 -89
- package/skills/python-modern-python/references/typing.md +0 -96
- package/skills/python-monorepo/SKILL.md +0 -146
- package/skills/python-monorepo/references/docker.md +0 -515
- package/skills/python-monorepo/references/namespace-packages.md +0 -426
- package/skills/python-monorepo/references/workspace-config.md +0 -341
- package/skills/python-sqlalchemy/SKILL.md +0 -119
- package/skills/python-sqlalchemy/references/async.md +0 -51
- package/skills/python-sqlalchemy/references/models.md +0 -67
- package/skills/python-sqlalchemy/references/queries.md +0 -57
- package/skills/python-temporal/SKILL.md +0 -128
- package/skills/python-temporal/references/patterns.md +0 -87
- package/skills/python-temporal/references/testing.md +0 -54
- package/skills/python-testing/SKILL.md +0 -161
- package/skills/python-testing/references/boundaries.md +0 -449
- package/skills/python-testing/references/mocking.md +0 -449
- package/skills/python-testing/references/pytest.md +0 -524
- package/skills/typescript-api-design/SKILL.md +0 -396
- package/skills/typescript-api-design/references/error-responses.md +0 -469
- package/skills/typescript-build-tools/SKILL.md +0 -447
- package/skills/typescript-build-tools/references/biome.md +0 -436
- package/skills/typescript-build-tools/references/bun.md +0 -432
- package/skills/typescript-build-tools/references/turborepo.md +0 -426
- package/skills/typescript-build-tools/references/vitest.md +0 -518
- package/skills/typescript-drizzle-orm/SKILL.md +0 -398
- package/skills/typescript-drizzle-orm/references/cloudflare.md +0 -392
- package/skills/typescript-drizzle-orm/references/entity-pattern.md +0 -412
- package/skills/typescript-drizzle-orm/references/postgresql.md +0 -191
- package/skills/typescript-drizzle-orm/references/repository-pattern.md +0 -479
- package/skills/typescript-drizzle-orm/references/sqlite.md +0 -291
- package/skills/typescript-dynamodb-toolbox/SKILL.md +0 -370
- package/skills/typescript-dynamodb-toolbox/references/entity-layer.md +0 -574
- package/skills/typescript-dynamodb-toolbox/references/error-handling.md +0 -495
- package/skills/typescript-dynamodb-toolbox/references/modeling.md +0 -332
- package/skills/typescript-dynamodb-toolbox/references/repository-layer.md +0 -631
- package/skills/typescript-dynamodb-toolbox/references/testing.md +0 -718
- package/skills/typescript-dynamodb-toolbox/references/transactions.md +0 -423
- package/skills/typescript-effect-ts/SKILL.md +0 -341
- package/skills/typescript-effect-ts/references/core.md +0 -412
- package/skills/typescript-effect-ts/references/error-handling.md +0 -468
- package/skills/typescript-effect-ts/references/resources.md +0 -476
- package/skills/typescript-effect-ts/references/schema.md +0 -536
- package/skills/typescript-effect-ts/references/services.md +0 -461
- package/skills/typescript-fastify/SKILL.md +0 -405
- package/skills/typescript-fastify/references/plugins.md +0 -329
- package/skills/typescript-fastify/references/typeid.md +0 -315
- package/skills/typescript-functional-patterns/SKILL.md +0 -402
- package/skills/typescript-functional-patterns/references/adts.md +0 -497
- package/skills/typescript-functional-patterns/references/branded-types.md +0 -542
- package/skills/typescript-functional-patterns/references/migration-guide.md +0 -695
- package/skills/typescript-functional-patterns/references/option-result.md +0 -585
- package/skills/typescript-testing/SKILL.md +0 -353
- package/skills/typescript-testing/references/mocking.md +0 -675
- package/skills/typescript-testing/references/msw.md +0 -852
- package/skills/typescript-testing/references/snapshot-testing.md +0 -774
|
@@ -1,542 +0,0 @@
|
|
|
1
|
-
# Branded Types and Smart Constructors
|
|
2
|
-
|
|
3
|
-
Branded types (also called nominal types or opaque types) prevent mixing semantically different values that share the same primitive type. They use smart constructors to enforce invariants at creation time.
|
|
4
|
-
|
|
5
|
-
## The Problem: Primitive Obsession
|
|
6
|
-
|
|
7
|
-
TypeScript uses structural typing - two types with the same structure are compatible:
|
|
8
|
-
|
|
9
|
-
```typescript
|
|
10
|
-
type UserId = string
|
|
11
|
-
type OrderId = string
|
|
12
|
-
|
|
13
|
-
function getUser(id: UserId): User { /* ... */ }
|
|
14
|
-
|
|
15
|
-
const orderId: OrderId = "ord_123"
|
|
16
|
-
getUser(orderId) // Compiles! But logically wrong
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
The compiler allows passing an `OrderId` where a `UserId` is expected because both are `string`.
|
|
20
|
-
|
|
21
|
-
**Common issues:**
|
|
22
|
-
- Mixing units (cents vs dollars, seconds vs milliseconds)
|
|
23
|
-
- Mixing IDs (UserId vs OrderId vs ProductId)
|
|
24
|
-
- Using unvalidated values (raw email string vs validated Email)
|
|
25
|
-
|
|
26
|
-
## The Brand Pattern
|
|
27
|
-
|
|
28
|
-
Add a phantom brand property to create nominal types:
|
|
29
|
-
|
|
30
|
-
```typescript
|
|
31
|
-
type Brand<K, T> = K & { __brand: T }
|
|
32
|
-
|
|
33
|
-
type UserId = Brand<string, "UserId">
|
|
34
|
-
type OrderId = Brand<string, "OrderId">
|
|
35
|
-
|
|
36
|
-
function getUser(id: UserId): User { /* ... */ }
|
|
37
|
-
|
|
38
|
-
const orderId: OrderId = "ord_123" as OrderId
|
|
39
|
-
getUser(orderId) // Type error! OrderId is not assignable to UserId
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
The `__brand` property exists only at compile time. At runtime, branded types are just their base types.
|
|
43
|
-
|
|
44
|
-
## Smart Constructors
|
|
45
|
-
|
|
46
|
-
Smart constructors enforce invariants and create branded values:
|
|
47
|
-
|
|
48
|
-
```typescript
|
|
49
|
-
type Email = Brand<string, "Email">
|
|
50
|
-
|
|
51
|
-
const Email = (s: string): Email => {
|
|
52
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)) {
|
|
53
|
-
throw new Error("Invalid email format")
|
|
54
|
-
}
|
|
55
|
-
return s as Email
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Usage
|
|
59
|
-
const email = Email("alice@example.com") // OK
|
|
60
|
-
const invalid = Email("not-an-email") // Throws error
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
**Benefits:**
|
|
64
|
-
- Validation happens once at construction
|
|
65
|
-
- Invalid values can't be created
|
|
66
|
-
- Type system proves all Email values are valid
|
|
67
|
-
|
|
68
|
-
## Common Branded Type Patterns
|
|
69
|
-
|
|
70
|
-
### Financial Units (Prevent Floating Point Errors)
|
|
71
|
-
|
|
72
|
-
```typescript
|
|
73
|
-
type Cents = Brand<number, "Cents">
|
|
74
|
-
type Dollars = Brand<number, "Dollars">
|
|
75
|
-
|
|
76
|
-
const Cents = (n: number): Cents => {
|
|
77
|
-
if (!Number.isInteger(n)) {
|
|
78
|
-
throw new Error("Cents must be integer")
|
|
79
|
-
}
|
|
80
|
-
if (n < 0) {
|
|
81
|
-
throw new Error("Cents cannot be negative")
|
|
82
|
-
}
|
|
83
|
-
return n as Cents
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const Dollars = (n: number): Dollars => {
|
|
87
|
-
if (n < 0) {
|
|
88
|
-
throw new Error("Dollars cannot be negative")
|
|
89
|
-
}
|
|
90
|
-
return n as Dollars
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Convert between units
|
|
94
|
-
const dollarsFromCents = (cents: Cents): Dollars => {
|
|
95
|
-
return Dollars(cents / 100)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const centsFromDollars = (dollars: Dollars): Cents => {
|
|
99
|
-
return Cents(Math.round(dollars * 100))
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Type-safe arithmetic
|
|
103
|
-
const addCents = (a: Cents, b: Cents): Cents => Cents(a + b)
|
|
104
|
-
const subtractCents = (a: Cents, b: Cents): Cents => Cents(a - b)
|
|
105
|
-
|
|
106
|
-
// Usage
|
|
107
|
-
const price = Cents(1000) // $10.00
|
|
108
|
-
const tax = Cents(100) // $1.00
|
|
109
|
-
const total = addCents(price, tax) // Cents(1100)
|
|
110
|
-
|
|
111
|
-
const inDollars = dollarsFromCents(total) // Dollars(11)
|
|
112
|
-
|
|
113
|
-
// This won't compile:
|
|
114
|
-
const wrong = Cents(10) + Dollars(5) // Type error!
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
### Time Units (Prevent Unit Confusion)
|
|
118
|
-
|
|
119
|
-
```typescript
|
|
120
|
-
type Millis = Brand<number, "Millis">
|
|
121
|
-
type Seconds = Brand<number, "Seconds">
|
|
122
|
-
|
|
123
|
-
const Millis = (n: number): Millis => {
|
|
124
|
-
if (n < 0) throw new Error("Millis cannot be negative")
|
|
125
|
-
return n as Millis
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const Seconds = (n: number): Seconds => {
|
|
129
|
-
if (n < 0) throw new Error("Seconds cannot be negative")
|
|
130
|
-
return n as Seconds
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const secondsFromMillis = (ms: Millis): Seconds => Seconds(ms / 1000)
|
|
134
|
-
const millisFromSeconds = (s: Seconds): Millis => Millis(s * 1000)
|
|
135
|
-
|
|
136
|
-
// Usage
|
|
137
|
-
const timeout = Millis(5000)
|
|
138
|
-
const wait = Seconds(5)
|
|
139
|
-
|
|
140
|
-
setTimeout(() => console.log("Done"), timeout) // OK
|
|
141
|
-
setTimeout(() => console.log("Done"), wait) // Type error!
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
### Validated IDs
|
|
145
|
-
|
|
146
|
-
```typescript
|
|
147
|
-
type UserId = Brand<string, "UserId">
|
|
148
|
-
type OrderId = Brand<string, "OrderId">
|
|
149
|
-
type ProductId = Brand<string, "ProductId">
|
|
150
|
-
|
|
151
|
-
const UserId = (s: string): UserId => {
|
|
152
|
-
if (!/^usr_[a-zA-Z0-9]+$/.test(s)) {
|
|
153
|
-
throw new Error("Invalid UserId format")
|
|
154
|
-
}
|
|
155
|
-
return s as UserId
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const OrderId = (s: string): OrderId => {
|
|
159
|
-
if (!/^ord_[a-zA-Z0-9]+$/.test(s)) {
|
|
160
|
-
throw new Error("Invalid OrderId format")
|
|
161
|
-
}
|
|
162
|
-
return s as OrderId
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Functions are type-safe
|
|
166
|
-
function getUser(id: UserId): User { /* ... */ }
|
|
167
|
-
function getOrder(id: OrderId): Order { /* ... */ }
|
|
168
|
-
|
|
169
|
-
const userId = UserId("usr_123")
|
|
170
|
-
const orderId = OrderId("ord_456")
|
|
171
|
-
|
|
172
|
-
getUser(userId) // OK
|
|
173
|
-
getUser(orderId) // Type error! OrderId is not UserId
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
### Email Addresses
|
|
177
|
-
|
|
178
|
-
```typescript
|
|
179
|
-
type Email = Brand<string, "Email">
|
|
180
|
-
|
|
181
|
-
const Email = (s: string): Email => {
|
|
182
|
-
const trimmed = s.trim().toLowerCase()
|
|
183
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
|
184
|
-
throw new Error("Invalid email format")
|
|
185
|
-
}
|
|
186
|
-
return trimmed as Email
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Email is always validated and normalized
|
|
190
|
-
function sendEmail(to: Email, subject: string, body: string): void {
|
|
191
|
-
// No need to validate 'to' - type guarantees it's valid
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const email = Email("Alice@Example.com") // Normalized to "alice@example.com"
|
|
195
|
-
sendEmail(email, "Hello", "World")
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### Non-Empty Strings
|
|
199
|
-
|
|
200
|
-
```typescript
|
|
201
|
-
type NonEmptyString = Brand<string, "NonEmptyString">
|
|
202
|
-
|
|
203
|
-
const NonEmptyString = (s: string): NonEmptyString => {
|
|
204
|
-
if (s.length === 0) {
|
|
205
|
-
throw new Error("String cannot be empty")
|
|
206
|
-
}
|
|
207
|
-
return s as NonEmptyString
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
type User = {
|
|
211
|
-
id: UserId
|
|
212
|
-
name: NonEmptyString // Guaranteed never empty
|
|
213
|
-
email: Email // Guaranteed valid format
|
|
214
|
-
}
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
### Positive Numbers
|
|
218
|
-
|
|
219
|
-
```typescript
|
|
220
|
-
type PositiveInt = Brand<number, "PositiveInt">
|
|
221
|
-
type PositiveFloat = Brand<number, "PositiveFloat">
|
|
222
|
-
|
|
223
|
-
const PositiveInt = (n: number): PositiveInt => {
|
|
224
|
-
if (!Number.isInteger(n) || n <= 0) {
|
|
225
|
-
throw new Error("Must be positive integer")
|
|
226
|
-
}
|
|
227
|
-
return n as PositiveInt
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const PositiveFloat = (n: number): PositiveFloat => {
|
|
231
|
-
if (n <= 0) {
|
|
232
|
-
throw new Error("Must be positive number")
|
|
233
|
-
}
|
|
234
|
-
return n as PositiveFloat
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Usage: Quantity must be positive integer
|
|
238
|
-
type CartItem = {
|
|
239
|
-
productId: ProductId
|
|
240
|
-
quantity: PositiveInt
|
|
241
|
-
price: Cents
|
|
242
|
-
}
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
### URL and URI
|
|
246
|
-
|
|
247
|
-
```typescript
|
|
248
|
-
type Url = Brand<string, "Url">
|
|
249
|
-
type Uri = Brand<string, "Uri">
|
|
250
|
-
|
|
251
|
-
const Url = (s: string): Url => {
|
|
252
|
-
try {
|
|
253
|
-
new URL(s) // Validates URL format
|
|
254
|
-
return s as Url
|
|
255
|
-
} catch {
|
|
256
|
-
throw new Error("Invalid URL")
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
const Uri = (s: string): Uri => {
|
|
261
|
-
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s)) {
|
|
262
|
-
throw new Error("Invalid URI format")
|
|
263
|
-
}
|
|
264
|
-
return s as Uri
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function fetchData(url: Url): Promise<Response> {
|
|
268
|
-
// Guaranteed 'url' is valid URL
|
|
269
|
-
return fetch(url)
|
|
270
|
-
}
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
## Result-Returning Smart Constructors
|
|
274
|
-
|
|
275
|
-
For user input validation, return `Result` instead of throwing:
|
|
276
|
-
|
|
277
|
-
```typescript
|
|
278
|
-
import { Result, Ok, Err } from "./result"
|
|
279
|
-
|
|
280
|
-
type Email = Brand<string, "Email">
|
|
281
|
-
|
|
282
|
-
const Email = {
|
|
283
|
-
// Throwing version for internal use
|
|
284
|
-
unsafe: (s: string): Email => {
|
|
285
|
-
const trimmed = s.trim().toLowerCase()
|
|
286
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
|
287
|
-
throw new Error("Invalid email format")
|
|
288
|
-
}
|
|
289
|
-
return trimmed as Email
|
|
290
|
-
},
|
|
291
|
-
|
|
292
|
-
// Result version for external input
|
|
293
|
-
parse: (s: string): Result<Email, string> => {
|
|
294
|
-
try {
|
|
295
|
-
return Ok(Email.unsafe(s))
|
|
296
|
-
} catch (e) {
|
|
297
|
-
return Err("Invalid email format")
|
|
298
|
-
}
|
|
299
|
-
},
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Usage
|
|
303
|
-
const emailResult = Email.parse(userInput)
|
|
304
|
-
switch (emailResult._tag) {
|
|
305
|
-
case "Ok":
|
|
306
|
-
saveUser({ email: emailResult.value })
|
|
307
|
-
break
|
|
308
|
-
case "Err":
|
|
309
|
-
showError(emailResult.error)
|
|
310
|
-
break
|
|
311
|
-
}
|
|
312
|
-
```
|
|
313
|
-
|
|
314
|
-
## Advanced Patterns
|
|
315
|
-
|
|
316
|
-
### Multiple Brands
|
|
317
|
-
|
|
318
|
-
Combine multiple brands for stronger guarantees:
|
|
319
|
-
|
|
320
|
-
```typescript
|
|
321
|
-
type Positive = Brand<number, "Positive">
|
|
322
|
-
type Integer = Brand<number, "Integer">
|
|
323
|
-
type PositiveInt = Positive & Integer
|
|
324
|
-
|
|
325
|
-
const PositiveInt = (n: number): PositiveInt => {
|
|
326
|
-
if (!Number.isInteger(n)) throw new Error("Must be integer")
|
|
327
|
-
if (n <= 0) throw new Error("Must be positive")
|
|
328
|
-
return n as PositiveInt
|
|
329
|
-
}
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
### Generic Branded Collections
|
|
333
|
-
|
|
334
|
-
```typescript
|
|
335
|
-
type NonEmptyArray<T> = Brand<T[], "NonEmptyArray">
|
|
336
|
-
|
|
337
|
-
const NonEmptyArray = <T>(arr: T[]): NonEmptyArray<T> => {
|
|
338
|
-
if (arr.length === 0) {
|
|
339
|
-
throw new Error("Array cannot be empty")
|
|
340
|
-
}
|
|
341
|
-
return arr as NonEmptyArray<T>
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Safe head/tail operations
|
|
345
|
-
const head = <T>(arr: NonEmptyArray<T>): T => arr[0]
|
|
346
|
-
const tail = <T>(arr: NonEmptyArray<T>): T[] => arr.slice(1)
|
|
347
|
-
|
|
348
|
-
const numbers = NonEmptyArray([1, 2, 3])
|
|
349
|
-
const first = head(numbers) // No need to check undefined
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
### Range-Constrained Numbers
|
|
353
|
-
|
|
354
|
-
```typescript
|
|
355
|
-
type Percentage = Brand<number, "Percentage">
|
|
356
|
-
|
|
357
|
-
const Percentage = (n: number): Percentage => {
|
|
358
|
-
if (n < 0 || n > 100) {
|
|
359
|
-
throw new Error("Percentage must be 0-100")
|
|
360
|
-
}
|
|
361
|
-
return n as Percentage
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
type Port = Brand<number, "Port">
|
|
365
|
-
|
|
366
|
-
const Port = (n: number): Port => {
|
|
367
|
-
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
368
|
-
throw new Error("Port must be 1-65535")
|
|
369
|
-
}
|
|
370
|
-
return n as Port
|
|
371
|
-
}
|
|
372
|
-
```
|
|
373
|
-
|
|
374
|
-
### JSON-Safe Branded Types
|
|
375
|
-
|
|
376
|
-
```typescript
|
|
377
|
-
type UserId = Brand<string, "UserId">
|
|
378
|
-
|
|
379
|
-
// Serialization
|
|
380
|
-
function userIdToJSON(id: UserId): string {
|
|
381
|
-
return id // Just a string at runtime
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Deserialization (with validation)
|
|
385
|
-
function userIdFromJSON(json: string): Result<UserId, string> {
|
|
386
|
-
if (!/^usr_[a-zA-Z0-9]+$/.test(json)) {
|
|
387
|
-
return Err("Invalid UserId format")
|
|
388
|
-
}
|
|
389
|
-
return Ok(json as UserId)
|
|
390
|
-
}
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
## Testing Branded Types
|
|
394
|
-
|
|
395
|
-
```typescript
|
|
396
|
-
describe("Email", () => {
|
|
397
|
-
it("accepts valid email", () => {
|
|
398
|
-
expect(() => Email("alice@example.com")).not.toThrow()
|
|
399
|
-
})
|
|
400
|
-
|
|
401
|
-
it("rejects invalid email", () => {
|
|
402
|
-
expect(() => Email("not-an-email")).toThrow("Invalid email format")
|
|
403
|
-
})
|
|
404
|
-
|
|
405
|
-
it("normalizes email", () => {
|
|
406
|
-
const email = Email("Alice@Example.COM")
|
|
407
|
-
expect(email).toBe("alice@example.com")
|
|
408
|
-
})
|
|
409
|
-
|
|
410
|
-
it("trims whitespace", () => {
|
|
411
|
-
const email = Email(" alice@example.com ")
|
|
412
|
-
expect(email).toBe("alice@example.com")
|
|
413
|
-
})
|
|
414
|
-
})
|
|
415
|
-
|
|
416
|
-
describe("Cents", () => {
|
|
417
|
-
it("rejects non-integers", () => {
|
|
418
|
-
expect(() => Cents(10.5)).toThrow("Cents must be integer")
|
|
419
|
-
})
|
|
420
|
-
|
|
421
|
-
it("rejects negative values", () => {
|
|
422
|
-
expect(() => Cents(-100)).toThrow("Cents cannot be negative")
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
it("allows zero", () => {
|
|
426
|
-
expect(() => Cents(0)).not.toThrow()
|
|
427
|
-
})
|
|
428
|
-
})
|
|
429
|
-
```
|
|
430
|
-
|
|
431
|
-
## Integration with Domain Models
|
|
432
|
-
|
|
433
|
-
```typescript
|
|
434
|
-
type Money = Brand<number, "Money"> // Always in cents
|
|
435
|
-
|
|
436
|
-
type User = {
|
|
437
|
-
id: UserId
|
|
438
|
-
email: Email
|
|
439
|
-
name: NonEmptyString
|
|
440
|
-
createdAt: Millis
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
type Order = {
|
|
444
|
-
id: OrderId
|
|
445
|
-
userId: UserId
|
|
446
|
-
items: NonEmptyArray<OrderItem>
|
|
447
|
-
total: Money
|
|
448
|
-
createdAt: Millis
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
type OrderItem = {
|
|
452
|
-
productId: ProductId
|
|
453
|
-
quantity: PositiveInt
|
|
454
|
-
pricePerUnit: Money
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// All invariants enforced at type level:
|
|
458
|
-
// - IDs are validated and can't be mixed
|
|
459
|
-
// - Email is valid format
|
|
460
|
-
// - Name is never empty
|
|
461
|
-
// - Orders always have at least one item
|
|
462
|
-
// - Quantities are positive integers
|
|
463
|
-
// - Money is always in cents (no float errors)
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
## Best Practices
|
|
467
|
-
|
|
468
|
-
1. **Use smart constructors for all branded types**
|
|
469
|
-
- Enforce invariants at creation time
|
|
470
|
-
- Never cast directly to branded type in application code
|
|
471
|
-
|
|
472
|
-
2. **Provide both throwing and Result versions**
|
|
473
|
-
- Throwing for internal code (programmer error if invalid)
|
|
474
|
-
- Result for external input (user error expected)
|
|
475
|
-
|
|
476
|
-
3. **Document invariants**
|
|
477
|
-
```typescript
|
|
478
|
-
/**
|
|
479
|
-
* Cents - Money amount in integer cents (prevents floating point errors)
|
|
480
|
-
* Invariants:
|
|
481
|
-
* - Must be integer (no fractional cents)
|
|
482
|
-
* - Must be non-negative
|
|
483
|
-
*/
|
|
484
|
-
type Cents = Brand<number, "Cents">
|
|
485
|
-
```
|
|
486
|
-
|
|
487
|
-
4. **Use branded types in domain models**
|
|
488
|
-
- Replace primitive types with branded types in entities
|
|
489
|
-
- Make illegal states unrepresentable
|
|
490
|
-
|
|
491
|
-
5. **Provide conversion functions between units**
|
|
492
|
-
```typescript
|
|
493
|
-
const dollarsFromCents = (cents: Cents): Dollars => Dollars(cents / 100)
|
|
494
|
-
const centsFromDollars = (dollars: Dollars): Cents => Cents(dollars * 100)
|
|
495
|
-
```
|
|
496
|
-
|
|
497
|
-
6. **Test smart constructor validation**
|
|
498
|
-
- Test valid inputs don't throw
|
|
499
|
-
- Test invalid inputs throw with correct message
|
|
500
|
-
- Test normalization (trim, lowercase, etc.)
|
|
501
|
-
|
|
502
|
-
7. **Consider runtime brand checking for untrusted data**
|
|
503
|
-
```typescript
|
|
504
|
-
function isCents(value: unknown): value is Cents {
|
|
505
|
-
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
506
|
-
}
|
|
507
|
-
```
|
|
508
|
-
|
|
509
|
-
## Common Use Cases
|
|
510
|
-
|
|
511
|
-
### Financial Systems
|
|
512
|
-
- `Cents` - Integer cents (no float errors)
|
|
513
|
-
- `Dollars` - Dollar amounts
|
|
514
|
-
- `BasisPoints` - Fee percentages (1 bps = 0.01%)
|
|
515
|
-
|
|
516
|
-
### Time/Duration
|
|
517
|
-
- `Millis` - Milliseconds since epoch
|
|
518
|
-
- `Seconds` - Seconds
|
|
519
|
-
- `IsoDateString` - ISO 8601 date string
|
|
520
|
-
|
|
521
|
-
### Identifiers
|
|
522
|
-
- `UserId`, `OrderId`, `ProductId` - Prevent ID confusion
|
|
523
|
-
- `SessionToken` - Validated token format
|
|
524
|
-
- `ApiKey` - Validated API key
|
|
525
|
-
|
|
526
|
-
### Validated Strings
|
|
527
|
-
- `Email` - Valid email format
|
|
528
|
-
- `Url` - Valid URL format
|
|
529
|
-
- `PhoneNumber` - Valid phone format
|
|
530
|
-
- `NonEmptyString` - Never empty
|
|
531
|
-
|
|
532
|
-
### Numeric Constraints
|
|
533
|
-
- `PositiveInt` - Integer > 0
|
|
534
|
-
- `NonNegativeInt` - Integer >= 0
|
|
535
|
-
- `Percentage` - 0-100
|
|
536
|
-
- `Port` - 1-65535
|
|
537
|
-
|
|
538
|
-
## Further Reading
|
|
539
|
-
|
|
540
|
-
- [ADTs](./adts.md) - Algebraic Data Types and discriminated unions
|
|
541
|
-
- [Option & Result](./option-result.md) - Type-safe error handling
|
|
542
|
-
- [Migration Guide](./migration-guide.md) - Step-by-step adoption playbook
|