typescript - El compilador elimina la restricción del tipo de contexto 'this'
Recientemente construí una clase Builder, y me di cuenta de que algunos de los campos deberían ser obligatorios, antes de cerrar la cadena del constructor con el execute() final.
Pensé que esta verificación podría hacerse de manera estática en tiempo de compilación, y como tal se me ocurrió esta solución (simplificada por el bien del ejemplo):
interface abc {
a: string; // required
b: string; // required
c: string; // optional
}
class Builder<T = void> {
protected state: T = {} as any; // necessary ugly cast
public a(aa: string): Builder<T & Pick<abc, 'a'>> {
Object.defineProperty(this.state, 'a', {
value: aa,
writable: true,
});
return this as unknown as Builder<T & Pick<abc, 'a'>>;
}
public b(bb: string): Builder<T & Pick<abc, 'b'>> {
Object.defineProperty(this.state, 'b', {
value: bb,
writable: true,
});
return this as unknown as Builder<T & Pick<abc, 'b'>>;
}
public c(cc: string): Builder<T & Pick<abc, 'c'>> {
Object.defineProperty(this.state, 'c', {
value: cc,
writable: true,
});
return this as unknown as Builder<T & Pick<abc, 'c'>>;
}
public execute(this: Builder<Pick<abc, 'a' | 'b'>>) {
return this.state;
}
}
Ahora, cuando hago esto:
new Builder().a('foo').c('bar').execute();
Obtengo el error que deseo, que es: El contexto 'this' del tipo ... no se puede asignar al 'this' del método del tipo ..., que es exactamente lo que quería.
Sin embargo, cuando compilo este código TypeScript e importo los archivos de compilación en otro proyecto, ya no obtengo este error.
Esta es la definición de tipo compilada:
interface abc {
a: string;
b: string;
c: string;
}
declare class Builder<T> {
protected state: T;
a(aa: string): Builder<T & Pick<abc, 'a'>>;
b(bb: string): Builder<T & Pick<abc, 'b'>>;
c(cc: string): Builder<T & Pick<abc, 'c'>>;
execute(this: Builder<Pick<abc, 'a' | 'b'>>): Pick<abc, "a" | "b">;
}
Y aquí está mi tsconfig.json (he excluido las rutas del proyecto para mantenerme relevante al tema):
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"strict": true,
"importHelpers": true,
"declaration": true,
"declarationDir": "dist/esm/types/",
"moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"noImplicitAny": true,
"lib": [
"ESNext",
"webworker",
"DOM"
]
}
}
Para colmo de males, IntelliSense me muestra estos tipos al pasar el cursor sobre las variables (aunque sé que esto no está necesariamente ligado al compilador):
new Builder().execute(); // should be an error
// with the type...
Builder<void>.send(this: Builder<Pick<abc, "a" | "b">>): ...
¿Es este un comportamiento intencionado? ¿Necesito cambiar tsconfig.json? ¿Estoy haciendo algo mal? Si no compilo la clase, entonces funciona perfectamente.
Respuesta #1
Debes tener en cuenta que esta línea es INSEGURA: protected state: T={} as any;.
Por favor, considera este enfoque:
class Builder {
state = {}
public a<Param extends string>(aa: Param) {
Object.assign(this.state, { a: aa })
return this as this & { state: { a: Param } }
}
public b<Param extends string>(bb: Param) {
Object.assign(this.state, { b: bb })
return this as this & { state: { b: Param } }
}
public c<Param extends string>(cc: Param) {
Object.assign(this.state, { c: cc })
return this as this & { state: { c: Param } }
}
public execute() {
return this.state;
}
}
new Builder().a('foo').state.a // foo
new Builder().a('foo').b('bar').state.b // bar
Parque infantil
Respuesta #2
Con la ayuda del capitán Yossarian, me di cuenta de qué salió mal y por qué encontré el problema.
El problema con mi enfoque
Después de dos años jugando con tipos, tenía una separación incorrecta de tipo y entidad en mi cabeza. En realidad, están mucho más entrelazados de manera ordenada dentro de TypeScript. Aunque usar genéricos es un gran enfoque para tipos condicionales-recursivos-mapeados, este caso de uso era mucho más sencillo, como lo demostró captain-yossarian.
No necesitaba encapsular un tipo dentro de un genérico solo para modificarlo progresivamente en cada llamada subsiguiente. this es en sí mismo un tipo, y resultó muy útil aquí.
Después de todo, una clase es solo otro tipo. Así que no necesitaba alguna interfaz externa para construir mientras construía la entidad. Podría haber usado simplemente la propia entidad para representar el tipo 'progresivo'.
(Sin embargo, si tienes una interfaz externa de este tipo, Pick
La solución, después de aplicar lo que se sugirió
La solución es realmente similar a lo que sugirió el capitán Yossarian. Se agregaron unas líneas para terminar la solución a mi pregunta.
Usé otra interfaz porque es la única implementación que realmente probé. También es un poco más fácil de comprender a primera vista.
interface ABC {
a: string;
b: string;
c: string;
}
class Builder {
state = {};
public a(aa: string) {
Object.assign(this.state, { a: aa });
return this as this & { state: Pick<ABC, 'a'> };
}
public b(bb: Param) {
Object.assign(this.state, { b: bb });
return this as this & { state: Pick<ABC, 'b'> };
}
public c(cc: Param) {
Object.assign(this.state, { c: cc });
return this as this & { state: Pick<ABC, 'c'> };
}
public execute(this: this & { state: Pick<ABC, 'a' | 'b'> }) {
return this.state;
}
}
La buena parte es que esto también funciona después de que ha sido compilado. Ocurrió una situación general de 'victoria', pero...
La desventaja
No puedes hacer que el estado private, o la intersección de tipos producirá never (porque tanto this como tu interfaz proporcionan un valor para state, y uno es privado). El enfoque genérico, aunque más torpe, funcionaba bien con campos privados. En este sentido, podríamos simplemente usar un estado para marcar el progreso, pero entonces ¿cuál sería el sentido de la verificación de tipos, si no estamos tipeando los estados directamente?
Aunque private no funcionará, ya que el resultado de la intersección de tipos se afirmará como never, protected funciona perfectamente. Sin embargo, no es lo mismo, pero si planeas ocultarlo generalmente del espacio público, esto servirá.
Qué pasó
Eso realmente no puedo decirlo. Estoy seguro de que el compilador no debería producir resultados que funcionen de manera diferente a cuando el código no está compilado. No estoy seguro de qué se perdió en el camino, pero tengo curiosidad por saber más sobre esto. Como con muchas otras cosas, tal vez esto se arregle.
Conclusión
Los tipos deberían/podrían usarse principalmente para modelar los datos. Este es su uso principal, y es una herramienta poderosa cuando se utiliza correctamente. Creo que usar tipos avanzados para operaciones interesantes es útil, pero a veces (como fue en este caso) hacer todo el mecanismo de esa manera fue excesivo.
