Effortless Entity Management: Type Aliases for Creation and Restoration

Dive into how TypeScript type aliases simplify the creation and restoration of Domain-Driven Design (DDD) entities, offering a cleaner constructor pattern compared to traditional static factory methods. Discover the elegance and ease of maintenance that this approach brings to your code.

Effortless Entity Management: Type Aliases for Creation and Restoration

TL;DR

In this article, we'll explore how to use TypeScript type aliases to simplify the creation and restoration of Domain-Driven Design (DDD) entities. By leveraging type aliases, we can achieve a cleaner constructor pattern, enhancing readability and maintainability.

This approach offers a more elegant and flexible solution compared to traditional static factory methods, aligning with TypeScript's strengths in expressing complex domain models.

For a fast track to the code and examples, jump to the The Approach of Type Aliases and Creating the ClassProps<T, U> Type Alias sections.


As we delve into the intricate world of Domain-Driven Design (DDD), we encounter the need for elegant solutions that simplify code complexity, ensuring easier maintenance and evolution. Today, I invite you to explore a new approach using TypeScript type aliases, offering a perfect method for creating and restoring entities within the DDD framework. Say goodbye to the confusion of stacking confusing properties in a constructor or creating static factory methods to instantiate your entities.

The Implications of Stacking Properties in a Constructor

When creating an entity, it's common to stack properties in a constructor. This can be problematic, especially when you have many properties. After all, the order of properties matters, and if you forget to pass an argument, TypeScript may not be able to detect the error. Additionally, if you add a new property, depending on its position, you'll need to update all the places where the entity is instantiated. Let's look at an example:

User.ts
1import crypto from "node:crypto";
2
3export class User {
4 constructor(
5 public id?: string = crypto.randomUUID(),
6 public email: string,
7 public password: string,
8 public surname: string,
9 public givenName: string,
10 public middleName?: string
11 ) {}
12}
User.ts
1import crypto from "node:crypto";
2
3export class User {
4 constructor(
5 public id?: string = crypto.randomUUID(),
6 public email: string,
7 public password: string,
8 public surname: string,
9 public givenName: string,
10 public middleName?: string
11 ) {}
12}

When instantiating the User entity, you need to pass all the arguments in the correct order.

1const user = new User(
2 undefined,
3 "johndoe@example.com",
4 "p@$$w0rd",
5 "Doe",
6 "John",
7 "Smith"
8);
1const user = new User(
2 undefined,
3 "johndoe@example.com",
4 "p@$$w0rd",
5 "Doe",
6 "John",
7 "Smith"
8);

Here, besides having a less elegant entity creation, having to pass undefined for id, we also have to pass all arguments in the correct order. If we forget to pass an argument, TypeScript may not be able to detect the error if the next property is optional.

What about Value Objects?

The situation becomes even more complicated when we have value objects. For example, let's suppose we have two value objects Email and Password, which are used in the aggregate root User.

Email.ts
1export class Email {
2 constructor(readonly value: string) {
3 if (!/^\S+@\S+$/.test(value)) {
4 throw new Error("Invalid email");
5 }
6 }
7}
Email.ts
1export class Email {
2 constructor(readonly value: string) {
3 if (!/^\S+@\S+$/.test(value)) {
4 throw new Error("Invalid email");
5 }
6 }
7}
Password.ts
1export class Password {
2 constructor(readonly value: string) {
3 if (value.length < 6) {
4 throw new Error("Password must be at least 6 characters");
5 }
6 }
7
8 // hash and verify methods...
9}
Password.ts
1export class Password {
2 constructor(readonly value: string) {
3 if (value.length < 6) {
4 throw new Error("Password must be at least 6 characters");
5 }
6 }
7
8 // hash and verify methods...
9}

Now, the User entity uses these value objects.

User.ts
1import crypto from "node:crypto";
2import { Email } from "./Email";
3import { Password } from "./Password";
4
5export class User {
6 constructor(
7 public id?: string = crypto.randomUUID(),
8 public email: string | Email,
9 public password: string | Password,
10 public surname: string,
11 public givenName: string,
12 public middleName?: string
13 ) {
14 if (typeof email === "string") {
15 this.email = new Email(email);
16 }
17 if (typeof password === "string") {
18 this.password = new Password(password);
19 }
20 }
21}
22
23const user = new User(
24 undefined,
25 "johndoe@example.com",
26 "p@$$w0rd",
27 "Doe",
28 "John",
29 "Smith"
30);
31
32console.log(user.email.value); // ❌ Property 'value' does not exist on type 'string | Email'.
User.ts
1import crypto from "node:crypto";
2import { Email } from "./Email";
3import { Password } from "./Password";
4
5export class User {
6 constructor(
7 public id?: string = crypto.randomUUID(),
8 public email: string | Email,
9 public password: string | Password,
10 public surname: string,
11 public givenName: string,
12 public middleName?: string
13 ) {
14 if (typeof email === "string") {
15 this.email = new Email(email);
16 }
17 if (typeof password === "string") {
18 this.password = new Password(password);
19 }
20 }
21}
22
23const user = new User(
24 undefined,
25 "johndoe@example.com",
26 "p@$$w0rd",
27 "Doe",
28 "John",
29 "Smith"
30);
31
32console.log(user.email.value); // ❌ Property 'value' does not exist on type 'string | Email'.

Here, we have a problem. TypeScript cannot infer that user.email is of type Email, as the User entity constructor accepts both string and Email. This is a problem because we don't want user.email to be of type string. Additionally, we have to check if email and password are of type string and, if they are, instantiate the value objects Email and Password. This is repetitive code and prone to errors.

The Approach of Static Factory Methods

A common approach to solving these problems is to use static factory methods. This allows us to create methods that instantiate the entity and can have a more descriptive name. Additionally, we can use value objects as arguments only in the private constructor method, avoiding the need to check the type of arguments.

User.ts
1import crypto from "node:crypto";
2import { Email } from "./Email";
3import { Password } from "./Password";
4
5export class User {
6 private constructor(
7 public id: string,
8 public email: Email,
9 public password: Password,
10 public surname: string,
11 public givenName: string,
12 public middleName?: string
13 ) {}
14
15 static create(
16 email: string,
17 password: string,
18 surname: string,
19 givenName: string,
20 middleName?: string
21 ): User {
22 return new User(
23 crypto.randomUUID(),
24 new Email(email),
25 new Password(password),
26 surname,
27 givenName,
28 middleName
29 );
30 }
31
32 static restore(
33 id: string,
34 email: string,
35 password: string,
36 surname: string,
37 givenName: string,
38 middleName?: string
39 ): User {
40 return new User(
41 id,
42 new Email(email),
43 new Password(password),
44 surname,
45 givenName,
46 middleName
47 );
48 }
49}
50
51const user = User.create(
52 "johndoe@example.com",
53 "p@$$w0rd",
54 "Doe",
55 "John",
56 "Smith"
57);
58
59console.log(user.email.value); // ✅ johndoe@example.com
User.ts
1import crypto from "node:crypto";
2import { Email } from "./Email";
3import { Password } from "./Password";
4
5export class User {
6 private constructor(
7 public id: string,
8 public email: Email,
9 public password: Password,
10 public surname: string,
11 public givenName: string,
12 public middleName?: string
13 ) {}
14
15 static create(
16 email: string,
17 password: string,
18 surname: string,
19 givenName: string,
20 middleName?: string
21 ): User {
22 return new User(
23 crypto.randomUUID(),
24 new Email(email),
25 new Password(password),
26 surname,
27 givenName,
28 middleName
29 );
30 }
31
32 static restore(
33 id: string,
34 email: string,
35 password: string,
36 surname: string,
37 givenName: string,
38 middleName?: string
39 ): User {
40 return new User(
41 id,
42 new Email(email),
43 new Password(password),
44 surname,
45 givenName,
46 middleName
47 );
48 }
49}
50
51const user = User.create(
52 "johndoe@example.com",
53 "p@$$w0rd",
54 "Doe",
55 "John",
56 "Smith"
57);
58
59console.log(user.email.value); // ✅ johndoe@example.com

Static factory methods solve the problem. However, this approach also has its disadvantages. For example, if you add a new property, you'll have to update all static factory methods. Additionally, you'll have to create a static factory method for each combination of properties you want to allow.

The Approach of Type Aliases

A more elegant approach to solving these problems is to use type aliases. This allows us to create an object that represents all the properties of the entity and place it directly in the constructor method. This object can be typed using a ClassProps<T, U> type alias (we'll see how to create this alias later) that infers all the properties of the aggregate root, where T is the entity and U is an optional typing representing the properties of the entity that can be replaced. This replacement is necessary when we have value objects and want the constructor method to accept primitive types to later instantiate the value objects.

User.ts
1import crypto from "node:crypto";
2import { Email } from "./Email";
3import { Password } from "./Password";
4
5export type UserProps = ClassProps<User, { email: string; password: string }>; // ✨ Here is the magic!
6
7export class User {
8 id?: string = crypto.randomUUID();
9 email: Email;
10 password: Password;
11 surname!: string;
12 givenName!: string;
13 middleName?: string;
14
15 constructor(props: UserProps) {
16 Object.assign(this, props); // ✨ Assign all properties to the instance at once!
17 this.email = new Email(props.email);
18 this.password = new Password(props.password);
19 }
20}
21
22const user = new User({
23 email: "johndoe@example.com",
24 password: "p@$$w0rd",
25 surname: "Doe",
26 givenName: "John",
27 middleName: "Smith",
28});
29
30console.log(user.email.value); // ✅ johndoe@example.com
User.ts
1import crypto from "node:crypto";
2import { Email } from "./Email";
3import { Password } from "./Password";
4
5export type UserProps = ClassProps<User, { email: string; password: string }>; // ✨ Here is the magic!
6
7export class User {
8 id?: string = crypto.randomUUID();
9 email: Email;
10 password: Password;
11 surname!: string;
12 givenName!: string;
13 middleName?: string;
14
15 constructor(props: UserProps) {
16 Object.assign(this, props); // ✨ Assign all properties to the instance at once!
17 this.email = new Email(props.email);
18 this.password = new Password(props.password);
19 }
20}
21
22const user = new User({
23 email: "johndoe@example.com",
24 password: "p@$$w0rd",
25 surname: "Doe",
26 givenName: "John",
27 middleName: "Smith",
28});
29
30console.log(user.email.value); // ✅ johndoe@example.com

See how we achieved leaner code. We started using a props object that represents all the properties of the entity and passed it directly to the constructor method. Additionally, we used Object.assign to assign all properties to the object at once. This is a safer approach, as it avoids the need to pass all arguments in the correct order and also avoids the need to check the type of arguments.

1// `UserProps` using `ClassProps` type alias with `U` to replace specific properties will look like this:
2type UserProps = {
3 id?: string;
4 email: string;
5 password: string;
6 surname: string;
7 givenName: string;
8 middleName?: string;
9};
1// `UserProps` using `ClassProps` type alias with `U` to replace specific properties will look like this:
2type UserProps = {
3 id?: string;
4 email: string;
5 password: string;
6 surname: string;
7 givenName: string;
8 middleName?: string;
9};

You may have noticed that we need to use the non-null assertion operator (!) for the surname and givenName properties. This is necessary because when using Object.assign, TypeScript cannot infer that these properties have been assigned to the object. To resolve this, we have 3 options:

  • Use ! for all properties that are mandatory;
  • Manually set the value in the constructor (e.g., this.surname = props.surname);
  • Or disable (not recommended!) null and undefined checks in tsconfig.json: strictNullChecks: false.

strictNullChecks is a TypeScript setting that promotes code safety and robustness by requiring developers to explicitly handle values that can be null or undefined. By making these situations more explicit, TypeScript helps prevent runtime errors, improves code readability and maintainability, and facilitates interoperability with existing JavaScript code. This approach promotes a more defensive programming practice, resulting in more reliable code less prone to errors.

Creating the ClassProps<T, U> Type Alias

The magic of our example happens in the definition of UserProps through the ClassProps alias. It encapsulates the writable properties of the User class and allows for specific sets of properties to be defined for different entity creation scenarios.

ClassProps is an abstraction that leverages TypeScript's conditional inference capability to extract writable properties from a class. However, this is not a native alias and needs to be declared in your project.

index.d.ts
1declare global {
2 type ExcludeMethods<T> = Pick<
3 T,
4 {
5 [K in keyof T]: T[K] extends Function ? never : K;
6 }[keyof T]
7 >;
8 type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X
9 ? 1
10 : 2) extends <T>() => T extends Y ? 1 : 2
11 ? A
12 : B;
13 type WritableKeys<T> = {
14 [P in keyof T]-?: IfEquals<
15 { [Q in P]: T[P] },
16 { -readonly [Q in P]: T[P] },
17 P
18 >;
19 }[keyof T];
20 type ExtractClassProps<T> = ExcludeMethods<Pick<T, WritableKeys<T>>>;
21 type ClassProps<T, U = ExtractClassProps<T>> = Omit<
22 ExtractClassProps<T>,
23 keyof U
24 > &
25 U;
26}
27
28export {};
index.d.ts
1declare global {
2 type ExcludeMethods<T> = Pick<
3 T,
4 {
5 [K in keyof T]: T[K] extends Function ? never : K;
6 }[keyof T]
7 >;
8 type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X
9 ? 1
10 : 2) extends <T>() => T extends Y ? 1 : 2
11 ? A
12 : B;
13 type WritableKeys<T> = {
14 [P in keyof T]-?: IfEquals<
15 { [Q in P]: T[P] },
16 { -readonly [Q in P]: T[P] },
17 P
18 >;
19 }[keyof T];
20 type ExtractClassProps<T> = ExcludeMethods<Pick<T, WritableKeys<T>>>;
21 type ClassProps<T, U = ExtractClassProps<T>> = Omit<
22 ExtractClassProps<T>,
23 keyof U
24 > &
25 U;
26}
27
28export {};

This TypeScript declaration file (index.d.ts) defines several type aliases meant to aid in type manipulation and inference. Let's break down what each alias does and why declare global and export {} are used:

  1. declare global: This is used to declare global scope augmentation. It allows you to add declarations to the global scope from within a module. In this context, it's used to ensure that the type aliases declared within this file are available globally throughout your TypeScript project.
  2. export {}: This is a TypeScript syntax used to ensure that the file is treated as a module. Even if the file doesn't export anything explicitly, it's still considered a module. This helps prevent potential conflicts with other modules and ensures proper encapsulation.

Now, let's examine each type alias:

  • ExcludeMethods<T>: This alias is used to exclude any methods from a type T. It utilizes TypeScript's mapped types (Pick and key mapping) along with conditional types to achieve this. For each key K in T, it checks if the type of T[K] is a function. If it is, it excludes that key from the resulting type, otherwise includes it.
  • IfEquals<X, Y, A = X, B = never>: This alias is a conditional type that checks whether two types X and Y are equal. If they are equal, it evaluates to type A, otherwise to type B. It's a complex type leveraging conditional type inference.
  • WritableKeys<T>: This alias calculates the keys of a type T that are writable, i.e., not marked as readonly. It uses a mapped type to iterate through all keys of T and uses the IfEquals type to determine if the property is writable or not.
  • ExtractClassProps<T>: This alias extracts all properties from a type T that are not methods and are writable. It combines ExcludeMethods and WritableKeys to achieve this.
  • ClassProps<T, U = ExtractClassProps<T>>: This alias takes a type T and an optional type U (defaults to ExtractClassProps<T>), and returns a type that includes all properties from T that are not methods and are writable but excludes properties defined in U. It essentially provides a way to extend or modify the properties of a class type.

Overall, these type aliases are useful for working with TypeScript's type system to manipulate and extract properties from types in a generic and reusable manner. The combination of declare global and export {} ensures that these type aliases are globally available while maintaining proper encapsulation and module behavior.

Benefits of the Approach

The type aliases approach offers several benefits compared to other approaches, such as stacking properties in a constructor or using static factory methods:

  • Enhanced Readability: Clearer constructor with focused property assignments.
  • Greater Maintainability: Easier to understand and modify entity creation logic.
  • Type Safety: Type aliases ensure type correctness.
  • Flexibility: Customizable construction using different sets of properties.
  • Alignment with TypeScript: Leveraging TypeScript's strengths for clear type definitions.
  • No Code Duplication: The type alias definition for ClassProps allows writable properties to be extracted generically, without the need to repeat the logic for each class.

Conclusion

In the code, we've incorporated a set of type aliases in index.d.ts to simplify TypeScript type definitions. These aliases serve to exclude methods, extract writable keys, and provide utility functions for handling class properties. The U in ClassProps<T, U> allows for the replacement of specific properties of the class, offering flexibility in customizing entity instantiation.

In this exploration of simplifying Domain-Driven Design (DDD) with TypeScript type aliases, we've addressed the challenges posed by traditional static factory methods. By adopting a cleaner constructor pattern and leveraging powerful type aliases, we've enhanced the readability and maintainability of our code.

The alternative approach showcased in the User.ts file demonstrates how type aliases, such as ClassProps, can replace the need for static factory methods. This not only streamlines the code but also aligns with TypeScript's strengths in expressing complex domain models.

By embracing these techniques, developers can build more intuitive and expressive codebases, reducing the cognitive load associated with intricate DDD implementations. TypeScript's type system, coupled with thoughtful design choices, empowers developers to create robust and readable domain models, laying the foundation for scalable and maintainable applications.

Additional Considerations

By adopting type aliases along with the constructor pattern, you can simplify the creation and restoration of DDD entities, resulting in more readable, maintainable, and type-safe code. This approach aligns well with TypeScript features and promotes better organization and understanding of the code.

  • Consider incorporating error handling and validation in constructors or separate methods for greater robustness.
  • Adapt the approach to your specific domain model and development preferences, striking a balance between clarity and complexity.

By embracing these refinements and carefully considering your domain requirements, you can safely employ type aliases to streamline DDD entity creation and enhance the overall quality of your codebase.


The art of creating

programming
programming
programming
programming
programming
programming