Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/guides/null-object.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/month-of-year.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
6 changes: 5 additions & 1 deletion src/value-objects/StringValueObject.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
constructor(value: string | StringValueObject, maxLength: number = 512) {
constructor(
value: string | StringValueObject | Nullish,
maxLength: number = 512,
) {
super(value?.valueOf());

if (NullObject.isNullObject(this)) {
Expand Down
17 changes: 14 additions & 3 deletions src/value-objects/coordinates/Coordinates.ts
Original file line number Diff line number Diff line change
@@ -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]);
Expand All @@ -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());
Expand Down
7 changes: 5 additions & 2 deletions src/value-objects/coordinates/Latitude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
6 changes: 3 additions & 3 deletions src/value-objects/coordinates/Longitude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
20 changes: 12 additions & 8 deletions src/value-objects/time/CalendarDay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { MonthOfYear } from './MonthOfYear';
import { Timestamp } from './Timestamp';

export class CalendarDay extends ValueObject<string> {
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(
Expand Down Expand Up @@ -62,15 +62,19 @@ export class CalendarDay extends ValueObject<string> {
}

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;
Expand Down
7 changes: 6 additions & 1 deletion src/value-objects/time/Day.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Expand Down
2 changes: 1 addition & 1 deletion src/value-objects/time/Duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class Duration extends NumberValueObject {
}

constructor(milliseconds: NumberValueObject | Duration) {
super(milliseconds.valueOf());
super(milliseconds?.valueOf());
}

public getTotalDays(): NumberValueObject {
Expand Down
28 changes: 18 additions & 10 deletions src/value-objects/time/Hour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 22 additions & 5 deletions src/value-objects/time/MonthOfYear.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,46 @@
import { InvalidFormatError } from '../../errors/InvalidFormatError';
import { assert } from '../../patterns/Assert';
import { ValueObject } from '../ValueObject';
import { Month } from './Month';
import { Timestamp } from './Timestamp';
import { TimestampInterval } from './TimestampInterval';
import { Year } from './Year';

export class MonthOfYear extends ValueObject<string> {
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());
}
Expand Down
7 changes: 6 additions & 1 deletion src/value-objects/time/TimestampInterval.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -22,7 +23,11 @@ export class TimestampInterval extends ValueObject<string> {
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),
Expand Down
58 changes: 58 additions & 0 deletions tests/value-objects/time/TimeNullSafety.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading