🤯 ¡No sabia que TypeScript podia hacer esto!
Prerrequisitos
Asumo que conoces como funcionan los constructores en JavaScript y los Genéricos en TypeScript. Además de estar familiarizado con conceptos como la herencia y el polimorfismo.
El problema
No es que no supiera que TypeScript no pudiera hacer esto, lo que no sabÃa es como hacerlo.
No te quiero entretener mucho, pero para que lo entiendas tengo que darte un poco de contexto, solo lee y déjate llevar.
¿Alguna vez te has encontrado con una clase que te permite instanciar objetos usando métodos estáticos? Te pongo un ejemplo de código que tengo en producción. He quitado la documentación para hacerlo más legible (con la documentación son 80 lÃneas).
export class UUIDValueObject extends NonEmptyStringValueObject {
constructor(value: string) {
super(value);
if (!this.isValidUUID(value)) {
throw new InvalidArgumentError('Invalid uuid it should be a uuid v7');
}
}
public static random(): UUIDValueObject {
return new UUIDValueObject(uuidv7());
}
protected isValidUUID(value: string): boolean {
return uuidValidate(value) && uuidVersion(value) === 7;
}
}
Esta clase es un value object que envuelve un UUID v7. Si te fijas tiene un método estático llamado random
.
Este devuelve un uuid aleatorio, nada nuevo hasta el momento.
Esta clase es una clase base de la que heredan otras clases como UserId
. El problema con el que me encontré es que si no
sobreescribÃa el método random
lo que me devolvÃa era una instancia de la clase padre, en este caso UUIDValueObject
, en
vez de la clase que contenÃa el método.
Es decir, por cada clase que heredara de UUIDValueObject
(que no son pocas) tenÃa que sobreescribir un método
estático que se comportaba exactamente igual, sin que cambiara en absoluto el comportamiento del método.
La solución
De alguna manera tenÃa que decirle a TypeScript que el método random devuelve una instancia de la clase que lo ejecuta, en vez de decirle que devuelve una instancia de UUIDValueObject. Aquà la solución sencilla pero altamente efectiva.
export class UUIDValueObject extends NonEmptyStringValueObject {
constructor(value: string) {
super(value);
if (!this.isValidUUID(value)) {
throw new InvalidArgumentError('Invalid uuid it should be a uuid v7');
}
}
public static random<T extends UUIDValueObject>(
this: new (value: string) => T,
): T {
return new this(uuidv7());
}
protected isValidUUID(value: string): boolean {
return uuidValidate(value) && uuidVersion(value) === 7;
}
}
Como dijo Jack el destripador, vamos por partes. Lo primero que vamos a ver es que devuelve este método.
Este método devuelve un valor genérico de tipo T
. Este tipo tiene una restricción i es que tiene que ser
un UUIDValueObject
o derivado.
El método
// Esto es lo que nos intersa por el momento
public static random<T extends UUIDValueObject>(
//
this: new (value: string) => T,
): T {
return new this(uuidv7());
}
Entonces en el caso de tener una clase como UserId
export class UserId extends UUIDValueObject {}
El método random serÃa capaz de devolver UserId
, ya que este cumple con la restricción de T
🤔 ¿Pero cómo puede ser que
UserId
al heredar deUUIDValueObject
random
nos devuelva una instancia deUserId
?
Esto se debe a que estamos usando el constructor implÃcito this
. En un contexto no estático, en JavaScript, this
hace referencia la instancia actual de nuestro objeto.
🤓 En realidad es un poco más complejo debido a que JavaScript por debajo no usa una programación orientiada a objetos basada en clases si no que funciona por prototipos. Sin embargo, para simplificar la lección vamos a acotar el contexto.
Sin embargo, en un contexto estático, this
hace referencia al constructor de la clase. Ojo esto en tiempo de ejecución.
Por lo que al momento de ejecutarlo random desde UserId
. El objeto que nos devuelva el constructor this
,
será una instancia de UserId
.
El valor de retorno
Pero como estamos usando TypeScript, hay que indicarle al compilador cuál es la cabecera de this
, es decir,
que valores acepta y cuáles devuelve. Con esto TypeScript será capaz de entender qué devuelve random
.
public static random<T extends UUIDValueObject>(
// Estableciendo el tipo de this. Fijate que al poner la palabra new estamos indicando que es un constructor
this: new (value: string) => T,
//
): T {
return new this(uuidv7());
}
Nos falta un último detalle
¿Porque cuando usamos random no hay que enviarle ningun parametro?
Esto se debe a que this
es una palabra que tiene un valor implÃcito. En este caso entiendo que al ya tener un valor por defecto,
es equivalente a cuando tenemos valores opcionales en nuestros métodos, no es necesario darles ningún valor porque ya lo tienen.
Lecturas adicionales
Te recomiendo encarecidamente que leas más sobre como funciona la POO en JS. Por lo que aquà te dejo cuatro artÃculos que te pueden ayudar.