Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Keep track of state in class instance

Tags:

typescript

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().

like image 907
David Gomes Avatar asked Aug 13 '19 10:08

David Gomes


3 Answers

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

like image 128
Titian Cernicova-Dragomir Avatar answered Nov 20 '22 13:11

Titian Cernicova-Dragomir


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')))
like image 45
Federkun Avatar answered Nov 20 '22 13:11

Federkun


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

like image 24
ford04 Avatar answered Nov 20 '22 13:11

ford04