Reputation: 580
The problem
For validation purposes I want to have some value objects in the core of my system. These value objects could be classes:
class UserName {
readonly value: string;
constructor(value: string) {
this.value = value.trim();
if (this.value === '') {
throw new Error('Empty value');
}
}
}
// and use it
userService.updateName({id: 1, name: new UserName('Maxim')});
But it does not work well because everyone can call the service without instantiating the class and therefore without validation:
userService.updateName({id: 1, name: {value: INVALID_NAME});
My solution
I have an interface and utility function to create object that is not convenient to create manually:
interface ValueObject<T extends string, U> {
readonly type: T;
readonly value: U;
readonly DO_NOT_CREATE_MANUALLY: 'DO NOT CREATE THIS MANUALLY!',
}
function ValueObject<T extends string, U>(type: T, value: U): ValueObject<T, U> {
return {
type,
value,
DO_NOT_CREATE_MANUALLY: 'DO NOT CREATE THIS MANUALLY!',
};
}
And I have an interface for value object and utility function:
interface UserName extends ValueObject<'UserName', string> {}
function UserName(value: string): UserName {
value = value.trim();
if (value === '') {
throw new Error('Empty value');
}
return ValueObject('UserName', value);
}
I use it like that:
userService.updateName({id: 1, name: UserName('Maxim')});
It works well. No one will write:
userService.updateName({
id: 1,
name: {
value: INVALI_VALUE,
type: 'UserName',
DO_NOT_CREATE_MANUALLY: 'DO NOT CREATE THIS MANUALLY!',
},
});
The question
Is there a more elegant way to have value objects in typescript without DO_NOT_CREATE_MANUALLY
?
Upvotes: 5
Views: 5477
Reputation: 15760
To flesh out the comment from @titian a little more...
Consider the following class:
class UserName {
public readonly value: string;
constructor(value: string) {
if (value === '') {
throw new Error('Empty value');
}
this.value = value;
}
}
According to the Typescript type compatibility documentation, any object with a value: string
property will be compatible with an instance of this class. This is what you are trying to avoid - you want to ensure that any UserName
-like object passed to userService.updateName
is, in fact, a UserName
object.
The solution is to add a private property to the class so that any UserName
-like object must also have that private property:
class UserName {
public readonly value: string;
private readonly is_nominal: boolean = true;
constructor(value: string) {
if (value === '') {
throw new Error('Empty value');
}
this.value = value;
}
}
Now, if someone does the following:
userService.updateName({ id: 1, value: { value: 'James T Kirk' } })`
Typescript will complain, since { value: 'James T Kirk' }
is missing the private is_nominal
property.
There is, however, a somewhat simpler (and IMHO cleaner) way to add a private field: make the value private and add an accessor.
class UserName {
private readonly _value: string;
constructor(value: string) {
if (value === '') {
throw new Error('Empty value');
}
this._value = value;
}
get value() { return this._value; }
}
Upvotes: 5
Reputation: 1004
Like Titian said, you have to put a private
field to the class.
But instead of putting a useless and ugly field, you can simply externalize your constructor logic, like that:
class UserName {
readonly value: string;
constructor(value: string) {
this.value = this.compute(value);
}
private compute(value: string): string {
value = value.trim();
if (value === '') {
throw new Error('Empty value');
}
return value;
}
}
Upvotes: 1