Modeling Primitives as Value Objects in TypeScript

Modeling Primitives as Value Objects in TypeScript

Introduction

When we model an entity we need to declare some properties with primitive types such as number or string. However, these types are so generic that they do not express the logic and rules behind the properties we need to model. We will cover some ways to avoid this "primitive obsession".

Modeling with Primitives

Let’s start by modeling a hypothetical user aggregate that has an email, and a phone, there could be more properties but that's enough for this example. A common and easy way to model the user would be to use primitive types like:

interface User {
  email: string;
  phone: string;
  // ... more properties
}

Although this solution is simple and flexible, it has some caveats the string properties could take any text value and the model itself does not express the rules inside it when we look at the types. There is even a code smell well known as Primitive Obsession.

/* 
 * The model does not express the rules behind the aggregate
 */
const user: User = {
  // no errors when initialize with invalid values
  email: 'testdemo',
  phone: '77',
};

Value Objects with Classes

A small simple object, like money or a date range, whose equality isn't based on identity.

-Fowler, Martin

One of the patterns to solve Primitive Obsession is the one proposed on DDD, Value Objects, we can encapsulate the logic of the value inside a class. Let's see that with the email value, we need to make a class to encapsulate the value and its rules.

class Email {
  value: string;

  constructor(
    value: string,
  ) {
    if (!this.isValidEmail(value)) throw new Error(`Invalid email '${value}'`);

    this.value = value;
  }

  private isValidEmail(value: string) {
    const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/gi;

    return emailRegex.test(value);
  }
}

With the previous class, now declaring a user now would be:

interface User {
  email: Email;
  phone: Phone; // assuming we have another Value Object class for phone
  // ... more properties
}

Now the model can express what kind of values we need and we know there are some rules behind those values. Additionally, if we try to create a new user with an invalid value an error will be thrown:

const invalidUser: User = {
  email: new Email('testdemo'),
  phone: new Phone('77'),
};
// Error: Invalid email 'testdemo'

Good! We have a valid model to express the rules behind our aggregate but one of the caveats of this pattern on Typescript is that we have to deal with a nested structure, which is shown in the following code:

const validUser: User = {
  email: new Email('test@demo.io'),
  phone: new Phone('+1 777 77777'),
};

console.log(validUser);
/* 
 * Output:
 *   {
 *     email: Email { value: 'test@demo.io' },
 *     phone: Phone { value: '+1 777 77777' }
 *   }
 */

Certainly, if we want to store this data in a database, send it as a response to a client, or send it to another service, we will need to flatten the object to transform this email object into its primitive value. Some complexity might be added to our code to support these transformations, you might need to add a layer with the appropriate transformations for each aggregate or entity you have.

Using type branding

Maybe you can think that instead of declaring a class we can declare a type alias for the value, let's see with Email:

type Email = string;

Although it simply expresses the value that it holds, it is not enough to model the email value because it could be any string and no validation prevents us from assigning a wrong value.

However, we can brand this type! It will mimic the behavior of Nominal Typing which ensures that we assign values with the same type name and not by its structure.

type Email = string & { __type: 'Email' }

Then when we try to assign a raw string to a variable of type string it will show an error, let's try to assign the email property of a user to a raw string:

const invalidUser: User = {
  email: 'test@test.com', // It will show the error: Type 'string' is not assignable to type 'Email'.
  phone: '+1 123 12111'
};

Great! But how can we assign an email correctly? We need a type predicate function to check if the type is assignable:

const isValidEmail = (value: string): value is Email => {
  const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/gi;

  return emailRegex.test(value);
};

Note that the return type of the previous function value is Email it will help us to parse a value to Email safely

const toEmail = (value: string): Email => {
  if (isValidEmail(value)) {
    return value; // value now is an Email! 
  }

  throw new Error('Not valid Email');
}

Finally, we can model and initialize our user in this way:

interface User {
  email: Email;
  phone: Phone;
  // ...
}

const myBrandedUser: User = {
  email: toEmail('test@demo.io'), // Ensure the type is an email and cast as an email
  // ...
};

console.log(myBrandedUser);
/*
 * Output:
 * { email: 'test@demo.io', phone: '+1 777 77777', name: 'John Doe' }
 */

It gives us safety and less complexity in our structure than using objects. However, it can easily be bypassed by using the keyword as provided by Typescript like 'invalidmail' as Email and it will work. So it's up to you and your team to not use it, or to use it in odd cases where it's absolutely necessary.

Conclusions

  • It is helpful to model attributes as Value Objects because we can model our business logic inside of them and ensure it in the entire code base.

  • Using objects with only one primitive property to model our Value Object gives us safety and good modeling but it could add more complexity than expected to our code base.

  • Using type branding we can use primitive-like values without losing safety and modeling of the business logic behind the value. However, typescript allows us to bypass the type checking with the keyword as.

References

Β