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




(5)

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

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

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 типов isSuccess , isLoading , isError который проверяет состояние экземпляра вашего класса и может сузить тип состояния в истинной ветви с помощью if / else. Вы можете сделать это, создав защиту типа, возвращающую предикат типа Polymorphic, который содержит ваше суженное состояние.

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

Playground

Ограничения

Вот что происходит: вы можете сделать это только тогда, когда вы объявили свой член класса 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
}

Playground

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

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

Playground

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

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

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

Идеальный API:

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

Главное, с чем я isError , это isError методов 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() .


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

  • Успех: значение получено и теперь доступно
  • Загрузка: мы выбираем значение
  • Ошибка: не удалось получить значение (ошибка)
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')))

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

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

  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


Предикат уточняет один тип в один из его подтипов и не может быть «отрицан», чтобы вывести другой подтип.

В коде вы пытаетесь уточнить 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
}

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

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

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

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

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