typescript

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; }

Playground

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 }

Playground

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 }

Playground

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; //... } } }

Playground

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:

  1. Use una clase para representar una unión de estados
  2. Use un miembro de la clase para reducir a un estado
  3. 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 }

Play

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 }

Play

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.

[parque infantil con todo el código]


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 }