tutorial - typescript vs javascript
Acompanhe o estado na instância da classe (5)
Quero criar uma classe que tenha algum estado interno (pode estar carregando, erro ou êxito)
Crie seus possíveis 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" };
Eu também quero ter alguns métodos na classe que possam verificar o estado dessa classe.
Crie a classe Foo contendo o estado
Presumo que aqui, você deseja invocar algum tipo de método de guarda de tipo público
isSuccess
,
isLoading
,
isError
que verifica o estado da instância da classe e pode restringir o tipo de estado no ramo verdadeiro usando if / else.
Você pode fazer isso criando protetores de tipo que retornam um predicado
polimórfico desse tipo
que contém seu estado restrito.
// 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";
}
}
Vamos testá-lo:
const instance = new Foo({ status: "success", data: 42 });
if (instance.isSuccess()) {
// works, (property) data: number
instance.currentState.data;
}
Limitações
Aí vem o negócio: Você só pode fazer isso quando tiver declarado o membro da classe
currentState
com modificador
público
(limitação do TypeScript)!
Se você o declarou
privado
, não poderá usar esse tipo de proteção para esse fim.
Uma alternativa seria retornar um estado opcional:
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 lateral sobre o erro de ramificação com
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
}
O TypeScript não infere
foo.value
como null aqui, porque
foo.hasValue()
é um
foo.hasValue()
tipo personalizado que apenas restringe seu tipo a
{ value: string }
com a condição verdadeira.
Se a condição for falsa, o tipo de
value
padrão (
string | null
) será assumido novamente.
O protetor de tipo personalizado cancela a lógica de ramificação normal do TypeScript.
Você pode mudar isso simplesmente omitindo:
if (foo.value !== null) {
// OK
foo.value.toString();
} else {
(foo.value); // (property) Foo.value: null
}
Se você verificar seu estado de dentro da instância da classe
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;
//...
}
}
}
Eu quero criar uma classe que tenha algum estado interno (pode estar carregando, erro ou sucesso). Eu também quero ter alguns métodos na classe que possam verificar o estado dessa classe.
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`
}
}
A principal coisa com a qual estou lutando é a criação dos métodos
isError
e
isError
, para que o TypeScript possa entendê-los.
Tentei escrever alguns protetores de tipo definido pelo usuário na estrutura de classe 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
}
No entanto, isso não funciona desde que o TypeScript "esquece" o estado da instância da classe na cláusula
else
.
Um dos meus requisitos rígidos é usar uma
classe
para isso, já que não quero ter os
isLoading(instance)
ou
isError(instance)
, mas sim os
instance.isLoading()
e
instance.isError()
.
Eu gosto de usar " Uniões discriminadas " (ou "Uniões etiquetadas"). Algo assim:
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
}
Você pode vê-lo em ação nesta demonstração ao vivo .
Muitas soluções para isso, como podemos ver nas outras respostas.
Ao ler a pergunta, seus requisitos são:
- Use uma classe para representar uma união de estados
- Use um membro da classe para restringir a um estado
- Faça um trabalho estreito corretamente nos dois ramos.
O grande problema é 3. O estreitamento funcionará no ramo verdadeiro, cruzando-o com o tipo declarado pelo protetor de tipo.
A ramificação else funciona excluindo o tipo declarado do tipo de variável.
Isso funciona muito bem se o tipo for uma união e o compilador puder excluir todos os constituintes correspondentes da união, mas aqui temos uma classe, portanto, não há constituintes a serem excluídos e ficamos com o tipo
Foo
original.
A solução mais simples da IMO seria dissociar o tipo de instância da classe da classe real. Podemos digitar um construtor para retornar uma união com a união apropriada de estados. Isso permitirá que o mecanismo de exclusão funcione conforme o esperado no ramo 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 misturar vários 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
}
A única limitação é que dentro da classe os guardas não funcionam.
Para sua informação, se você possui guardas de tipo que excluem todos os estados, pode até fazer o truque
assertNever
para garantir que todos os estados foram tratados:
play
Não tenho certeza sobre o caso de uso que você está descrevendo (considere redigir a pergunta novamente para esclarecer mais talvez), mas se você estiver tentando trabalhar com um status, talvez as enums sejam melhores para você, assim você pode evitar verificações nulas e sempre mantenha um status de conjunto válido válido.
Aqui está um exemplo que eu fiz com base no que eu acho que é a sua funcionalidade desejada.
- digitações:
enum FoobarStatus {
loading = 'loading',
error = 'error',
success = 'success'
}
interface IFoobar {
status: FoobarStatus,
isLoading: () => boolean,
isError: () => boolean,
isSuccess: () => boolean,
}
- classe:
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);
}
}
- Função 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 -----');
}
- Trabalhando com foobar:
const foobar = new Foobar<IFoobar>();
report(foobar);
foobar.status = FoobarStatus.success;
report(foobar);
Você pode criar um tipo que possa lidar com três casos:
- Sucesso: o valor foi obtido e agora está disponível
- Carregando: estamos buscando o valor
- Falha: não foi possível buscar o valor (erro)
type AsyncValue<T> = Success<T> | Loading<T> | Failure<T>;
Em seguida, você pode definir todos esses tipos com seus guardas 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;
}
}
Agora você está pronto para usar o
AsyncValue
no seu 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 pode ser invocado com um desses:
doSomething(new Success<number>(123))
doSomething(new Loading())
doSomething(new Failure(new Error('not found')))