Maxim Pyshko
Maxim Pyshko

Reputation: 580

Is there an elegant way to have value objects in typescript?

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

Answers (2)

Kryten
Kryten

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

Richard Haddad
Richard Haddad

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

Related Questions