Reputation: 5835
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
Reputation: 250016
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
}
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
}
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
Reputation: 1014
I tried to make an elegant solution, there it is.
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.
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.
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
.
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 !
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.
const loadingError: LoadingErrorType<number> = LoadingError.build();
f(loadingError); // OK
I hope it helped, any suggestion is appreciated !
[playground with all the code]
Upvotes: 0
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
Reputation: 74620
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;
}
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
}
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
}
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;
//...
}
}
}
Upvotes: 8
Reputation: 36964
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')))
Upvotes: 2
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
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.
enum FoobarStatus {
loading = 'loading',
error = 'error',
success = 'success'
}
interface IFoobar {
status: FoobarStatus,
isLoading: () => boolean,
isError: () => boolean,
isSuccess: () => boolean,
}
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);
}
}
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 -----');
}
const foobar = new Foobar<IFoobar>();
report(foobar);
foobar.status = FoobarStatus.success;
report(foobar);
Upvotes: 1