TypeScript

Como crear un value object en TypeScript

Como crear un value object en TypeScript
En: TypeScript, DDD

Cuando utilizamos arquitecturas orientadas al dominio, es habitual utilizar conceptos de Domain-Driven Design, los Value Objects son uno de los conceptos básicos de DDD.

Suelo utilizar Value Objects en los proyectos que realizo, por los beneficios que me aportan y como llevo un tiempo con proyectos TypeScript he tenido que enfrentarme a la situación de como hacerlo en este lenguaje.

Qué es un Value Object

Value object es una solución para evitar la obsesión habitual por modelar las entidades de dominio utilizando tipos primitivos.

Mediante estas clases conseguimos beneficios como reutilización de validación invariantes, son más seguros al compilar, al estar tipado cada property de una entidad con un tipo correspondiente, en lugar de todo como strings por poner un ejemplo.

Este artículo no pretende ser una introducción al concepto de Value Object, sino un ejemplo de implementación en TypeScript.

Si no tienes claro el concepto te sugiero que te mires este artículo que escribí hace tiempo:
No tengas Primitive Obsession crea un Value Object
.

Creando value object en TypeScript

Vamos a ver como podemos crearnos un Values Object en typescript.

Creado la clase base

La característica principal de los value objects es la característica de igualdad basado en el valor de sus propiedades.

En cualquier lenguaje donde definimos value objects esta característica es común para todos los value objects y si utilizamos el parseo a JSON, dado lo sencillo que es el JavaScript, podríamos tener una clase base asi:

interface ValueObjectProps {
    [index: string]: any;
}

export abstract class ValueObject<T extends ValueObjectProps> {
    constructor(protected props: T) {
        const baseProps: any = {
            ...props,
        };

        this.props = baseProps;
    }

    public equals(vo?: ValueObject<T>): boolean {
        if (vo === null || vo === undefined) {
            return false;
        }
        if (vo.props === undefined) {
            return false;
        }
        return JSON.stringify(this.props) === JSON.stringify(vo.props);
    }
}

Según las características de cada lenguaje puede variar como definimos conceptos agnostícos como value objects.

Creando un value object

Una vez tenemos definida nuestra clase base para los value objects, vamos a crearnos un value object email:

export interface UserEmailProps {
    value: string;
}

const EMAIL_PATTERN = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

export class Email extends ValueObject<UserEmailProps> {
    public readonly value: string;

    private constructor(props: UserEmailProps) {
        super(props);

        this.value = props.value;
    }

    public static create(email: string): Either<ValidationErrorKey[], Email> {
        const requiredError = validateRequired(email);
        const regexpErrors = validateRegexp(email, EMAIL_PATTERN);

        if (requiredError.length > 0) {
            return Either.left(requiredError);
        } else if (regexpErrors.length > 0) {
            return Either.left(regexpErrors);
        } else {
            return Either.right(new Email({ value: this.format(email) }));
        }
    }

    private static format(email: string): string {
        return email.trim().toLowerCase();
    }
}

Me gusta utilizar factory methods ya que puedo gestionar los errores en la creación sin lanzar diferentes Error y su correspondientes try catch en capas superiores.

Me siento más comodo utilizando conceptos de programación funcional como Either para la gestión de errores.

Al utilizar Either se expone de una forma más clara los posibles errores al llamador del factory method.

Si encima estos errores se representan como un union de typescript:

export type ValidationErrorKey =
    | "field_cannot_be_blank"
    | "invalid_field"
    | "field_number_must_be_greater_than_0";

Podemos utilizar switch de forma exustiva si queremos realizar diferentes acciones según el error.

De forma que cuando añado un nuevo error si este no es tratado, tanto el ide como el linter me avisarán en cada switch.

Para más información sobre Either en typescript te dejo este artículo:
Either en Typescript.

Otra de la característica de los value objects es que deben ser inmutables, por lo tanto no se puede modificar sus propiedades.

Si necesitamos modificar un value object, en relidad utilizaremos el factory method para crear uno nuevo.

Creando value object compuestos

Una vez definida la base de como crear un value object podemos crearnos value objects que se componen de otros value objects.

En esta ocasión vamos a crear un value object credentials que se compone de un email y un password.

export interface CredentialsData {
    email: Email;
    password: Password;
}

export class Credentials extends ValueObject<CredentialsData> implements CredentialsData {
    public readonly email: Email;
    public readonly password: Password;

    private constructor(data: CredentialsData) {
        super(data);
        this.email = data.email;
        this.password = data.password;
    }

    public static create(
        data: { email: string; password: string; }
    ): Either<ValidationError<Credentials>[], Credentials> {
        const emailResult = Email.create(data.email);
        const passwordResult = Password.create(data.password);

        const errors: ValidationError<Credentials>[] = [
            {
                property: "email" as const,
                errors: emailResult.fold(errors => errors,() => []),
                value: data.email,
            },
            {
                property: "password" as const,
                errors: passwordResult.fold(errors => errors,() => []),
                value: data.password,
            },
        ]
            .map(error => ({ ...error, type: Credentials.name }))
            .filter(validation => validation.errors.length > 0);

        if (errors.length === 0) {
            return Either.right(
                new Credentials({ email: emailResult.get(), password: passwordResult.get() })
            );
        } else {
            return Either.left(errors);
        }
    }
}

Creando test del value object

Vamos a ver como podríamos realizar test para verificar la logica del value object compuesto:

const validEmailInput = "info@xurxodev.com";
const validPasswordInput = "39893898";
const invalidEmailInput = "xurxodev.com";

describe("Credentials", () => {
    it("should return success reponse if email and password are valid", () => {
        const result = Credentials.create({ email: validEmailInput, password: validPasswordInput });

        result.fold(
            error => fail(error),
            credentials => {
                expect(credentials.email.value).toEqual(validEmailInput);
                expect(credentials.password.value).toEqual(validPasswordInput);
            }
        );
    });
    it("should be equals two credentials with same values", () => {
        const credentials1 = Credentials.create({ email: validEmailInput, password: validPasswordInput }).get();
        const credentials2 = Credentials.create({ email: validEmailInput, password: validPasswordInput }).get();

        expect(credentials1).toEqual(credentials2);
        expect(credentials1.equals(credentials2)).toBe(true);
    });
    it("should return Email cannot be blank error if email is empty", () => {
        const result = Credentials.create({ email: "", password: validPasswordInput });

        result.fold(
            errors => {
                expect(errors.find(error => error.property === "email").errors[0]).toBe(
                    "field_cannot_be_blank"
                );
            },
            () => fail("should be fail")
        );
    });

    it("should return Email cannot be blan error if email is empty", () => {
        const result = Credentials.create({ email: validEmailInput, password: "" });

        result.fold(
            errors => {
                expect(errors.find(error => error.property === "password").errors[0]).toBe(
                    "field_cannot_be_blank"
                );
            },
            () => fail("should be fail")
        );
    });

    it("should return Invalid email error if email is invalid", () => {
        const result = Credentials.create({
            email: invalidEmailInput,
            password: validPasswordInput,
        });

        result.fold(
            errors => {
                expect(errors.find(error => error.property === "email").errors[0]).toBe(
                    "invalid_field"
                );
            },
            () => fail("should be fail")
        );
    });
});

Artículos relacionados

Conclusiones

En este artículo hemos visto como podemos crearnos Value Objects en Typescript.

Y también como podemos gestionar los errores mediante el uso de Either, que es un tipo algebraico de tipo sum utilizado en programación funcional.

Más de XurxoDev
¡Genial! Te has inscrito con éxito.
Bienvenido de nuevo! Has iniciado sesión correctamente.
Te has suscrito correctamente a XurxoDev.
Su enlace ha caducado.
¡Éxito! Comprueba en tu correo electrónico el enlace mágico para iniciar sesión.
Éxito! Su información de facturación ha sido actualizada.
Su facturación no se actualizó.