diff --git a/docs/reference/value-object.md b/docs/reference/value-object.md index 5a40bd1..de96444 100644 --- a/docs/reference/value-object.md +++ b/docs/reference/value-object.md @@ -27,6 +27,12 @@ abstract class ValueObject constructor(value: T | null | undefined) ``` +## Runtime immutability + +The stored `value` cannot be reassigned, deleted, or redefined, including from JavaScript. Writes in strict mode throw a `TypeError`; non-strict assignments leave the value unchanged. + +Only the stored value is protected. Subclasses can initialize additional fields after `super()`, and are responsible for protecting those fields if needed. Subclasses must pass their final primitive value to `super()` instead of redeclaring or assigning `value` afterward. + ## NullObject creation Automatic NullObject creation is enabled by default. A `null` or `undefined` constructor value returns a compatible null object instead of storing the nullish value. diff --git a/scripts/verify-distribution.cjs b/scripts/verify-distribution.cjs index be9ce2b..924ab56 100644 --- a/scripts/verify-distribution.cjs +++ b/scripts/verify-distribution.cjs @@ -49,6 +49,38 @@ const main = async () => { const esm = await import(esmUrl); verifyExports(esm, 'ESM'); + for (const runtime of [cjs, esm]) { + const email = new runtime.Email('user@example.com'); + assert.equal(Reflect.set(email, 'value', 'invalid'), false); + email.value = 'invalid'; // Non-strict JavaScript must leave the value intact. + assert.throws(() => { + 'use strict'; + email.value = 'invalid'; + }, TypeError); + assert.equal(Reflect.deleteProperty(email, 'value'), false); + assert.throws( + () => Object.defineProperty(email, 'value', { value: 'invalid' }), + TypeError, + ); + assert.equal(email.valueOf(), 'user@example.com'); + + class LabeledEmail extends runtime.Email { + label = 'work'; + } + const labeled = new LabeledEmail('user@example.com'); + assert.equal(labeled.label, 'work'); + assert.equal(labeled.valueOf(), 'user@example.com'); + + class Status extends runtime.Enum { + getValues() { + return ['active', 'inactive']; + } + } + assert.equal(new Status('active').valueOf(), 'active'); + assert.throws(() => new Status('invalid'), runtime.ValueNotInEnumError); + assert.ok(runtime.NullObject.isNullObject(new Status(undefined))); + } + for (const name of Object.keys(cjs)) { assert.strictEqual( esm[name], diff --git a/src/value-objects/Enum.ts b/src/value-objects/Enum.ts index 66c3585..960a7df 100644 --- a/src/value-objects/Enum.ts +++ b/src/value-objects/Enum.ts @@ -7,7 +7,7 @@ import { ValueObject } from './ValueObject'; export abstract class Enum< T extends Primitive = Primitive, > extends ValueObject { - constructor(protected readonly value: T) { + constructor(value: T) { super(value); if (NullObject.isNullObject(this)) { diff --git a/src/value-objects/ValueObject.ts b/src/value-objects/ValueObject.ts index f9d96b6..fe61952 100644 --- a/src/value-objects/ValueObject.ts +++ b/src/value-objects/ValueObject.ts @@ -23,7 +23,12 @@ export abstract class ValueObject { return NullObject.new(new.target); } - this.value = value; + Object.defineProperty(this, 'value', { + value, + writable: false, + configurable: false, + enumerable: true, + }); } private isNullish(value: T | Nullish): value is null | undefined {