Следите за состоянием в экземпляре класса

Я хочу создать класс, который имеет некоторое внутреннее состояние (может быть загрузка, ошибка или успех). Я также хочу, чтобы в классе было несколько методов, которые могли бы проверять состояние этого класса.

Идеальный API:

function f(x: LoadingError<number>) {
  if (x.isLoading()) {
  } else if (x.isError()) {
  } else {
    (x.data); // TypeScript knows x.data is of type 'number'
  }
}

Главное, с чем я борюсь, - это создание методов isLoading и isError, чтобы TypeScript мог их понимать.

Я попытался написать некоторые определенные пользователем типы защиты для фактической структуры класса ("" 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
}

Однако это не работает, поскольку TypeScript "забывает" о состоянии экземпляра класса в предложении else.

Одним из моих жестких требований является использование класса для этого, поскольку я не хочу иметь методы isLoading(instance) или isError(instance), а скорее instance.isLoading() и instance.isError().

Ответ 1

Вы можете создать тип, который может обрабатывать три случая:

  • Успешно: значение получено и теперь доступно
  • Загрузка: мы выбираем значение
  • Ошибка: не удалось получить значение (ошибка)
type AsyncValue<T> = Success<T> | Loading<T> | Failure<T>;

Затем вы можете определить все эти типы с помощью их пользовательских охранников:

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

Теперь вы готовы использовать AsyncValue в своем коде:

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

который может быть вызван одним из них:

doSomething(new Success<number>(123))
doSomething(new Loading())
doSomething(new Failure(new Error('not found')))

Ответ 2

  Я хочу создать класс с некоторым внутренним состоянием (может быть загрузка, ошибка или успех)

Создайте свои возможные типы состояний

type State<T> = ErrorState | SuccessState<T> | LoadingState;

type ErrorState = { status: "error"; error: unknown };
type SuccessState<T> = { status: "success"; data: T };
type LoadingState = { status: "loading" };

Я также хочу, чтобы в классе было несколько методов, которые могли бы проверять состояние этого класса.

Создать класс Foo, содержащий состояние

Здесь я предполагаю, что вы хотите вызвать какой-нибудь открытый метод защиты типов isSuccess, isLoading, isError, который проверяет состояние экземпляра вашего класса и может сузить тип состояния в истинной ветки с помощью if/else. Вы можете сделать это, создав защиту типов, которая возвращает Полиморфный предикат этого типа, который содержит ваше суженное состояние.

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

Давайте проверим это:

const instance = new Foo({ status: "success", data: 42 });
if (instance.isSuccess()) {
  // works, (property) data: number
  instance.currentState.data; 
}

площадка

Ограничения

Вот что происходит: вы можете сделать это только тогда, когда вы объявили своего члена класса currentState с открытым модификатором (ограничение TypeScript)! Если вы объявили его закрытым, вы не можете использовать такой тип защиты для этой цели. Альтернативой было бы вернуть необязательное состояние:

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
}

площадка

Дополнительное примечание относительно вашей ошибки ветвления с 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 здесь не выводит foo.value в ноль, потому что foo.hasValue() - это настраиваемая защита типов, которая просто сужает ваш тип до { value: string } с истинным условием. Если условие ложно, тип по умолчанию value (string | null) предполагается снова. Защита пользовательских типов отменяет обычную логику ветвления в TypeScript. Вы можете изменить это, просто опустив его:

if (foo.value !== null) {
  // OK
  foo.value.toString();
} else {
  (foo.value); // (property) Foo.value: null
}

площадка

Если вы проверите свое состояние из экземпляра класса

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

площадка

Ответ 3

Мне нравится использовать "Дискриминационные союзы" (или "Помеченные союзы"). Примерно так:

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
}

Вы можете увидеть это в действии, в этом живом демо.

Ответ 4

Много решений для этого, как мы видим в других ответах.

После прочтения вопроса ваши требования:

  1. Используйте класс для представления союза государств
  2. Используйте члена класса для сужения до одного состояния
  3. Правильно сужайте обе ветки.

Большая проблема - 3. Сужение будет работать на истинной ветки, пересекаясь с типом, утвержденным защитником типа. Ветвь else работает, исключая утвержденный тип из типа переменной. Это прекрасно работает, если типом будет объединение, и компилятор может исключить все совпадающие составляющие объединения, но здесь у нас есть класс, поэтому нет исключаемого компонента, и у нас остается исходный тип Foo.

Простейшим решением IMO было бы отделение типа экземпляра класса от фактического класса. Мы можем напечатать конструктор, чтобы вернуть объединение с соответствующим объединением состояний. Это позволит механизму исключения работать должным образом в ветки 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

Мы можем смешать несколько состояний:

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

Единственным ограничением является то, что внутри класса охранники не будут работать.

К вашему сведению, если у вас есть охранники типов, исключающие все состояния, вы можете даже выполнить трюк assertNever, чтобы убедиться, что все состояния обработаны: play

Ответ 5

Я не уверен в описываемом вами сценарии использования (рассмотрите возможность повторного формулирования вопроса, возможно, для большей ясности), но если вы пытаетесь работать со статусом, возможно, enums будет для вас лучше Вы можете избежать любых нулевых проверок и всегда поддерживать правильный действительный установленный статус.

Вот пример, который я сделал на основе того, что я считаю желаемой вами функциональностью.

  • типизации:
enum FoobarStatus {
  loading = 'loading',
  error = 'error',
  success = 'success'
}

interface IFoobar {
  status: FoobarStatus,
  isLoading: () => boolean, 
  isError: () => boolean, 
  isSuccess: () => boolean, 
}
  • Класс:
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);
  }
}
  • Вспомогательная функция для 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 -----');
}
  • Работа с foobar:
const foobar = new Foobar<IFoobar>();
report(foobar);
foobar.status = FoobarStatus.success;
report(foobar);

Ответ 6

Предикат уточняет один тип в один из его подтипов и не может быть "отрицан" для вывода другого подтипа.

В коде вы пытаетесь уточнить type A в его подтип type B, но если возможны другие подтипы type A, отрицание не будет работать (см. Игровую площадку).

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
}

Простое решение - создать предикат непосредственно на value вместо всего объекта (см. игровую площадку).

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
}

Ответ 7

Я попытался сделать элегантное решение, вот оно.


Сначала мы определяем состояния

interface Loading {
    loading: true;
}

interface Error {
    error: Error;
}

interface Success<D> {
    data: D;
}

type State<D> = Loading | Error | Success<D>;

Обратите внимание, что вы также можете использовать поле type для тегового объединения, в зависимости от ваших предпочтений.


Далее мы определим интерфейс функций

interface LoadingErrorFn<D> {
    isLoading(): this is Loading;

    isError(): this is Error;

    isSuccess(): this is Success<D>;
}

Все this is являются предикатами типа, которые сузят тип объекта до целевого типа. Когда вы вызовете isLoading, если функция вернет true, тогда объект теперь рассматривается как объект Loading.


Теперь мы определим наш окончательный тип LoadingError

type LoadingErrorType<D> = LoadingErrorFn<D> & State<D>;

Итак, LoadingErrorType содержит все наши функции, а также State, который может быть Loading, Error, ИЛИ Success.


Функция проверки

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

Теперь x может быть отлично выведен, у вас есть доступ к требуемому свойству (и функциям), ничего более!


Пример реализации класса

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

Ваш класс должен реализовать LoadingErrorFn и реализовать все функции. Объявление полей не требуется для интерфейса, поэтому выберите желаемую структуру.

Статическая функция build - это обходной путь для проблемы: класс не может реализовать типы, поэтому LoadingError не может реализовать LoadingErrorType. Но это критичное требование для нашей функции f (для части логического вывода else). Поэтому я должен был сделать бросок.


Время тестировать

const loadingError: LoadingErrorType<number> = LoadingError.build();

f(loadingError); // OK

Я надеюсь, что это помогло, любое предложение приветствуется!

[площадка со всем кодом]