typescript - Realizar un seguimiento del estado en la instancia de clase
(7)
Quiero crear una clase que tenga algún estado interno (podría estar cargando, error o éxito)
Crea tus posibles tipos de estado
type State<T> = ErrorState | SuccessState<T> | LoadingState;
type ErrorState = { status: "error"; error: unknown };
type SuccessState<T> = { status: "success"; data: T };
type LoadingState = { status: "loading" };
También quiero tener algunos métodos en la clase que puedan verificar el estado de esta clase.
Crear clase Foo que contiene el estado
Supongo que aquí, desea invocar algún tipo de método de protección de tipo público
isSuccess
,
isLoading
,
isError
que verifica el estado de su instancia de clase y puede reducir el tipo de estado en la rama verdadera mediante el uso de if / else.
Puede hacerlo creando guardias de tipo que devuelvan un predicado
polimórfico de este tipo
que contenga su estado reducido.
// T is the possible data type of success state
class Foo<T = unknown> {
constructor(public readonly currentState: State<T>) {}
isLoading(): this is { readonly currentState: LoadingState } {
return this.currentState.status === "loading";
}
isSuccess(): this is { readonly currentState: SuccessState<T> } {
return this.currentState.status === "success";
}
isError(): this is { readonly currentState: ErrorState } {
return this.currentState.status === "error";
}
}
Probémoslo:
const instance = new Foo({ status: "success", data: 42 });
if (instance.isSuccess()) {
// works, (property) data: number
instance.currentState.data;
}
Limitaciones
Aquí viene el trato: ¡solo puede hacer eso cuando haya declarado a su miembro de clase
currentState
con
un
modificador
público
(limitación de TypeScript)!
Si lo ha declarado
privado
, no puede utilizar dicho tipo de protección para este propósito.
Una alternativa sería devolver un estado opcional en su lugar:
class Foo<T = unknown> {
...
getSuccess(): SuccessState<T> | null {
return this.currentState.status === "success" ? this.currentState : null;
}
...
}
// test it
const instance = new Foo({ status: "success", data: 42 });
const state = instance.getSuccess()
if (state !== null) {
// works, (property) data: number
state.data
}
Nota al
foo.hasValue()
sobre el error de su rama con
foo.hasValue()
:
const foo = new Foo();
if (foo.hasValue()) {
// OK
foo.value.toString();
} else {
(foo.value); // TypeScript does NOT understand that this can only be null
}
TypeScript no infiere
foo.value
a null aquí, porque
foo.hasValue()
es un Type Guard personalizado que simplemente reduce su tipo a
{ value: string }
con verdadera condición.
Si la condición es falsa, se asume nuevamente el tipo de
value
predeterminado (
string | null
).
El protector de tipo personalizado cancela la lógica de ramificación normal de TypeScript.
Puede cambiar eso simplemente omitiéndolo:
if (foo.value !== null) {
// OK
foo.value.toString();
} else {
(foo.value); // (property) Foo.value: null
}
Si verifica su estado desde una instancia de clase interna
class Foo<T = unknown> {
...
// Define private custom type guard. We cannot use a polymorphic
// this type on private attribute, so we pass in the state directly.
private _isSuccess(state: State<T>): state is SuccessState<T> {
return state.status === "success";
}
public doSomething() {
// use type guard
if (this._isSuccess(this.currentState)) {
//...
}
// or inline it directly
if (this.currentState.status === "success") {
this.currentState.data;
//...
}
}
}
Quiero crear una clase que tenga algún estado interno (podría estar cargando, error o éxito). También quiero tener algunos métodos en la clase que puedan verificar el estado de esta clase.
API ideal:
function f(x: LoadingError<number>) {
if (x.isLoading()) {
} else if (x.isError()) {
} else {
(x.data); // TypeScript knows x.data is of type `number`
}
}
Lo principal con lo que estoy luchando es crear los métodos
isLoading
e
isError
para que TypeScript pueda entenderlos.
Traté de escribir algunos protectores de tipo definidos por el usuario en la estructura de clase real ("
" this is { ... }
"):
class Foo {
public value: string | null;
public hasValue(): this is { value: string } {
return this.value !== null;
}
}
const foo = new Foo();
if (foo.hasValue()) {
// OK
foo.value.toString();
} else {
(foo.value); // TypeScript does NOT understand that this can only be null
}
Sin embargo, eso no funciona ya que TypeScript "se olvida" sobre el estado de la instancia de clase en la cláusula
else
.
Uno de mis requisitos más estrictos es usar una
clase
para esto, ya que no quiero tener los
isLoading(instance)
o
isError(instance)
, sino
instance.isLoading()
e
instance.isError()
.
Me gusta usar " Uniones discriminadas " (o "Uniones etiquetadas"). Algo como esto:
class LoadingFoo {
status: ''loading'';
}
class ErrorFoo {
status: ''error'';
error: any;
}
class SuccessFoo<T> {
status: ''success'';
value: T | undefined;
}
type Foo<T> = LoadingFoo | ErrorFoo | SuccessFoo<T>;
let bar: Foo<number>;
if (bar.status === ''success'') {
bar.value; // OK
bar.error; // error
} else if (bar.status === ''error'') {
bar.value; // error
bar.error; // OK
} else {
bar.value; // error
bar.error; // error
}
Puedes verlo en acción, en esta demostración en vivo .
Muchas soluciones a esto, como podemos ver en las otras respuestas.
Al leer la pregunta, sus requisitos son:
- Use una clase para representar una unión de estados
- Use un miembro de la clase para reducir a un estado
- Haga que el estrechamiento funcione correctamente en ambas ramas.
El gran problema es 3. El estrechamiento funcionará en la rama verdadera al cruzarse con el tipo afirmado por el protector de tipo.
La rama else funciona al excluir el tipo afirmado del tipo variable.
Esto funciona muy bien si el tipo sería una unión y el compilador podría excluir todos los componentes coincidentes de la unión, pero aquí tenemos una clase, por lo que no hay ningún componente que excluir y nos quedamos con el tipo
Foo
original.
La solución más simple de la OMI sería desacoplar el tipo de instancia de la clase de la clase real. Podemos escribir un constructor para devolver una unión con la unión de estados apropiada. Esto permitirá que el mecanismo de exclusión funcione como se espera en la rama else:
class _Foo {
public value: string | null = null;
public hasValue(): this is { value: string } {
return this.value !== null;
}
}
const Foo : new () => _Foo &
({ value: string } | { value: null })
= _Foo as any;
const foo = new Foo();
if (foo.hasValue()) {
// OK
foo.value.toString();
} else {
(foo.value); // TypeScript does NOT understand that this can only be null
}
Podemos mezclar varios estados:
class _Foo {
public value: string | null = null;
public error: string | null = null;
public progress: string | null = null
public isError(): this is { error: string } { // we just need to specify enough to select the desired state, only one state below has error: string
return this.error !== null;
}
public isLoading(): this is { progress: string } { // we just need to specify enough to select the desired state, only one state below has progress: string
return this.value === null && this.progress !== null;
}
}
const Foo: new () => _Foo & (
| { value: string, error: null, progress: null } // not loading anymore
| { value: null, error: null, progress: string } // loading
| { value: null, error: string, progress: null})
= _Foo as any;
const foo = new Foo();
if (foo.isError()) {
// we got an error
foo.progress // null
foo.value // null
foo.error.big() // string
} else if (foo.isLoading()) {
// Still loading
foo.progress // string
foo.value // null
foo.error // null
} else {
// no error, not loading we have a value
foo.value.big() // string
foo.error // null
foo.progress // null
}
La única limitación es que dentro de la clase los guardias no funcionarán.
Para su información, si tiene guardias de tipo que excluyen todos los estados, incluso puede hacer el truco de
assertNever
para asegurarse de que se hayan manejado todos los estados:
play
No estoy seguro sobre el caso de uso que está describiendo (considere la posibilidad de formular la pregunta nuevamente para obtener más aclaraciones quizás), pero si está tratando de trabajar con un estado, tal vez las enums serían mejores para usted, de esa manera puede evitar cualquier verificación nula y siempre mantenga un estado de conjunto válido válido.
Aquí hay un ejemplo que hice basado en lo que creo que es su funcionalidad deseada.
- tipings:
enum FoobarStatus {
loading = ''loading'',
error = ''error'',
success = ''success''
}
interface IFoobar {
status: FoobarStatus,
isLoading: () => boolean,
isError: () => boolean,
isSuccess: () => boolean,
}
- clase:
class Foobar<IFoobar> {
private _status: FoobarStatus = FoobarStatus.loading;
constructor(){
this._status = FoobarStatus.loading;
}
get status(): FoobarStatus {
return this._status
}
set status(status: FoobarStatus) {
this._status = status;
}
isLoading(): boolean {
return (this._status === FoobarStatus.loading);
}
isError(): boolean {
return (this._status === FoobarStatus.error);
}
isSuccess(): boolean {
return (this._status === FoobarStatus.success);
}
}
- Función auxiliar para console.logs () "
function report(foobar: IFoobar): void {
console.log(''---- report ----'');
console.log("status:", foobar.status);
console.log("isLoading:", foobar.isLoading());
console.log("isError:", foobar.isError());
console.log("isSucess:", foobar.isSuccess());
console.log(''----- done -----'');
}
- Trabajando con foobar:
const foobar = new Foobar<IFoobar>();
report(foobar);
foobar.status = FoobarStatus.success;
report(foobar);
Puede crear un tipo que pueda manejar tres casos:
- Éxito: se obtuvo el valor y ahora está disponible
- Cargando: estamos recuperando el valor
- Error: no se pudo obtener el valor (error)
type AsyncValue<T> = Success<T> | Loading<T> | Failure<T>;
Luego puede definir todos esos tipos con sus guardias personalizados:
class Success<T> {
readonly value: T;
constructor(value: T) {
this.value = value;
}
isSuccess(this: AsyncValue<T>): this is Success<T> {
return true;
}
isLoading(this: AsyncValue<T>): this is Loading<T> {
return false;
}
isError(this: AsyncValue<T>): this is Failure<T> {
return false;
}
}
class Loading<T> {
readonly loading = true;
isSuccess(this: AsyncValue<T>): this is Success<T> {
return false;
}
isLoading(this: AsyncValue<T>): this is Loading<T> {
return true;
}
isError(this: AsyncValue<T>): this is Failure<T> {
return false;
}
}
class Failure<T> {
readonly error: Error;
constructor(error: Error) {
this.error = error;
}
isSuccess(this: AsyncValue<T>): this is Success<T> {
return false;
}
isLoading(this: AsyncValue<T>): this is Loading<T> {
return false;
}
isError(this: AsyncValue<T>): this is Failure<T> {
return true;
}
}
Ahora está listo para usar
AsyncValue
en su código:
function doSomething(val: AsyncValue<number>) {
if(val.isLoading()) {
// can only be loading
} else if (val.isError()) {
// can only be error
val.error
} else {
// can only be the success type
val.value // this is a number
}
}
que se puede invocar con uno de esos:
doSomething(new Success<number>(123))
doSomething(new Loading())
doSomething(new Failure(new Error(''not found'')))
Traté de hacer una solución elegante, ahí está.
Primero, definimos los estados
interface Loading {
loading: true;
}
interface Error {
error: Error;
}
interface Success<D> {
data: D;
}
type State<D> = Loading | Error | Success<D>;
Tenga en cuenta que también puede usar un campo de
type
para la
unión etiquetada
, según su preferencia.
A continuación definimos la interfaz de funciones.
interface LoadingErrorFn<D> {
isLoading(): this is Loading;
isError(): this is Error;
isSuccess(): this is Success<D>;
}
Todo
this is
son
predicados de tipo
que reducirán el tipo de objeto al tipo de destino.
Cuando llame a
isLoading
, si la función devuelve verdadero, el objeto ahora se considera como un objeto
Loading
.
Ahora definimos nuestro tipo de LoadingError final
type LoadingErrorType<D> = LoadingErrorFn<D> & State<D>;
Por lo tanto, un
LoadingErrorType
contiene todas nuestras funciones y también es un
State
, que puede ser
Loading
,
Error
o
Success
.
La función de verificación
function f<D>(x: LoadingErrorType<D>) {
if (x.isLoading()) {
// only access to x.loading (true)
x.loading
} else if (x.isError()) {
// only access to x.error (Error)
x.error
} else {
// only access to x.data (D)
x.data
}
}
Ahora
x
se puede inferir perfectamente, tiene acceso a la propiedad deseada (y funciones), ¡nada más!
Ejemplo de implementación de una clase.
class LoadingError<D = unknown> implements LoadingErrorFn<D> {
static build<D>(): LoadingErrorType<D> {
return new LoadingError() as LoadingErrorType<D>;
}
loading: boolean;
error?: Error;
data?: D;
private constructor() {
this.loading = true;
}
isLoading(): this is Loading {
return this.loading;
}
isError(): this is Error {
return !!this.error;
}
isSuccess(): this is Success<D> {
return !!this.data;
}
}
Su clase tiene que implementar
LoadingErrorFn
e implementar todas las funciones.
La interfaz no requiere la declaración de campos, así que elija la estructura que desee.
La función estática de
build
es una solución para un problema: la clase no puede implementar tipos, por lo que
LoadingError
no puede implementar
LoadingErrorType
.
Pero este es un requisito crítico para nuestra función
f
(para la parte de inferencia
else
).
Así que tuve que hacer un reparto.
Hora de probar
const loadingError: LoadingErrorType<number> = LoadingError.build();
f(loadingError); // OK
Espero que haya ayudado, cualquier sugerencia es apreciada.
Un predicado refina un tipo en uno de sus subtipos y no se puede "negar" para inferir otro subtipo.
En el código, está tratando de refinar el
type A
en su subtipo
type B
pero si son posibles otros subtipos de
type A
, la negación no funcionará (
vea el área de juegos
).
type A = { value: string | null }
type B = { value: string }
type C = { value: null }
type D = { value: string | null, whatever: any }
// ...
declare function hasValue(x: A): x is B
declare const x: A
if (hasValue(x)) {
x.value // string
} else {
x.value // string | null
}
Una solución fácil es crear un predicado directamente en el
value
lugar de todo el objeto (
ver área de juegos
).
type A = { value: string | null }
type B = { value: string }
declare function isNullValue(x: A[''value'']): x is null
if (isNullValue(x.value)) {
x.value // null
} else {
x.value // string
}