@vytches/ddd-value-objects 0.26.0 → 0.28.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/README.md +76 -1333
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,1391 +1,134 @@
|
|
|
1
1
|
# @vytches/ddd-value-objects
|
|
2
2
|
|
|
3
|
-
<!-- LLM-METADATA
|
|
4
|
-
Package: @vytches/ddd-value-objects
|
|
5
|
-
Category: Foundation
|
|
6
|
-
Purpose: Common value object implementations including EntityId, Money, Email, and other domain value objects
|
|
7
|
-
Dependencies: @vytches/ddd-domain-primitives, @vytches/ddd-contracts
|
|
8
|
-
Complexity: Medium
|
|
9
|
-
DDD Patterns: Value Object, Entity Identifier, Domain Primitive
|
|
10
|
-
Integration Points: Used by all domain models and aggregates
|
|
11
|
-
-->
|
|
12
|
-
|
|
13
3
|
[](https://badge.fury.io/js/%40vytches%2Fddd-value-objects)
|
|
14
4
|
[](https://www.typescriptlang.org/)
|
|
15
5
|
[](https://opensource.org/licenses/MIT)
|
|
16
6
|
|
|
17
|
-
> **
|
|
18
|
-
|
|
19
|
-
Collection of battle-tested value objects including EntityId, Money, Email,
|
|
20
|
-
Address, and other common domain primitives. Built with validation,
|
|
21
|
-
immutability, and rich behavior patterns.
|
|
7
|
+
> **Base value object class, EntityId, and branded ID types**
|
|
22
8
|
|
|
23
|
-
|
|
9
|
+
Provides the foundation for value objects in DDD — immutability, structural
|
|
10
|
+
equality, and type-safe entity identifiers.
|
|
24
11
|
|
|
25
|
-
|
|
26
|
-
- [Key Features](#key-features)
|
|
27
|
-
- [Core Concepts](#core-concepts)
|
|
28
|
-
- [Quick Start](#quick-start)
|
|
29
|
-
- [EntityId](#entityid)
|
|
30
|
-
- [Money](#money)
|
|
31
|
-
- [Email](#email)
|
|
32
|
-
- [Address](#address)
|
|
33
|
-
- [PhoneNumber](#phonenumber)
|
|
34
|
-
- [DateRange](#daterange)
|
|
35
|
-
- [Custom Value Objects](#custom-value-objects)
|
|
36
|
-
- [Validation](#validation)
|
|
37
|
-
- [Serialization](#serialization)
|
|
38
|
-
- [Testing](#testing)
|
|
39
|
-
- [Best Practices](#best-practices)
|
|
40
|
-
- [Contributing](#contributing)
|
|
41
|
-
|
|
42
|
-
## 🚀 Installation
|
|
12
|
+
## Installation
|
|
43
13
|
|
|
44
14
|
```bash
|
|
45
|
-
# npm
|
|
46
|
-
npm install @vytches/ddd-value-objects
|
|
47
|
-
|
|
48
|
-
# yarn
|
|
49
|
-
yarn add @vytches/ddd-value-objects
|
|
50
|
-
|
|
51
|
-
# pnpm
|
|
52
15
|
pnpm add @vytches/ddd-value-objects
|
|
53
16
|
```
|
|
54
17
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
# Required for full functionality
|
|
59
|
-
npm install @vytches/ddd-domain-primitives @vytches/ddd-contracts
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
## ✨ Key Features
|
|
63
|
-
|
|
64
|
-
### Rich Value Objects
|
|
65
|
-
|
|
66
|
-
- **EntityId**: Strongly-typed entity identifiers with multiple formats
|
|
67
|
-
- **Money**: Currency-aware monetary values with arithmetic operations
|
|
68
|
-
- **Email**: Validated email addresses with domain extraction
|
|
69
|
-
- **Address**: Structured address representation with validation
|
|
70
|
-
|
|
71
|
-
### Advanced Features
|
|
72
|
-
|
|
73
|
-
- **Immutability**: All value objects are immutable by design
|
|
74
|
-
- **Validation**: Built-in validation with meaningful error messages
|
|
75
|
-
- **Serialization**: JSON serialization and deserialization support
|
|
76
|
-
- **Comparison**: Proper equality comparison and hash code generation
|
|
77
|
-
|
|
78
|
-
### Developer Experience
|
|
79
|
-
|
|
80
|
-
- **Type Safety**: Full TypeScript support with strict typing
|
|
81
|
-
- **Factory Methods**: Convenient creation methods for common patterns
|
|
82
|
-
- **Rich Behavior**: Domain-specific methods and operations
|
|
83
|
-
- **Testing Support**: Comprehensive testing utilities and builders
|
|
84
|
-
|
|
85
|
-
## 🎯 Core Concepts
|
|
86
|
-
|
|
87
|
-
### Value Object Pattern
|
|
88
|
-
|
|
89
|
-
Value objects are immutable objects that are defined by their value rather than
|
|
90
|
-
their identity:
|
|
91
|
-
|
|
92
|
-
```typescript
|
|
93
|
-
// Value objects are compared by value, not reference
|
|
94
|
-
const money1 = new Money(100, 'USD');
|
|
95
|
-
const money2 = new Money(100, 'USD');
|
|
96
|
-
|
|
97
|
-
console.log(money1.equals(money2)); // true
|
|
98
|
-
console.log(money1 === money2); // false (different instances)
|
|
99
|
-
|
|
100
|
-
// Value objects are immutable
|
|
101
|
-
const money3 = money1.add(new Money(50, 'USD'));
|
|
102
|
-
console.log(money1.amount); // 100 (unchanged)
|
|
103
|
-
console.log(money3.amount); // 150 (new instance)
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
### Entity Identification
|
|
107
|
-
|
|
108
|
-
EntityId provides strongly-typed identifiers for domain entities:
|
|
109
|
-
|
|
110
|
-
```typescript
|
|
111
|
-
// Type-safe entity identification
|
|
112
|
-
const userId = EntityId.create(); // UUID by default
|
|
113
|
-
const orderId = EntityId.fromString('order-123');
|
|
114
|
-
const productId = EntityId.fromInteger(456);
|
|
115
|
-
|
|
116
|
-
// Prevents mixing entity types
|
|
117
|
-
function processOrder(orderId: EntityId, userId: EntityId) {
|
|
118
|
-
// Type safety prevents mistakes
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
processOrder(orderId, userId); // ✅ Correct
|
|
122
|
-
processOrder(userId, orderId); // ❌ Type error
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
## 🚀 Quick Start
|
|
126
|
-
|
|
127
|
-
### 1. Using EntityId
|
|
128
|
-
|
|
129
|
-
```typescript
|
|
130
|
-
import { EntityId } from '@vytches/ddd-value-objects';
|
|
131
|
-
|
|
132
|
-
// Create new UUID-based ID
|
|
133
|
-
const id = EntityId.create();
|
|
134
|
-
console.log(id.value); // "550e8400-e29b-41d4-a716-446655440000"
|
|
135
|
-
|
|
136
|
-
// Create from string
|
|
137
|
-
const customId = EntityId.fromString('user-123');
|
|
138
|
-
console.log(customId.value); // "user-123"
|
|
139
|
-
|
|
140
|
-
// Create from integer
|
|
141
|
-
const numericId = EntityId.fromInteger(42);
|
|
142
|
-
console.log(numericId.value); // "42"
|
|
143
|
-
|
|
144
|
-
// Create from big integer
|
|
145
|
-
const bigId = EntityId.fromBigInt(BigInt('123456789012345'));
|
|
146
|
-
console.log(bigId.value); // "123456789012345"
|
|
147
|
-
|
|
148
|
-
// Type-safe comparison
|
|
149
|
-
const id1 = EntityId.create();
|
|
150
|
-
const id2 = EntityId.create();
|
|
151
|
-
console.log(id1.equals(id2)); // false
|
|
152
|
-
console.log(id1.equals(id1)); // true
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
### 2. Using Money
|
|
156
|
-
|
|
157
|
-
```typescript
|
|
158
|
-
import { Money } from '@vytches/ddd-value-objects';
|
|
159
|
-
|
|
160
|
-
// Create money values
|
|
161
|
-
const price = new Money(99.99, 'USD');
|
|
162
|
-
const tax = new Money(8.5, 'USD');
|
|
163
|
-
|
|
164
|
-
// Arithmetic operations
|
|
165
|
-
const total = price.add(tax);
|
|
166
|
-
console.log(total.amount); // 108.49
|
|
167
|
-
console.log(total.currency); // 'USD'
|
|
168
|
-
|
|
169
|
-
// Comparison operations
|
|
170
|
-
const budget = new Money(100, 'USD');
|
|
171
|
-
console.log(total.isGreaterThan(budget)); // true
|
|
172
|
-
console.log(total.isLessThan(budget)); // false
|
|
173
|
-
|
|
174
|
-
// Formatting
|
|
175
|
-
console.log(total.format()); // "$108.49"
|
|
176
|
-
console.log(total.format('en-EU')); // "108,49 $"
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
### 3. Using Email
|
|
180
|
-
|
|
181
|
-
```typescript
|
|
182
|
-
import { Email } from '@vytches/ddd-value-objects';
|
|
183
|
-
|
|
184
|
-
// Create validated email
|
|
185
|
-
const email = new Email('user@example.com');
|
|
186
|
-
console.log(email.value); // "user@example.com"
|
|
187
|
-
|
|
188
|
-
// Extract parts
|
|
189
|
-
console.log(email.localPart); // "user"
|
|
190
|
-
console.log(email.domain); // "example.com"
|
|
191
|
-
console.log(email.topLevelDomain); // "com"
|
|
192
|
-
|
|
193
|
-
// Validation
|
|
194
|
-
try {
|
|
195
|
-
const invalidEmail = new Email('invalid-email');
|
|
196
|
-
} catch (error) {
|
|
197
|
-
console.log(error.message); // "Invalid email format"
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Domain operations
|
|
201
|
-
console.log(email.isDomainEmail('example.com')); // true
|
|
202
|
-
console.log(email.isPersonalEmail()); // false (not gmail, yahoo, etc.)
|
|
203
|
-
```
|
|
204
|
-
|
|
205
|
-
### 4. Using Address
|
|
206
|
-
|
|
207
|
-
```typescript
|
|
208
|
-
import { Address } from '@vytches/ddd-value-objects';
|
|
209
|
-
|
|
210
|
-
// Create address
|
|
211
|
-
const address = new Address('123 Main St', 'Anytown', 'NY', '12345', 'USA');
|
|
212
|
-
|
|
213
|
-
// Access components
|
|
214
|
-
console.log(address.street); // "123 Main St"
|
|
215
|
-
console.log(address.city); // "Anytown"
|
|
216
|
-
console.log(address.state); // "NY"
|
|
217
|
-
console.log(address.zipCode); // "12345"
|
|
218
|
-
console.log(address.country); // "USA"
|
|
219
|
-
|
|
220
|
-
// Formatted output
|
|
221
|
-
console.log(address.getFullAddress());
|
|
222
|
-
// "123 Main St, Anytown, NY 12345, USA"
|
|
223
|
-
|
|
224
|
-
// Validation
|
|
225
|
-
try {
|
|
226
|
-
const invalidAddress = new Address('', 'City', 'State', 'ZIP', 'Country');
|
|
227
|
-
} catch (error) {
|
|
228
|
-
console.log(error.message); // "Street address is required"
|
|
229
|
-
}
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
## 🆔 EntityId
|
|
233
|
-
|
|
234
|
-
### Creation Methods
|
|
235
|
-
|
|
236
|
-
```typescript
|
|
237
|
-
import { EntityId } from '@vytches/ddd-value-objects';
|
|
238
|
-
|
|
239
|
-
// UUID-based (default)
|
|
240
|
-
const uuidId = EntityId.create();
|
|
241
|
-
console.log(uuidId.value); // "550e8400-e29b-41d4-a716-446655440000"
|
|
242
|
-
|
|
243
|
-
// String-based
|
|
244
|
-
const stringId = EntityId.fromString('user-123');
|
|
245
|
-
console.log(stringId.value); // "user-123"
|
|
246
|
-
|
|
247
|
-
// Integer-based
|
|
248
|
-
const intId = EntityId.fromInteger(42);
|
|
249
|
-
console.log(intId.value); // "42"
|
|
250
|
-
|
|
251
|
-
// Big integer-based
|
|
252
|
-
const bigIntId = EntityId.fromBigInt(BigInt('123456789012345'));
|
|
253
|
-
console.log(bigIntId.value); // "123456789012345"
|
|
254
|
-
|
|
255
|
-
// From existing value (type-safe)
|
|
256
|
-
const copyId = EntityId.fromValue(uuidId.value);
|
|
257
|
-
console.log(copyId.equals(uuidId)); // true
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
### Validation and Operations
|
|
261
|
-
|
|
262
|
-
```typescript
|
|
263
|
-
// Validation
|
|
264
|
-
console.log(EntityId.isValid('550e8400-e29b-41d4-a716-446655440000')); // true
|
|
265
|
-
console.log(EntityId.isValid('')); // false
|
|
266
|
-
console.log(EntityId.isValid(null)); // false
|
|
267
|
-
|
|
268
|
-
// Comparison
|
|
269
|
-
const id1 = EntityId.create();
|
|
270
|
-
const id2 = EntityId.create();
|
|
271
|
-
const id3 = EntityId.fromValue(id1.value);
|
|
272
|
-
|
|
273
|
-
console.log(id1.equals(id2)); // false
|
|
274
|
-
console.log(id1.equals(id3)); // true
|
|
275
|
-
|
|
276
|
-
// Serialization
|
|
277
|
-
const serialized = id1.toJSON();
|
|
278
|
-
console.log(serialized); // { value: "550e8400-e29b-41d4-a716-446655440000" }
|
|
279
|
-
|
|
280
|
-
const deserialized = EntityId.fromJSON(serialized);
|
|
281
|
-
console.log(deserialized.equals(id1)); // true
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
### Type Safety
|
|
285
|
-
|
|
286
|
-
```typescript
|
|
287
|
-
// EntityId provides type safety for entity identification
|
|
288
|
-
class User {
|
|
289
|
-
constructor(public readonly id: EntityId) {}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
class Order {
|
|
293
|
-
constructor(
|
|
294
|
-
public readonly id: EntityId,
|
|
295
|
-
public readonly userId: EntityId
|
|
296
|
-
) {}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Usage
|
|
300
|
-
const userId = EntityId.create();
|
|
301
|
-
const orderId = EntityId.create();
|
|
302
|
-
|
|
303
|
-
const user = new User(userId);
|
|
304
|
-
const order = new Order(orderId, userId);
|
|
305
|
-
|
|
306
|
-
// Type safety prevents mistakes
|
|
307
|
-
function findUser(id: EntityId): User | null {
|
|
308
|
-
// Implementation
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
findUser(userId); // ✅ Correct
|
|
313
|
-
findUser(orderId); // ✅ Also correct (both are EntityId)
|
|
314
|
-
findUser('string-id'); // ❌ Type error
|
|
315
|
-
```
|
|
316
|
-
|
|
317
|
-
## 💰 Money
|
|
318
|
-
|
|
319
|
-
### Creation and Basic Operations
|
|
320
|
-
|
|
321
|
-
```typescript
|
|
322
|
-
import { Money } from '@vytches/ddd-value-objects';
|
|
323
|
-
|
|
324
|
-
// Create money values
|
|
325
|
-
const price = new Money(99.99, 'USD');
|
|
326
|
-
const discount = new Money(10.0, 'USD');
|
|
327
|
-
|
|
328
|
-
// Arithmetic operations
|
|
329
|
-
const discountedPrice = price.subtract(discount);
|
|
330
|
-
console.log(discountedPrice.amount); // 89.99
|
|
331
|
-
|
|
332
|
-
const doublePrice = price.multiply(2);
|
|
333
|
-
console.log(doublePrice.amount); // 199.98
|
|
334
|
-
|
|
335
|
-
const halfPrice = price.divide(2);
|
|
336
|
-
console.log(halfPrice.amount); // 49.995
|
|
337
|
-
|
|
338
|
-
// Rounding
|
|
339
|
-
const roundedHalf = price.divide(2).round();
|
|
340
|
-
console.log(roundedHalf.amount); // 50.00
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
### Currency Operations
|
|
18
|
+
## What's included
|
|
344
19
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
20
|
+
| Export | Kind | Description |
|
|
21
|
+
| ------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
22
|
+
| `BaseValueObject<T>` | class | Abstract base for value objects; deep-freezes internal value; implements `equals()` via structural comparison |
|
|
23
|
+
| `ValueObjectValidator<T>` | interface | `validate(value: T): boolean` — implement to add invariant checking |
|
|
24
|
+
| `EntityId<T>` | class | Enhanced entity identifier extending the contracts `EntityId`; adds UUID/integer/text validation, `create()`, `fromString()`, `fromNumber()` |
|
|
25
|
+
| `EntityIdFactory` | class | **Deprecated** — will be removed in v1.0.0; use `EntityId.create()` directly |
|
|
26
|
+
| `BrandedId<Tag>` | type | Compile-time branded `EntityId<string>` — prevents mixing IDs across aggregate types |
|
|
27
|
+
| `createBrandedId<Tag>(id)` | function | Casts an `EntityId` to `BrandedId<Tag>` |
|
|
28
|
+
| `newBrandedId<Tag>()` | function | Creates a new UUID-based `BrandedId<Tag>` |
|
|
29
|
+
| `brandedIdFromUUID<Tag>(uuid)` | function | Creates a `BrandedId<Tag>` from an existing UUID string |
|
|
30
|
+
| `brandedIdFromText<Tag>(text)` | function | Creates a `BrandedId<Tag>` from a text string |
|
|
31
|
+
| `IEntityId<T>` | interface | Re-exported from `@vytches/ddd-contracts` |
|
|
32
|
+
| `IEntityIdFactory` | interface | Re-exported from `@vytches/ddd-contracts` |
|
|
33
|
+
| `IdType` | type | Re-exported from `@vytches/ddd-contracts` — `'uuid' \| 'integer' \| 'text'` |
|
|
352
34
|
|
|
353
|
-
|
|
354
|
-
const usd = new Money(100, 'USD');
|
|
355
|
-
const eur = new Money(85, 'EUR');
|
|
35
|
+
## Note on bundled domain examples
|
|
356
36
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
37
|
+
This package does **not** export `Email`, `Money`, `Address`, `PhoneNumber`,
|
|
38
|
+
`DateRange`, or other concrete domain value objects. Those are application-level
|
|
39
|
+
types — implement them in your own domain layer using `BaseValueObject` as the
|
|
40
|
+
base class.
|
|
362
41
|
|
|
363
|
-
|
|
364
|
-
const conversionRate = 0.85;
|
|
365
|
-
const convertedToEur = usd.convertTo('EUR', conversionRate);
|
|
366
|
-
console.log(convertedToEur.amount); // 85.00
|
|
367
|
-
console.log(convertedToEur.currency); // "EUR"
|
|
368
|
-
```
|
|
42
|
+
## Quick start
|
|
369
43
|
|
|
370
|
-
###
|
|
44
|
+
### Custom value object
|
|
371
45
|
|
|
372
46
|
```typescript
|
|
373
|
-
|
|
374
|
-
const money2 = new Money(200, 'USD');
|
|
375
|
-
|
|
376
|
-
// Comparison
|
|
377
|
-
console.log(money1.isLessThan(money2)); // true
|
|
378
|
-
console.log(money1.isGreaterThan(money2)); // false
|
|
379
|
-
console.log(money1.equals(money2)); // false
|
|
380
|
-
|
|
381
|
-
// Validation
|
|
382
|
-
console.log(money1.isPositive()); // true
|
|
383
|
-
console.log(money1.isNegative()); // false
|
|
384
|
-
console.log(money1.isZero()); // false
|
|
385
|
-
|
|
386
|
-
// Zero and negative values
|
|
387
|
-
const zero = Money.zero('USD');
|
|
388
|
-
console.log(zero.isZero()); // true
|
|
389
|
-
|
|
390
|
-
const negative = new Money(-50, 'USD');
|
|
391
|
-
console.log(negative.isNegative()); // true
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
### Formatting
|
|
395
|
-
|
|
396
|
-
```typescript
|
|
397
|
-
const money = new Money(1234.56, 'USD');
|
|
398
|
-
|
|
399
|
-
// Default formatting
|
|
400
|
-
console.log(money.format()); // "$1,234.56"
|
|
401
|
-
|
|
402
|
-
// Locale-specific formatting
|
|
403
|
-
console.log(money.format('en-US')); // "$1,234.56"
|
|
404
|
-
console.log(money.format('en-GB')); // "US$1,234.56"
|
|
405
|
-
console.log(money.format('de-DE')); // "1.234,56 $"
|
|
406
|
-
|
|
407
|
-
// Custom formatting
|
|
408
|
-
console.log(
|
|
409
|
-
money.format('en-US', {
|
|
410
|
-
minimumFractionDigits: 3,
|
|
411
|
-
maximumFractionDigits: 3,
|
|
412
|
-
})
|
|
413
|
-
); // "$1,234.560"
|
|
414
|
-
```
|
|
415
|
-
|
|
416
|
-
## 📧 Email
|
|
417
|
-
|
|
418
|
-
### Creation and Validation
|
|
419
|
-
|
|
420
|
-
```typescript
|
|
421
|
-
import { Email } from '@vytches/ddd-value-objects';
|
|
422
|
-
|
|
423
|
-
// Valid email creation
|
|
424
|
-
const email = new Email('user@example.com');
|
|
425
|
-
console.log(email.value); // "user@example.com"
|
|
426
|
-
|
|
427
|
-
// Case normalization
|
|
428
|
-
const upperEmail = new Email('USER@EXAMPLE.COM');
|
|
429
|
-
console.log(upperEmail.value); // "user@example.com"
|
|
430
|
-
|
|
431
|
-
// Validation errors
|
|
432
|
-
try {
|
|
433
|
-
new Email('invalid-email');
|
|
434
|
-
} catch (error) {
|
|
435
|
-
console.log(error.message); // "Invalid email format"
|
|
436
|
-
}
|
|
47
|
+
import { BaseValueObject } from '@vytches/ddd-value-objects';
|
|
437
48
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
console.log(error.message); // "Email cannot be empty"
|
|
49
|
+
interface MoneyProps {
|
|
50
|
+
amount: number;
|
|
51
|
+
currency: string;
|
|
442
52
|
}
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
### Email Analysis
|
|
446
|
-
|
|
447
|
-
```typescript
|
|
448
|
-
const email = new Email('john.doe+newsletter@company.co.uk');
|
|
449
|
-
|
|
450
|
-
// Component extraction
|
|
451
|
-
console.log(email.localPart); // "john.doe+newsletter"
|
|
452
|
-
console.log(email.domain); // "company.co.uk"
|
|
453
|
-
console.log(email.topLevelDomain); // "uk"
|
|
454
|
-
|
|
455
|
-
// Advanced analysis
|
|
456
|
-
console.log(email.hasPlus()); // true
|
|
457
|
-
console.log(email.getPlusTag()); // "newsletter"
|
|
458
|
-
console.log(email.getBaseEmail()); // "john.doe@company.co.uk"
|
|
459
|
-
|
|
460
|
-
// Domain analysis
|
|
461
|
-
console.log(email.isDomainEmail('company.co.uk')); // true
|
|
462
|
-
console.log(email.isPersonalEmail()); // false
|
|
463
|
-
console.log(email.isBusinessEmail()); // true
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
### Common Email Patterns
|
|
467
|
-
|
|
468
|
-
```typescript
|
|
469
|
-
// Personal email detection
|
|
470
|
-
const personalEmails = [
|
|
471
|
-
'user@gmail.com',
|
|
472
|
-
'user@yahoo.com',
|
|
473
|
-
'user@hotmail.com',
|
|
474
|
-
'user@outlook.com',
|
|
475
|
-
];
|
|
476
|
-
|
|
477
|
-
personalEmails.forEach(emailStr => {
|
|
478
|
-
const email = new Email(emailStr);
|
|
479
|
-
console.log(`${emailStr}: ${email.isPersonalEmail()}`); // true for all
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
// Business email detection
|
|
483
|
-
const businessEmail = new Email('employee@company.com');
|
|
484
|
-
console.log(businessEmail.isBusinessEmail()); // true
|
|
485
53
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
```
|
|
490
|
-
|
|
491
|
-
## 🏠 Address
|
|
492
|
-
|
|
493
|
-
### Creation and Components
|
|
494
|
-
|
|
495
|
-
```typescript
|
|
496
|
-
import { Address } from '@vytches/ddd-value-objects';
|
|
497
|
-
|
|
498
|
-
// Complete address
|
|
499
|
-
const address = new Address(
|
|
500
|
-
'123 Main Street, Apt 4B',
|
|
501
|
-
'New York',
|
|
502
|
-
'NY',
|
|
503
|
-
'10001',
|
|
504
|
-
'USA'
|
|
505
|
-
);
|
|
506
|
-
|
|
507
|
-
// Access components
|
|
508
|
-
console.log(address.street); // "123 Main Street, Apt 4B"
|
|
509
|
-
console.log(address.city); // "New York"
|
|
510
|
-
console.log(address.state); // "NY"
|
|
511
|
-
console.log(address.zipCode); // "10001"
|
|
512
|
-
console.log(address.country); // "USA"
|
|
513
|
-
|
|
514
|
-
// Extended address (with optional fields)
|
|
515
|
-
const extendedAddress = new Address(
|
|
516
|
-
'456 Oak Avenue',
|
|
517
|
-
'Los Angeles',
|
|
518
|
-
'CA',
|
|
519
|
-
'90210',
|
|
520
|
-
'USA',
|
|
521
|
-
'Suite 100' // optional apartment/unit
|
|
522
|
-
);
|
|
523
|
-
|
|
524
|
-
console.log(extendedAddress.apartment); // "Suite 100"
|
|
525
|
-
```
|
|
526
|
-
|
|
527
|
-
### Address Formatting
|
|
528
|
-
|
|
529
|
-
```typescript
|
|
530
|
-
const address = new Address('123 Main St', 'Anytown', 'NY', '12345', 'USA');
|
|
531
|
-
|
|
532
|
-
// Full address formatting
|
|
533
|
-
console.log(address.getFullAddress());
|
|
534
|
-
// "123 Main St, Anytown, NY 12345, USA"
|
|
535
|
-
|
|
536
|
-
// Mailing format
|
|
537
|
-
console.log(address.getMailingFormat());
|
|
538
|
-
// "123 Main St\nAnytown, NY 12345\nUSA"
|
|
539
|
-
|
|
540
|
-
// Short format (without country)
|
|
541
|
-
console.log(address.getShortFormat());
|
|
542
|
-
// "123 Main St, Anytown, NY 12345"
|
|
543
|
-
```
|
|
544
|
-
|
|
545
|
-
### Address Validation
|
|
546
|
-
|
|
547
|
-
```typescript
|
|
548
|
-
// Required field validation
|
|
549
|
-
try {
|
|
550
|
-
new Address('', 'City', 'State', 'ZIP', 'Country');
|
|
551
|
-
} catch (error) {
|
|
552
|
-
console.log(error.message); // "Street address is required"
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// ZIP code validation
|
|
556
|
-
try {
|
|
557
|
-
new Address('123 Main St', 'City', 'NY', '', 'USA');
|
|
558
|
-
} catch (error) {
|
|
559
|
-
console.log(error.message); // "ZIP code is required"
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// Country-specific validation
|
|
563
|
-
const usAddress = new Address('123 Main St', 'City', 'NY', '12345', 'USA');
|
|
564
|
-
console.log(usAddress.isUSAddress()); // true
|
|
565
|
-
console.log(usAddress.isValidUSZipCode()); // true
|
|
566
|
-
|
|
567
|
-
const ukAddress = new Address('123 High St', 'London', 'ENG', 'SW1A 1AA', 'UK');
|
|
568
|
-
console.log(ukAddress.isUKAddress()); // true
|
|
569
|
-
console.log(ukAddress.isValidUKPostcode()); // true
|
|
570
|
-
```
|
|
571
|
-
|
|
572
|
-
## 📞 PhoneNumber
|
|
573
|
-
|
|
574
|
-
### Creation and Validation
|
|
575
|
-
|
|
576
|
-
```typescript
|
|
577
|
-
import { PhoneNumber } from '@vytches/ddd-value-objects';
|
|
578
|
-
|
|
579
|
-
// US phone number
|
|
580
|
-
const phoneNumber = new PhoneNumber('+1 (555) 123-4567');
|
|
581
|
-
console.log(phoneNumber.value); // "+15551234567"
|
|
582
|
-
|
|
583
|
-
// International number
|
|
584
|
-
const intlNumber = new PhoneNumber('+44 20 7946 0958');
|
|
585
|
-
console.log(intlNumber.value); // "+442079460958"
|
|
586
|
-
|
|
587
|
-
// Validation
|
|
588
|
-
try {
|
|
589
|
-
new PhoneNumber('invalid');
|
|
590
|
-
} catch (error) {
|
|
591
|
-
console.log(error.message); // "Invalid phone number format"
|
|
592
|
-
}
|
|
593
|
-
```
|
|
594
|
-
|
|
595
|
-
### Number Analysis
|
|
596
|
-
|
|
597
|
-
```typescript
|
|
598
|
-
const phoneNumber = new PhoneNumber('+1 (555) 123-4567');
|
|
599
|
-
|
|
600
|
-
// Component extraction
|
|
601
|
-
console.log(phoneNumber.getCountryCode()); // "1"
|
|
602
|
-
console.log(phoneNumber.getAreaCode()); // "555"
|
|
603
|
-
console.log(phoneNumber.getNumber()); // "1234567"
|
|
604
|
-
|
|
605
|
-
// Type detection
|
|
606
|
-
console.log(phoneNumber.isUSNumber()); // true
|
|
607
|
-
console.log(phoneNumber.isMobileNumber()); // false (555 is not mobile)
|
|
608
|
-
console.log(phoneNumber.isTollFree()); // false
|
|
609
|
-
|
|
610
|
-
// Formatting
|
|
611
|
-
console.log(phoneNumber.format()); // "+1 (555) 123-4567"
|
|
612
|
-
console.log(phoneNumber.formatNational()); // "(555) 123-4567"
|
|
613
|
-
console.log(phoneNumber.formatInternational()); // "+1 555 123 4567"
|
|
614
|
-
```
|
|
615
|
-
|
|
616
|
-
### Mobile and Special Numbers
|
|
617
|
-
|
|
618
|
-
```typescript
|
|
619
|
-
// Mobile number
|
|
620
|
-
const mobileNumber = new PhoneNumber('+1 (555) 000-1234');
|
|
621
|
-
console.log(mobileNumber.isMobileNumber()); // true
|
|
622
|
-
|
|
623
|
-
// Toll-free number
|
|
624
|
-
const tollFreeNumber = new PhoneNumber('+1 (800) 123-4567');
|
|
625
|
-
console.log(tollFreeNumber.isTollFree()); // true
|
|
626
|
-
|
|
627
|
-
// International mobile
|
|
628
|
-
const intlMobile = new PhoneNumber('+44 7700 900123');
|
|
629
|
-
console.log(intlMobile.isMobileNumber()); // true
|
|
630
|
-
console.log(intlMobile.getCountryCode()); // "44"
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
## 📅 DateRange
|
|
634
|
-
|
|
635
|
-
### Creation and Validation
|
|
636
|
-
|
|
637
|
-
```typescript
|
|
638
|
-
import { DateRange } from '@vytches/ddd-value-objects';
|
|
639
|
-
|
|
640
|
-
// Create date range
|
|
641
|
-
const startDate = new Date('2023-01-01');
|
|
642
|
-
const endDate = new Date('2023-12-31');
|
|
643
|
-
const yearRange = new DateRange(startDate, endDate);
|
|
644
|
-
|
|
645
|
-
console.log(yearRange.startDate); // 2023-01-01T00:00:00.000Z
|
|
646
|
-
console.log(yearRange.endDate); // 2023-12-31T00:00:00.000Z
|
|
647
|
-
|
|
648
|
-
// Validation
|
|
649
|
-
try {
|
|
650
|
-
new DateRange(endDate, startDate); // End before start
|
|
651
|
-
} catch (error) {
|
|
652
|
-
console.log(error.message); // "End date must be after start date"
|
|
653
|
-
}
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
### Range Operations
|
|
657
|
-
|
|
658
|
-
```typescript
|
|
659
|
-
const range1 = new DateRange(new Date('2023-01-01'), new Date('2023-06-30'));
|
|
660
|
-
|
|
661
|
-
const range2 = new DateRange(new Date('2023-04-01'), new Date('2023-09-30'));
|
|
662
|
-
|
|
663
|
-
// Overlap detection
|
|
664
|
-
console.log(range1.overlaps(range2)); // true
|
|
665
|
-
|
|
666
|
-
// Contains date
|
|
667
|
-
const testDate = new Date('2023-03-15');
|
|
668
|
-
console.log(range1.contains(testDate)); // true
|
|
669
|
-
|
|
670
|
-
// Duration calculation
|
|
671
|
-
console.log(range1.getDurationInDays()); // 180
|
|
672
|
-
console.log(range1.getDurationInMonths()); // 6
|
|
673
|
-
|
|
674
|
-
// Range intersection
|
|
675
|
-
const intersection = range1.getIntersection(range2);
|
|
676
|
-
console.log(intersection.startDate); // 2023-04-01
|
|
677
|
-
console.log(intersection.endDate); // 2023-06-30
|
|
678
|
-
```
|
|
679
|
-
|
|
680
|
-
### Date Range Utilities
|
|
681
|
-
|
|
682
|
-
```typescript
|
|
683
|
-
// Business days calculation
|
|
684
|
-
const businessDays = range1.getBusinessDays();
|
|
685
|
-
console.log(businessDays); // Excludes weekends
|
|
686
|
-
|
|
687
|
-
// Split into periods
|
|
688
|
-
const monthlyPeriods = range1.splitIntoMonths();
|
|
689
|
-
console.log(monthlyPeriods.length); // 6
|
|
690
|
-
|
|
691
|
-
// Extend range
|
|
692
|
-
const extendedRange = range1.extend(30); // Extend by 30 days
|
|
693
|
-
console.log(extendedRange.endDate); // 2023-07-30
|
|
694
|
-
|
|
695
|
-
// Common ranges
|
|
696
|
-
const thisMonth = DateRange.thisMonth();
|
|
697
|
-
const thisYear = DateRange.thisYear();
|
|
698
|
-
const lastWeek = DateRange.lastWeek();
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
## 🏗️ Custom Value Objects
|
|
702
|
-
|
|
703
|
-
### Creating Custom Value Objects
|
|
704
|
-
|
|
705
|
-
```typescript
|
|
706
|
-
import { ValueObject } from '@vytches/ddd-domain-primitives';
|
|
707
|
-
|
|
708
|
-
// Custom value object for Social Security Number
|
|
709
|
-
class SocialSecurityNumber extends ValueObject {
|
|
710
|
-
private readonly value: string;
|
|
711
|
-
|
|
712
|
-
constructor(ssn: string) {
|
|
713
|
-
super();
|
|
714
|
-
this.value = this.normalize(ssn);
|
|
54
|
+
class Money extends BaseValueObject<MoneyProps> {
|
|
55
|
+
constructor(amount: number, currency: string) {
|
|
56
|
+
super({ amount, currency });
|
|
715
57
|
}
|
|
716
58
|
|
|
717
|
-
|
|
718
|
-
return
|
|
59
|
+
validate(value: MoneyProps): boolean {
|
|
60
|
+
return value.amount >= 0 && value.currency.length === 3;
|
|
719
61
|
}
|
|
720
62
|
|
|
721
|
-
|
|
722
|
-
return
|
|
63
|
+
get amount(): number {
|
|
64
|
+
return this.value.amount;
|
|
723
65
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
return this.value.slice(0, 3);
|
|
66
|
+
get currency(): string {
|
|
67
|
+
return this.value.currency;
|
|
727
68
|
}
|
|
728
69
|
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
public getSerialNumber(): string {
|
|
734
|
-
return this.value.slice(5);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
protected getEqualityComponents(): any[] {
|
|
738
|
-
return [this.value];
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
private normalize(ssn: string): string {
|
|
742
|
-
// Remove all non-digit characters
|
|
743
|
-
const digits = ssn.replace(/\D/g, '');
|
|
744
|
-
|
|
745
|
-
if (digits.length !== 9) {
|
|
746
|
-
throw new ValidationError('SSN must be 9 digits', 'ssn', ssn);
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
if (digits === '000000000' || digits === '123456789') {
|
|
750
|
-
throw new ValidationError('Invalid SSN pattern', 'ssn', ssn);
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
return digits;
|
|
70
|
+
add(other: Money): Money {
|
|
71
|
+
if (this.currency !== other.currency) throw new Error('Currency mismatch');
|
|
72
|
+
return new Money(this.amount + other.amount, this.currency);
|
|
754
73
|
}
|
|
755
74
|
}
|
|
756
75
|
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
console.log(
|
|
76
|
+
const price = new Money(10, 'USD');
|
|
77
|
+
const tax = new Money(1.5, 'USD');
|
|
78
|
+
const total = price.add(tax);
|
|
79
|
+
|
|
80
|
+
console.log(total.equals(new Money(11.5, 'USD'))); // true
|
|
762
81
|
```
|
|
763
82
|
|
|
764
|
-
###
|
|
83
|
+
### EntityId
|
|
765
84
|
|
|
766
85
|
```typescript
|
|
767
|
-
|
|
768
|
-
class Coordinates extends ValueObject {
|
|
769
|
-
constructor(
|
|
770
|
-
private readonly latitude: number,
|
|
771
|
-
private readonly longitude: number
|
|
772
|
-
) {
|
|
773
|
-
super();
|
|
774
|
-
this.validate();
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
public get Latitude(): number {
|
|
778
|
-
return this.latitude;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
public get Longitude(): number {
|
|
782
|
-
return this.longitude;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
public distanceTo(other: Coordinates): number {
|
|
786
|
-
// Haversine formula for distance calculation
|
|
787
|
-
const R = 6371; // Earth's radius in kilometers
|
|
788
|
-
const dLat = this.toRadians(other.latitude - this.latitude);
|
|
789
|
-
const dLon = this.toRadians(other.longitude - this.longitude);
|
|
790
|
-
|
|
791
|
-
const a =
|
|
792
|
-
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
793
|
-
Math.cos(this.toRadians(this.latitude)) *
|
|
794
|
-
Math.cos(this.toRadians(other.latitude)) *
|
|
795
|
-
Math.sin(dLon / 2) *
|
|
796
|
-
Math.sin(dLon / 2);
|
|
797
|
-
|
|
798
|
-
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
799
|
-
return R * c;
|
|
800
|
-
}
|
|
86
|
+
import { EntityId } from '@vytches/ddd-value-objects';
|
|
801
87
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
}
|
|
88
|
+
// Generate a new UUID-based ID
|
|
89
|
+
const id = EntityId.create(); // EntityId<string> with UUID
|
|
805
90
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
}
|
|
91
|
+
// From an existing UUID string
|
|
92
|
+
const fromString = EntityId.fromString('550e8400-e29b-41d4-a716-446655440000');
|
|
809
93
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
throw new ValidationError(
|
|
813
|
-
'Latitude must be between -90 and 90',
|
|
814
|
-
'latitude',
|
|
815
|
-
this.latitude
|
|
816
|
-
);
|
|
817
|
-
}
|
|
94
|
+
// From an integer
|
|
95
|
+
const fromNumber = EntityId.fromNumber(42);
|
|
818
96
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
'Longitude must be between -180 and 180',
|
|
822
|
-
'longitude',
|
|
823
|
-
this.longitude
|
|
824
|
-
);
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
private toRadians(degrees: number): number {
|
|
829
|
-
return degrees * (Math.PI / 180);
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// Usage
|
|
834
|
-
const newYork = new Coordinates(40.7128, -74.006);
|
|
835
|
-
const london = new Coordinates(51.5074, -0.1278);
|
|
836
|
-
|
|
837
|
-
console.log(newYork.format()); // "40.712800, -74.006000"
|
|
838
|
-
console.log(newYork.distanceTo(london)); // 5585.27 km
|
|
97
|
+
console.log(id.getValue()); // '550e8400-...'
|
|
98
|
+
console.log(id.equals(EntityId.fromString(id.getValue()))); // true
|
|
839
99
|
```
|
|
840
100
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
### Built-in Validators
|
|
101
|
+
### Branded IDs
|
|
844
102
|
|
|
845
103
|
```typescript
|
|
846
104
|
import {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
ZipCodeValidator,
|
|
105
|
+
BrandedId,
|
|
106
|
+
newBrandedId,
|
|
107
|
+
brandedIdFromUUID,
|
|
851
108
|
} from '@vytches/ddd-value-objects';
|
|
852
109
|
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
console.log(emailValidator.isValid('user@example.com')); // true
|
|
856
|
-
console.log(emailValidator.isValid('invalid')); // false
|
|
857
|
-
|
|
858
|
-
// Phone number validation
|
|
859
|
-
const phoneValidator = new PhoneNumberValidator();
|
|
860
|
-
console.log(phoneValidator.isValid('+1 (555) 123-4567')); // true
|
|
861
|
-
console.log(phoneValidator.isValid('invalid')); // false
|
|
862
|
-
|
|
863
|
-
// Currency validation
|
|
864
|
-
const currencyValidator = new CurrencyValidator();
|
|
865
|
-
console.log(currencyValidator.isValid('USD')); // true
|
|
866
|
-
console.log(currencyValidator.isValid('INVALID')); // false
|
|
867
|
-
|
|
868
|
-
// ZIP code validation
|
|
869
|
-
const zipValidator = new ZipCodeValidator();
|
|
870
|
-
console.log(zipValidator.isValidUSZip('12345')); // true
|
|
871
|
-
console.log(zipValidator.isValidUSZip('123')); // false
|
|
872
|
-
```
|
|
110
|
+
type OrderId = BrandedId<'Order'>;
|
|
111
|
+
type CustomerId = BrandedId<'Customer'>;
|
|
873
112
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
```typescript
|
|
877
|
-
// Custom validator for credit card numbers
|
|
878
|
-
class CreditCardValidator {
|
|
879
|
-
public static isValid(cardNumber: string): boolean {
|
|
880
|
-
// Remove spaces and hyphens
|
|
881
|
-
const cleaned = cardNumber.replace(/[\s-]/g, '');
|
|
882
|
-
|
|
883
|
-
// Check if all digits
|
|
884
|
-
if (!/^\d+$/.test(cleaned)) {
|
|
885
|
-
return false;
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// Check length
|
|
889
|
-
if (cleaned.length < 13 || cleaned.length > 19) {
|
|
890
|
-
return false;
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// Luhn algorithm
|
|
894
|
-
return this.luhnCheck(cleaned);
|
|
895
|
-
}
|
|
113
|
+
const orderId: OrderId = newBrandedId<'Order'>();
|
|
114
|
+
const customerId: CustomerId = newBrandedId<'Customer'>();
|
|
896
115
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
let alternate = false;
|
|
900
|
-
|
|
901
|
-
for (let i = cardNumber.length - 1; i >= 0; i--) {
|
|
902
|
-
let digit = parseInt(cardNumber.charAt(i));
|
|
903
|
-
|
|
904
|
-
if (alternate) {
|
|
905
|
-
digit *= 2;
|
|
906
|
-
if (digit > 9) {
|
|
907
|
-
digit = (digit % 10) + 1;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
sum += digit;
|
|
912
|
-
alternate = !alternate;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
return sum % 10 === 0;
|
|
916
|
-
}
|
|
116
|
+
function shipOrder(id: OrderId): void {
|
|
117
|
+
/* ... */
|
|
917
118
|
}
|
|
918
119
|
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
console.log(CreditCardValidator.isValid('1234 5678 9012 3456')); // false
|
|
922
|
-
```
|
|
923
|
-
|
|
924
|
-
## 📋 Serialization
|
|
925
|
-
|
|
926
|
-
### JSON Serialization
|
|
927
|
-
|
|
928
|
-
```typescript
|
|
929
|
-
// All value objects support JSON serialization
|
|
930
|
-
const money = new Money(100, 'USD');
|
|
931
|
-
const email = new Email('user@example.com');
|
|
932
|
-
const address = new Address('123 Main St', 'City', 'NY', '12345', 'USA');
|
|
933
|
-
|
|
934
|
-
// Serialize to JSON
|
|
935
|
-
const moneyJson = money.toJSON();
|
|
936
|
-
console.log(moneyJson); // { amount: 100, currency: 'USD' }
|
|
937
|
-
|
|
938
|
-
const emailJson = email.toJSON();
|
|
939
|
-
console.log(emailJson); // { value: 'user@example.com' }
|
|
940
|
-
|
|
941
|
-
const addressJson = address.toJSON();
|
|
942
|
-
console.log(addressJson);
|
|
943
|
-
// { street: '123 Main St', city: 'City', state: 'NY', zipCode: '12345', country: 'USA' }
|
|
944
|
-
```
|
|
945
|
-
|
|
946
|
-
### Deserialization
|
|
947
|
-
|
|
948
|
-
```typescript
|
|
949
|
-
// Deserialize from JSON
|
|
950
|
-
const deserializedMoney = Money.fromJSON(moneyJson);
|
|
951
|
-
console.log(deserializedMoney.equals(money)); // true
|
|
952
|
-
|
|
953
|
-
const deserializedEmail = Email.fromJSON(emailJson);
|
|
954
|
-
console.log(deserializedEmail.equals(email)); // true
|
|
955
|
-
|
|
956
|
-
const deserializedAddress = Address.fromJSON(addressJson);
|
|
957
|
-
console.log(deserializedAddress.equals(address)); // true
|
|
958
|
-
```
|
|
959
|
-
|
|
960
|
-
### Custom Serialization
|
|
961
|
-
|
|
962
|
-
```typescript
|
|
963
|
-
// Custom serialization for complex value objects
|
|
964
|
-
class PersonName extends ValueObject {
|
|
965
|
-
constructor(
|
|
966
|
-
private readonly firstName: string,
|
|
967
|
-
private readonly lastName: string,
|
|
968
|
-
private readonly middleName?: string
|
|
969
|
-
) {
|
|
970
|
-
super();
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
public get FirstName(): string {
|
|
974
|
-
return this.firstName;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
public get LastName(): string {
|
|
978
|
-
return this.lastName;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
public get MiddleName(): string | undefined {
|
|
982
|
-
return this.middleName;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
public getFullName(): string {
|
|
986
|
-
const parts = [this.firstName];
|
|
987
|
-
if (this.middleName) {
|
|
988
|
-
parts.push(this.middleName);
|
|
989
|
-
}
|
|
990
|
-
parts.push(this.lastName);
|
|
991
|
-
return parts.join(' ');
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
public toJSON(): object {
|
|
995
|
-
return {
|
|
996
|
-
firstName: this.firstName,
|
|
997
|
-
lastName: this.lastName,
|
|
998
|
-
middleName: this.middleName,
|
|
999
|
-
fullName: this.getFullName(),
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
public static fromJSON(json: any): PersonName {
|
|
1004
|
-
return new PersonName(json.firstName, json.lastName, json.middleName);
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
protected getEqualityComponents(): any[] {
|
|
1008
|
-
return [this.firstName, this.lastName, this.middleName];
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
```
|
|
1012
|
-
|
|
1013
|
-
## 🧪 Testing
|
|
1014
|
-
|
|
1015
|
-
### Unit Testing Value Objects
|
|
1016
|
-
|
|
1017
|
-
```typescript
|
|
1018
|
-
import { describe, it, expect } from 'vitest';
|
|
1019
|
-
import { Money, Email, Address } from '@vytches/ddd-value-objects';
|
|
1020
|
-
|
|
1021
|
-
describe('Money', () => {
|
|
1022
|
-
it('should create money with valid amount and currency', () => {
|
|
1023
|
-
// Arrange & Act
|
|
1024
|
-
const money = new Money(100, 'USD');
|
|
1025
|
-
|
|
1026
|
-
// Assert
|
|
1027
|
-
expect(money.amount).toBe(100);
|
|
1028
|
-
expect(money.currency).toBe('USD');
|
|
1029
|
-
});
|
|
1030
|
-
|
|
1031
|
-
it('should add money with same currency', () => {
|
|
1032
|
-
// Arrange
|
|
1033
|
-
const money1 = new Money(100, 'USD');
|
|
1034
|
-
const money2 = new Money(50, 'USD');
|
|
1035
|
-
|
|
1036
|
-
// Act
|
|
1037
|
-
const result = money1.add(money2);
|
|
1038
|
-
|
|
1039
|
-
// Assert
|
|
1040
|
-
expect(result.amount).toBe(150);
|
|
1041
|
-
expect(result.currency).toBe('USD');
|
|
1042
|
-
});
|
|
1043
|
-
|
|
1044
|
-
it('should throw error when adding different currencies', () => {
|
|
1045
|
-
// Arrange
|
|
1046
|
-
const money1 = new Money(100, 'USD');
|
|
1047
|
-
const money2 = new Money(50, 'EUR');
|
|
1048
|
-
|
|
1049
|
-
// Act & Assert
|
|
1050
|
-
expect(() => money1.add(money2)).toThrow(
|
|
1051
|
-
'Cannot operate on different currencies'
|
|
1052
|
-
);
|
|
1053
|
-
});
|
|
1054
|
-
});
|
|
1055
|
-
|
|
1056
|
-
describe('Email', () => {
|
|
1057
|
-
it('should create email with valid address', () => {
|
|
1058
|
-
// Arrange & Act
|
|
1059
|
-
const email = new Email('user@example.com');
|
|
1060
|
-
|
|
1061
|
-
// Assert
|
|
1062
|
-
expect(email.value).toBe('user@example.com');
|
|
1063
|
-
expect(email.localPart).toBe('user');
|
|
1064
|
-
expect(email.domain).toBe('example.com');
|
|
1065
|
-
});
|
|
1066
|
-
|
|
1067
|
-
it('should normalize email to lowercase', () => {
|
|
1068
|
-
// Arrange & Act
|
|
1069
|
-
const email = new Email('USER@EXAMPLE.COM');
|
|
1070
|
-
|
|
1071
|
-
// Assert
|
|
1072
|
-
expect(email.value).toBe('user@example.com');
|
|
1073
|
-
});
|
|
1074
|
-
|
|
1075
|
-
it('should throw error for invalid email', () => {
|
|
1076
|
-
// Act & Assert
|
|
1077
|
-
expect(() => new Email('invalid-email')).toThrow('Invalid email format');
|
|
1078
|
-
});
|
|
1079
|
-
});
|
|
1080
|
-
```
|
|
1081
|
-
|
|
1082
|
-
### Test Builders
|
|
1083
|
-
|
|
1084
|
-
```typescript
|
|
1085
|
-
// Test builder for complex value objects
|
|
1086
|
-
class AddressBuilder {
|
|
1087
|
-
private street: string = '123 Main St';
|
|
1088
|
-
private city: string = 'Anytown';
|
|
1089
|
-
private state: string = 'NY';
|
|
1090
|
-
private zipCode: string = '12345';
|
|
1091
|
-
private country: string = 'USA';
|
|
1092
|
-
|
|
1093
|
-
public withStreet(street: string): AddressBuilder {
|
|
1094
|
-
this.street = street;
|
|
1095
|
-
return this;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
public withCity(city: string): AddressBuilder {
|
|
1099
|
-
this.city = city;
|
|
1100
|
-
return this;
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
public withState(state: string): AddressBuilder {
|
|
1104
|
-
this.state = state;
|
|
1105
|
-
return this;
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
public withZipCode(zipCode: string): AddressBuilder {
|
|
1109
|
-
this.zipCode = zipCode;
|
|
1110
|
-
return this;
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
public withCountry(country: string): AddressBuilder {
|
|
1114
|
-
this.country = country;
|
|
1115
|
-
return this;
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
public build(): Address {
|
|
1119
|
-
return new Address(
|
|
1120
|
-
this.street,
|
|
1121
|
-
this.city,
|
|
1122
|
-
this.state,
|
|
1123
|
-
this.zipCode,
|
|
1124
|
-
this.country
|
|
1125
|
-
);
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
// Usage in tests
|
|
1130
|
-
describe('Address', () => {
|
|
1131
|
-
it('should create address with all components', () => {
|
|
1132
|
-
// Arrange
|
|
1133
|
-
const address = new AddressBuilder()
|
|
1134
|
-
.withStreet('456 Oak Ave')
|
|
1135
|
-
.withCity('Springfield')
|
|
1136
|
-
.withState('IL')
|
|
1137
|
-
.withZipCode('62701')
|
|
1138
|
-
.build();
|
|
1139
|
-
|
|
1140
|
-
// Assert
|
|
1141
|
-
expect(address.street).toBe('456 Oak Ave');
|
|
1142
|
-
expect(address.city).toBe('Springfield');
|
|
1143
|
-
expect(address.state).toBe('IL');
|
|
1144
|
-
expect(address.zipCode).toBe('62701');
|
|
1145
|
-
});
|
|
1146
|
-
});
|
|
1147
|
-
```
|
|
1148
|
-
|
|
1149
|
-
### Property-Based Testing
|
|
1150
|
-
|
|
1151
|
-
```typescript
|
|
1152
|
-
import { fc } from 'fast-check';
|
|
1153
|
-
|
|
1154
|
-
// Property-based testing for Money
|
|
1155
|
-
describe('Money Properties', () => {
|
|
1156
|
-
it('should be associative for addition', () => {
|
|
1157
|
-
fc.assert(
|
|
1158
|
-
fc.property(
|
|
1159
|
-
fc.float({ min: 0, max: 1000000 }),
|
|
1160
|
-
fc.float({ min: 0, max: 1000000 }),
|
|
1161
|
-
fc.float({ min: 0, max: 1000000 }),
|
|
1162
|
-
fc.constantFrom('USD', 'EUR', 'GBP'),
|
|
1163
|
-
(a, b, c, currency) => {
|
|
1164
|
-
const money1 = new Money(a, currency);
|
|
1165
|
-
const money2 = new Money(b, currency);
|
|
1166
|
-
const money3 = new Money(c, currency);
|
|
1167
|
-
|
|
1168
|
-
// (a + b) + c = a + (b + c)
|
|
1169
|
-
const left = money1.add(money2).add(money3);
|
|
1170
|
-
const right = money1.add(money2.add(money3));
|
|
1171
|
-
|
|
1172
|
-
expect(left.equals(right)).toBe(true);
|
|
1173
|
-
}
|
|
1174
|
-
)
|
|
1175
|
-
);
|
|
1176
|
-
});
|
|
1177
|
-
});
|
|
1178
|
-
```
|
|
1179
|
-
|
|
1180
|
-
## 🏆 Best Practices
|
|
1181
|
-
|
|
1182
|
-
### Value Object Design
|
|
1183
|
-
|
|
1184
|
-
```typescript
|
|
1185
|
-
// ✅ Good: Immutable, validated, rich behavior
|
|
1186
|
-
class ISBN extends ValueObject {
|
|
1187
|
-
private readonly value: string;
|
|
1188
|
-
|
|
1189
|
-
constructor(isbn: string) {
|
|
1190
|
-
super();
|
|
1191
|
-
this.value = this.normalize(isbn);
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
public get Value(): string {
|
|
1195
|
-
return this.value;
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
public isISBN10(): boolean {
|
|
1199
|
-
return this.value.length === 10;
|
|
1200
|
-
}
|
|
1201
|
-
|
|
1202
|
-
public isISBN13(): boolean {
|
|
1203
|
-
return this.value.length === 13;
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
public format(): string {
|
|
1207
|
-
if (this.isISBN13()) {
|
|
1208
|
-
return `${this.value.slice(0, 3)}-${this.value.slice(3, 4)}-${this.value.slice(4, 6)}-${this.value.slice(6, 12)}-${this.value.slice(12)}`;
|
|
1209
|
-
} else {
|
|
1210
|
-
return `${this.value.slice(0, 1)}-${this.value.slice(1, 4)}-${this.value.slice(4, 9)}-${this.value.slice(9)}`;
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
protected getEqualityComponents(): any[] {
|
|
1215
|
-
return [this.value];
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
private normalize(isbn: string): string {
|
|
1219
|
-
const cleaned = isbn.replace(/[\s-]/g, '');
|
|
1220
|
-
|
|
1221
|
-
if (!/^[\d]{9}[\dX]$/.test(cleaned) && !/^[\d]{13}$/.test(cleaned)) {
|
|
1222
|
-
throw new ValidationError('Invalid ISBN format', 'isbn', isbn);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
return cleaned;
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
|
|
1229
|
-
// ❌ Bad: Mutable, no validation
|
|
1230
|
-
class ISBN {
|
|
1231
|
-
public value: string;
|
|
1232
|
-
|
|
1233
|
-
constructor(isbn: string) {
|
|
1234
|
-
this.value = isbn; // No validation
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
```
|
|
1238
|
-
|
|
1239
|
-
### Validation Strategy
|
|
1240
|
-
|
|
1241
|
-
```typescript
|
|
1242
|
-
// ✅ Good: Comprehensive validation
|
|
1243
|
-
class CreditCard extends ValueObject {
|
|
1244
|
-
private readonly number: string;
|
|
1245
|
-
private readonly type: CreditCardType;
|
|
1246
|
-
|
|
1247
|
-
constructor(cardNumber: string) {
|
|
1248
|
-
super();
|
|
1249
|
-
this.number = this.normalize(cardNumber);
|
|
1250
|
-
this.type = this.detectType(this.number);
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
public get Number(): string {
|
|
1254
|
-
return this.number;
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
public get Type(): CreditCardType {
|
|
1258
|
-
return this.type;
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
public getMaskedNumber(): string {
|
|
1262
|
-
const last4 = this.number.slice(-4);
|
|
1263
|
-
const masked = '*'.repeat(this.number.length - 4);
|
|
1264
|
-
return `${masked}${last4}`;
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
protected getEqualityComponents(): any[] {
|
|
1268
|
-
return [this.number];
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
private normalize(cardNumber: string): string {
|
|
1272
|
-
const cleaned = cardNumber.replace(/[\s-]/g, '');
|
|
1273
|
-
|
|
1274
|
-
if (!/^\d+$/.test(cleaned)) {
|
|
1275
|
-
throw new ValidationError(
|
|
1276
|
-
'Credit card number must contain only digits',
|
|
1277
|
-
'cardNumber',
|
|
1278
|
-
cardNumber
|
|
1279
|
-
);
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
if (cleaned.length < 13 || cleaned.length > 19) {
|
|
1283
|
-
throw new ValidationError(
|
|
1284
|
-
'Credit card number must be between 13 and 19 digits',
|
|
1285
|
-
'cardNumber',
|
|
1286
|
-
cardNumber
|
|
1287
|
-
);
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
if (!this.luhnCheck(cleaned)) {
|
|
1291
|
-
throw new ValidationError(
|
|
1292
|
-
'Invalid credit card number',
|
|
1293
|
-
'cardNumber',
|
|
1294
|
-
cardNumber
|
|
1295
|
-
);
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
return cleaned;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
private detectType(number: string): CreditCardType {
|
|
1302
|
-
if (number.startsWith('4')) return CreditCardType.VISA;
|
|
1303
|
-
if (number.startsWith('5') || number.startsWith('2'))
|
|
1304
|
-
return CreditCardType.MASTERCARD;
|
|
1305
|
-
if (number.startsWith('3')) return CreditCardType.AMEX;
|
|
1306
|
-
return CreditCardType.UNKNOWN;
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
private luhnCheck(number: string): boolean {
|
|
1310
|
-
// Implementation details...
|
|
1311
|
-
return true;
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
```
|
|
1315
|
-
|
|
1316
|
-
### Error Handling
|
|
1317
|
-
|
|
1318
|
-
```typescript
|
|
1319
|
-
// ✅ Good: Specific error types and messages
|
|
1320
|
-
class TaxId extends ValueObject {
|
|
1321
|
-
private readonly value: string;
|
|
1322
|
-
|
|
1323
|
-
constructor(taxId: string) {
|
|
1324
|
-
super();
|
|
1325
|
-
this.value = this.validate(taxId);
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
private validate(taxId: string): string {
|
|
1329
|
-
if (!taxId) {
|
|
1330
|
-
throw new ValidationError('Tax ID cannot be empty', 'taxId', taxId);
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
const cleaned = taxId.replace(/[\s-]/g, '');
|
|
1334
|
-
|
|
1335
|
-
if (!/^\d{9}$/.test(cleaned)) {
|
|
1336
|
-
throw new ValidationError('Tax ID must be 9 digits', 'taxId', taxId);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
if (cleaned === '000000000') {
|
|
1340
|
-
throw new ValidationError('Tax ID cannot be all zeros', 'taxId', taxId);
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
return cleaned;
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
// ❌ Bad: Generic errors
|
|
1348
|
-
class TaxId {
|
|
1349
|
-
constructor(taxId: string) {
|
|
1350
|
-
if (!taxId) {
|
|
1351
|
-
throw new Error('Invalid'); // Too generic
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
```
|
|
1356
|
-
|
|
1357
|
-
## 🤝 Contributing
|
|
1358
|
-
|
|
1359
|
-
We welcome contributions! Please see our
|
|
1360
|
-
[Contributing Guide](../../CONTRIBUTING.md) for details.
|
|
1361
|
-
|
|
1362
|
-
### Development Setup
|
|
1363
|
-
|
|
1364
|
-
```bash
|
|
1365
|
-
# Clone repository
|
|
1366
|
-
git clone https://github.com/vytches/ddd.git
|
|
1367
|
-
cd ddd
|
|
1368
|
-
|
|
1369
|
-
# Install dependencies
|
|
1370
|
-
pnpm install
|
|
1371
|
-
|
|
1372
|
-
# Build package
|
|
1373
|
-
pnpm build --filter=@vytches/ddd-value-objects
|
|
1374
|
-
|
|
1375
|
-
# Run tests
|
|
1376
|
-
pnpm test --filter=@vytches/ddd-value-objects
|
|
1377
|
-
|
|
1378
|
-
# Run in development mode
|
|
1379
|
-
pnpm dev --filter=@vytches/ddd-value-objects
|
|
120
|
+
shipOrder(orderId); // OK
|
|
121
|
+
shipOrder(customerId); // TypeScript compile error!
|
|
1380
122
|
```
|
|
1381
123
|
|
|
1382
|
-
##
|
|
124
|
+
## Package boundaries
|
|
1383
125
|
|
|
1384
|
-
|
|
1385
|
-
[LICENSE](../../LICENSE) file for details.
|
|
126
|
+
`@vytches/ddd-value-objects` depends on:
|
|
1386
127
|
|
|
1387
|
-
|
|
128
|
+
- `@vytches/ddd-contracts` — `EntityId` base class, `IEntityId`, `IdType`
|
|
129
|
+
- `@vytches/ddd-domain-primitives` — error types
|
|
130
|
+
- `@vytches/ddd-utils` — `LibUtils`, `Result`
|
|
1388
131
|
|
|
1389
|
-
|
|
132
|
+
## License
|
|
1390
133
|
|
|
1391
|
-
|
|
134
|
+
MIT
|