diff --git a/docs/guides/null-object.md b/docs/guides/null-object.md index 7111661..6b4aad1 100644 --- a/docs/guides/null-object.md +++ b/docs/guides/null-object.md @@ -47,6 +47,17 @@ const value = new StringValueObject(undefined as never); value.isEmpty(); // throws NullObjectError ``` +## Time and coordinates + +`Day`, `Hour`, `Duration`, `CalendarDay`, `MonthOfYear`, `TimestampInterval`, `Latitude`, `Longitude` and `Coordinates` follow the same rule as the rest of the package: nullish input returns a NullObject instead of throwing `TypeError`. Composites such as `TimestampInterval` and `Coordinates` return a NullObject when any of their parts is nullish. + +```typescript +const day = new Day(undefined as never); + +NullObject.isNullObject(day); // true +day.valueOf(); // undefined +``` + ## When to check ```typescript diff --git a/docs/reference/month-of-year.md b/docs/reference/month-of-year.md index 3b1c5f3..eed56e0 100644 --- a/docs/reference/month-of-year.md +++ b/docs/reference/month-of-year.md @@ -34,7 +34,7 @@ Month and year are validated through `Month` and `Year`. | Method | Description | | --- | --- | | `static fromTimestamp(timestamp)` | Creates a month/year from a timestamp. | -| `static fromString(value)` | Parses `YYYY/MM`. | +| `static fromString(value)` | Parses `YYYY/MM`; throws `InvalidFormatError` when the text is not `year/month`. | | `getMonth()` | Returns a `Month`. | | `getYear()` | Returns a `Year`. | | `getNumberOfDays()` | Returns the number of days in that month. | diff --git a/src/value-objects/StringValueObject.ts b/src/value-objects/StringValueObject.ts index 90fc962..e876715 100644 --- a/src/value-objects/StringValueObject.ts +++ b/src/value-objects/StringValueObject.ts @@ -1,10 +1,14 @@ import { InvalidStringLengthError } from '../errors/InvalidStringLengthError'; import { assert } from '../patterns/Assert'; +import { Nullish } from '../types'; import { NullObject } from './NullObject'; import { ValueObject } from './ValueObject'; export class StringValueObject extends ValueObject { - constructor(value: string | StringValueObject, maxLength: number = 512) { + constructor( + value: string | StringValueObject | Nullish, + maxLength: number = 512, + ) { super(value?.valueOf()); if (NullObject.isNullObject(this)) { diff --git a/src/value-objects/coordinates/Coordinates.ts b/src/value-objects/coordinates/Coordinates.ts index 66f7b0f..4c4af52 100644 --- a/src/value-objects/coordinates/Coordinates.ts +++ b/src/value-objects/coordinates/Coordinates.ts @@ -1,10 +1,11 @@ +import { NullObject } from '../NullObject'; import { StringValueObject } from '../StringValueObject'; import { Latitude } from './Latitude'; import { Longitude } from './Longitude'; export class Coordinates extends StringValueObject { - private latitude: Latitude; - private longitude: Longitude; + private latitude!: Latitude; + private longitude!: Longitude; public static fromString(value: string): Coordinates { const latitude = parseFloat(value.split(',')[0]); @@ -14,7 +15,17 @@ export class Coordinates extends StringValueObject { } constructor(latitude: number | Latitude, longitude: number | Longitude) { - super(latitude?.valueOf() + ',' + longitude?.valueOf()); + const hasValues = + latitude !== null && + latitude !== undefined && + longitude !== null && + longitude !== undefined; + + super(hasValues ? latitude.valueOf() + ',' + longitude.valueOf() : null); + + if (NullObject.isNullObject(this)) { + return this; + } this.latitude = new Latitude(latitude.valueOf()); this.longitude = new Longitude(longitude.valueOf()); diff --git a/src/value-objects/coordinates/Latitude.ts b/src/value-objects/coordinates/Latitude.ts index e0360b4..e303e65 100644 --- a/src/value-objects/coordinates/Latitude.ts +++ b/src/value-objects/coordinates/Latitude.ts @@ -17,7 +17,10 @@ export class Latitude extends NumberValueObject { } constructor(value: number | NumberValueObject) { - assert(Latitude.isValid(value), new InvalidLatitudeError(value.valueOf())); - super(value.valueOf()); + assert( + value === null || value === undefined || Latitude.isValid(value), + new InvalidLatitudeError(value?.valueOf()), + ); + super(value?.valueOf()); } } diff --git a/src/value-objects/coordinates/Longitude.ts b/src/value-objects/coordinates/Longitude.ts index 4d26dce..740cb4e 100644 --- a/src/value-objects/coordinates/Longitude.ts +++ b/src/value-objects/coordinates/Longitude.ts @@ -18,9 +18,9 @@ export class Longitude extends NumberValueObject { constructor(value: number | NumberValueObject) { assert( - Longitude.isValid(value), - new InvalidLongitudeError(value.valueOf()), + value === null || value === undefined || Longitude.isValid(value), + new InvalidLongitudeError(value?.valueOf()), ); - super(value.valueOf()); + super(value?.valueOf()); } } diff --git a/src/value-objects/time/CalendarDay.ts b/src/value-objects/time/CalendarDay.ts index cab2ecd..7c19d0c 100644 --- a/src/value-objects/time/CalendarDay.ts +++ b/src/value-objects/time/CalendarDay.ts @@ -9,9 +9,9 @@ import { MonthOfYear } from './MonthOfYear'; import { Timestamp } from './Timestamp'; export class CalendarDay extends ValueObject { - private readonly year: number; - private readonly month: number; - private readonly day: number; + private readonly year!: number; + private readonly month!: number; + private readonly day!: number; private static ensureIsValidString(value: string): void { assert( @@ -62,15 +62,19 @@ export class CalendarDay extends ValueObject { } constructor(value?: string | Date | number | Timestamp) { - let timestamp: Timestamp; + let timestamp: Timestamp | undefined; - if (value !== undefined) { - timestamp = CalendarDay.timestampFromValue(value); - } else { + if (value === undefined) { timestamp = new Timestamp(); + } else if (value !== null) { + timestamp = CalendarDay.timestampFromValue(value); } - super(CalendarDay.generateStringValue(timestamp)); + super(timestamp && CalendarDay.generateStringValue(timestamp)); + + if (timestamp === undefined) { + return this; + } this.year = timestamp.getYear().valueOf(); this.month = timestamp.getMonth().getIndex() + 1; diff --git a/src/value-objects/time/Day.ts b/src/value-objects/time/Day.ts index d5bb991..263235f 100644 --- a/src/value-objects/time/Day.ts +++ b/src/value-objects/time/Day.ts @@ -1,11 +1,16 @@ import { InvalidDayError } from '../../errors/InvalidDayError'; import { assert } from '../../patterns/Assert'; import { Integer } from '../Integer'; +import { NullObject } from '../NullObject'; import { NumberValueObject } from '../NumberValueObject'; export class Day extends Integer { constructor(value: number | NumberValueObject) { - super(value.valueOf()); + super(value?.valueOf()); + + if (NullObject.isNullObject(this)) { + return this; + } this.ensureIsValidDay(); } diff --git a/src/value-objects/time/Duration.ts b/src/value-objects/time/Duration.ts index c9ae63c..19eab87 100644 --- a/src/value-objects/time/Duration.ts +++ b/src/value-objects/time/Duration.ts @@ -75,7 +75,7 @@ export class Duration extends NumberValueObject { } constructor(milliseconds: NumberValueObject | Duration) { - super(milliseconds.valueOf()); + super(milliseconds?.valueOf()); } public getTotalDays(): NumberValueObject { diff --git a/src/value-objects/time/Hour.ts b/src/value-objects/time/Hour.ts index 182e245..e9de4f1 100644 --- a/src/value-objects/time/Hour.ts +++ b/src/value-objects/time/Hour.ts @@ -4,8 +4,8 @@ import { assert } from '../../patterns/Assert'; import { StringValueObject } from '../StringValueObject'; export class Hour extends StringValueObject { - private readonly minutes: string; - private readonly hours: string; + private readonly minutes!: string; + private readonly hours!: string; private static addTrailZero(value: number): string { return value < 10 ? `0${value}` : value.toString(); @@ -55,14 +55,22 @@ export class Hour extends StringValueObject { constructor(value: string); constructor(value: number, minutes?: number); constructor(value: string | number, minutes?: number) { - const [parsedHours, parsedMinutes] = - typeof value === 'number' && minutes !== undefined - ? Hour.parseFromNumber(value, minutes) - : Hour.parseFromString(value as string); - - super(`${parsedHours}:${parsedMinutes}`); - this.hours = parsedHours; - this.minutes = parsedMinutes; + let parsed: string[] | undefined; + + if (value !== null && value !== undefined) { + parsed = + typeof value === 'number' && minutes !== undefined + ? Hour.parseFromNumber(value, minutes) + : Hour.parseFromString(value as string); + } + + super(parsed && `${parsed[0]}:${parsed[1]}`); + + if (parsed === undefined) { + return this; + } + + [this.hours, this.minutes] = parsed; } public addMinutes(minutes: number): Hour { diff --git a/src/value-objects/time/MonthOfYear.ts b/src/value-objects/time/MonthOfYear.ts index a7bf38c..b7b416a 100644 --- a/src/value-objects/time/MonthOfYear.ts +++ b/src/value-objects/time/MonthOfYear.ts @@ -1,3 +1,5 @@ +import { InvalidFormatError } from '../../errors/InvalidFormatError'; +import { assert } from '../../patterns/Assert'; import { ValueObject } from '../ValueObject'; import { Month } from './Month'; import { Timestamp } from './Timestamp'; @@ -5,25 +7,40 @@ import { TimestampInterval } from './TimestampInterval'; import { Year } from './Year'; export class MonthOfYear extends ValueObject { - private readonly month: Month; - private readonly year: Year; + private readonly month!: Month; + private readonly year!: Year; public static fromTimestamp(timestamp: Timestamp): MonthOfYear { return new MonthOfYear(timestamp.getMonth(), timestamp.getYear()); } public static fromString(value: string): MonthOfYear { + assert(/^[^/]+\/[^/]+$/.test(value), new InvalidFormatError(value)); + const [year, month] = value.split('/').map(Number); return new MonthOfYear(month, year); } constructor(month: number | Month, year: number | Year) { + const hasValues = + month !== null && + month !== undefined && + year !== null && + year !== undefined; + super( - year.valueOf().toString() + - '/' + - month.valueOf().toString().padStart(2, '0'), + hasValues + ? year.valueOf().toString() + + '/' + + month.valueOf().toString().padStart(2, '0') + : null, ); + + if (!hasValues) { + return this; + } + this.month = new Month(month.valueOf()); this.year = new Year(year.valueOf()); } diff --git a/src/value-objects/time/TimestampInterval.ts b/src/value-objects/time/TimestampInterval.ts index bc37129..8c9d4a3 100644 --- a/src/value-objects/time/TimestampInterval.ts +++ b/src/value-objects/time/TimestampInterval.ts @@ -1,6 +1,7 @@ import { InvalidTimestampIntervalError } from '../../errors/InvalidTimestampIntervalError'; import { PrimitiveOf } from '../../interfaces/PrimitiveOf'; import { assert } from '../../patterns/Assert'; +import { NullObject } from '../NullObject'; import { PositiveNumber } from '../PositiveNumber'; import { ValueObject } from '../ValueObject'; import { CalendarDay } from './CalendarDay'; @@ -22,7 +23,11 @@ export class TimestampInterval extends ValueObject { private readonly start: Timestamp, private readonly end: Timestamp, ) { - super(`${start.valueOf()}-${end.valueOf()}`); + super(start && end ? `${start.valueOf()}-${end.valueOf()}` : null); + + if (NullObject.isNullObject(this)) { + return this; + } assert( start.isBeforeOrEqual(end), diff --git a/tests/value-objects/time/TimeNullSafety.spec.ts b/tests/value-objects/time/TimeNullSafety.spec.ts new file mode 100644 index 0000000..33e0807 --- /dev/null +++ b/tests/value-objects/time/TimeNullSafety.spec.ts @@ -0,0 +1,58 @@ +import { + CalendarDay, + Coordinates, + Day, + Duration, + Hour, + Latitude, + Longitude, + MonthOfYear, + NullObject, + Timestamp, + TimestampInterval, +} from '../../../src'; + +describe('Null safety', () => { + const nullish = [null, undefined] as unknown[]; + + it.each(nullish)('should create NullObjects from %s', (value) => { + const created = [ + new Day(value as number), + new Duration(value as Duration), + new Hour(value as string), + new Latitude(value as number), + new Longitude(value as number), + new Coordinates(value as number, value as number), + new MonthOfYear(value as number, value as number), + new TimestampInterval(value as Timestamp, value as Timestamp), + ]; + + created.forEach((object) => + expect(NullObject.isNullObject(object)).toBeTrue(), + ); + }); + + it('should create a NullObject CalendarDay from null only', () => { + expect(NullObject.isNullObject(new CalendarDay(null as never))).toBeTrue(); + expect(NullObject.isNullObject(new CalendarDay())).toBeFalse(); + }); + + it('should create a NullObject when only part of a composite is missing', () => { + expect( + NullObject.isNullObject(new Coordinates(1, null as never)), + ).toBeTrue(); + expect( + NullObject.isNullObject(new Coordinates(null as never, 1)), + ).toBeTrue(); + expect( + NullObject.isNullObject(new MonthOfYear(1, null as never)), + ).toBeTrue(); + expect( + NullObject.isNullObject(new MonthOfYear(null as never, 2024)), + ).toBeTrue(); + }); + + it('should still reject malformed MonthOfYear strings', () => { + expect(() => MonthOfYear.fromString('2022')).toThrow(); + }); +});