Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Typescript narrow class into a discriminated union

I am having a hard time narrowing an instance of a class to its discriminated union.

I have the following discriminated union:

interface ILoadableLoading<T> {
  state: "Loading";
  id: number;
}

interface ILoadableLoaded<T> {
  state: "Loaded";
  id: number;
  item: T;
}

interface ILoadableErrored<T> {
  state: "Error";
  id: number;
  error: string;
}

export type ILoadableDiscriminated<T> =
  | ILoadableLoading<T>
  | ILoadableLoaded<T>
  | ILoadableErrored<T>;

type ILoadableState<T> = ILoadableDiscriminated<T>["state"];

And the following class:

class Loadable<T> {
  state: ILoadableState<T> = "Loading";
  id: number = 0;
  item?: T | undefined;
  error?: string | undefined;
}

Now how can I narrow an instance of that class to its respective ILoadableDiscriminated<T> union keeping some type safety (not using any)?

E.g. I have the following create method, and would like to return the discriminated union:

unction createLoadable<T>(someState: boolean): ILoadableDiscriminated<T> {
  var loadable = new Loadable<T>();

  if (someState) {
    loadable.state = "Error";
    loadable.error = "Some Error";

    // Would like to remove this cast, as it should narrow it out from state + defined error above
    return loadable as ILoadableErrored<T>;
  }

  if (loadable.state === "Loading") {
    // Would like to remove this cast, as it should narrow it from state;
    return loadable as ILoadableLoading<T>;
  }

  if (loadable.state === "Loaded" && loadable.item) {
    // Would like to remove this cast, as it should narrow it from state;
    return loadable as ILoadableLoaded<T>;
  }

  throw new Error("Some Error");
}

Sample can be found on: https://codesandbox.io/embed/weathered-frog-bjuh0 File: src/DiscriminatedUnion.ts

like image 408
Michal Ciechan Avatar asked Oct 31 '25 03:10

Michal Ciechan


1 Answers

Problem is that there is no relation between Loadable<T> and defined interfaces which would guaranty that function createLoadable() sets each property in correct state before you return the item. For example, Loadable<string> could have this values:

var loadable = new Loadable<string>();
loadable.state = "Error";
lodable.item = "Result text.";
return loadable;

Above does not fit any interface, but it's valid Loadable instance.

My approach would be following:

Simplify interface, only one has to be generic:

interface ILoadableLoading {
  state: "Loading";
  id: number;
}

interface ILoadableLoaded<T> {
  state: "Loaded";
  id: number;
  item: T;
}

interface ILoadableErrored {
  state: "Error";
  id: number;
  error: string;
}

export type ILoadableDiscriminated<T> =
  | ILoadableLoading
  | ILoadableLoaded<T>
  | ILoadableErrored;

type ILoadableState<T> = ILoadableDiscriminated<T>["state"];

Create separate class for each interface, to ensure created objects adhere to interface definitions:

class LoadableLoading implements ILoadableLoading {
  state: "Loading" = "Loading";
  id: number = 0;
}
class LoadableLoaded<T> implements ILoadableLoaded<T> {
  constructor(public item: T){}
  state: "Loaded" = "Loaded";
  id: number = 0;
}
class LoadableErrored implements ILoadableErrored {
  constructor(public error: string){}
  state: "Error" = "Error";
  id: number = 0;
}

Then we could use function with overloading, to state the intent:

function createLoadable<T>(someState: true, state: ILoadableState<T>, item?: T): ILoadableErrored;
function createLoadable<T>(someState: false, state: "Loading", item?: T): ILoadableLoading;
function createLoadable<T>(someState: false, state: "Loaded", item?: T): ILoadableLoaded<T>;
function createLoadable<T>(someState: boolean, state?: ILoadableState<T>, item?: T): ILoadableDiscriminated<T> {
  if (someState) {
    return new LoadableErrored("Some error");
  }

  if (state === "Loading") {
    // Would like to remove this cast, as it hsould figure it out from state;
    return new LoadableLoading();
  }

  if (state === "Loaded" && item) {
    // Would like to remove this cast, as it hsould figure it out from state;
    return new LoadableLoaded(item);
  }

  throw new Error("Some Error");
}

Finally, depending on your input parameters to createLoadable() function, type will be return type will discriminated automatically:

const lodableError = createLoadable<string>(true, "Loading");
console.log(lodableError.error);

const lodableLoading = createLoadable<string>(false, "Loading");
console.log("Loading");

const loadableLoaded = createLoadable<string>(false, "Loaded", "MyResponse");
console.log(loadableLoaded.item)

Note that parameter overloads state intent for Typescript compiler, but you need to ensure that code in the function body does what you declared.

like image 134
Nenad Avatar answered Nov 02 '25 17:11

Nenad



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!