David Gomes
David Gomes

Reputation: 5835

Keep track of state in class instance

I want to create a class that has some internal state (could be loading, error or success). I also want to have some methods on the class that can check on the state of this class.

Ideal API:

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

The main thing that I am struggling with is creating the isLoading and isError methods such that TypeScript can understand them.

I tried to write some user defined type guards on the actual class structure ("" 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
}

However, that doesn't work since TypeScript "forgets" about the state of the class instance on the else clause.

One of my hard requirements is using a class for this, since I don't want to have isLoading(instance) or isError(instance) methods but rather instance.isLoading() and instance.isError().

Upvotes: 11

Views: 1313

Answers (7)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 250016

Lots of solutions to this as we can see in the other answers.

From reading the question your requirements are:

  1. Use a class to represent a union of states
  2. Use a class member to narrow to one state
  3. Have narrowing work correctly on both branches.

The big problem is 3. Narrowing will work on the true branch by intersecting with the type asserted by the type guard. The else branch works by excluding the asserted type from the variable type. This works great if the type would be a union and the compiler could exclude all matching constituents of the union, but here we have a class, so there is no constituent to exclude and we are left with the original Foo type.

The simplest solution IMO would be to decouple the instance type of the class from the actual class. We can type a constructor to return a union with the appropriate union of states. This will allow the exclusion mechanism to work as expected on the else branch:

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

We can mix several states:

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

The only limitation is that inside the class the guards will not work.

FYI, if you have type guards that exclude all states, you can even do the assertNever trick to ensure all states have been handled: play

Upvotes: 3

Richard Haddad
Richard Haddad

Reputation: 1014

I tried to make an elegant solution, there it is.


First, we define the states

interface Loading {
    loading: true;
}

interface Error {
    error: Error;
}

interface Success<D> {
    data: D;
}

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

Note that you can also use a type field for tagged union, depending on your preference.


Next we define the functions interface

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

    isError(): this is Error;

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

All the this is are type predicates wich will narrow the type of the object to the targeted type. When you'll call isLoading, if the function return true, then the object is now considered as a Loading object.


Now we define our final LoadingError type

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

So, a LoadingErrorType contains all our functions, and is also a State, which can be Loading, Error, OR Success.


The check function

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

Now x can be perfectly inferred, you have access to the property wanted (and functions), nothing more !


Example of a class implementation

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

Your class has to implement LoadingErrorFn, and implement all functions. The fields declaration is not required by the interface, so choose the structure you want.

The build static function is a workaround for an issue: class cannot implement types, so LoadingError cannot implement LoadingErrorType. But this is a critic requirement for our f function (for the else inference part). So I had to do a cast.


Time to test

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

f(loadingError); // OK

I hope it helped, any suggestion is appreciated !

[playground with all the code]

Upvotes: 0

zhirzh
zhirzh

Reputation: 3449

A predicate refines one type into one of its subtypes and can't be "negated" to infer another subtype.

In the code you are trying to refine type A into its subtype type B but if other subtypes of type A are possible then negation won't work (see playground).

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
}

An easy fix is to create a predicate directly on value instead of the whole object (see playground).

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
}

Upvotes: 1

ford04
ford04

Reputation: 74620

I want to create a class that has some internal state (could be loading, error or success)

Create your possible state types

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

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

I also want to have some methods on the class that can check on the state of this class.

Create class Foo containing the state

I assume here, you want to invoke some kind of public type guard method isSuccess, isLoading,isError that checks your class instance state and can narrow the state type in the true branch by use of if/else. You can do that by creating type guards that return a Polymorphic this type predicate which contains your narrowed state.

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

Let's test it:

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

Playground

Limitations

Here comes the deal: You can only do that, when you have declared your class member currentState with public modifier (TypeScript limitation)! If you have declared it private, you cannot use such a type guard for this purpose. An alternative would be to return an optional state instead:

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

Side note concerning your branch error with 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 does not infer foo.value to null here, because foo.hasValue() is a custom Type Guard that just narrows your type to { value: string } with true condition. If the condition is false, the default type of value (string | null) is assumed again. The custom type guard cancels the normal branching logic of TypeScript. You can change that by simply omitting it:

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

Playground

If you check your state from inside class instance

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

Upvotes: 8

Federkun
Federkun

Reputation: 36964

You can create a type that can handle three cases:

  • Success: the value was fetched and it's now available
  • Loading: we are fetching the value
  • Failure: wasn't able to fetch the value (error)
type AsyncValue<T> = Success<T> | Loading<T> | Failure<T>;

Then you can define all those types with their custom guards:

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

Now you are ready to use the AsyncValue in your code:

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

which can be invoked with one of those:

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

Upvotes: 2

Shalom Peles
Shalom Peles

Reputation: 2632

I like to use "Discriminated Unions" (or "Tagged unions"). Something like this:

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
}

You can see it in action, in this live demo.

Upvotes: 2

aviya.developer
aviya.developer

Reputation: 3613

I am not sure about the use-case you are describing (consider phrasing the question again for more clarify perhaps), but if you are trying to work with a status perhaps enums would be better for you, that way you can avoid any null checks and always maintain a proper valid set status.

Here is an example I made based on what i think is your desired functionality.

  • typings:
enum FoobarStatus {
  loading = 'loading',
  error = 'error',
  success = 'success'
}

interface IFoobar {
  status: FoobarStatus,
  isLoading: () => boolean, 
  isError: () => boolean, 
  isSuccess: () => boolean, 
}
  • class:
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);
  }
}
  • Helper function for 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 -----');
}
  • Working with foobar:
const foobar = new Foobar<IFoobar>();
report(foobar);
foobar.status = FoobarStatus.success;
report(foobar);

Upvotes: 1

Related Questions