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()
.
Lots of solutions to this as we can see in the other answers.
From reading the question your requirements are:
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
You can create a type that can handle three cases:
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')))
I want to create a class that has some internal state (could be loading, error or success)
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.
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
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
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
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With