3
votes

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

1
Hmm, looks like union narrowing happens on assignment but that doesn't propagate to objects containing narrowed properties like this - jcalz
Maybe related to this issue and therefore this issue. It seems this should narrow the parent object (since state is a discriminant) but I'm not sure if narrowing on assignment follows the same path as type guarding. I'm a bit stumped... maybe someone else has more ideas? - jcalz
NOTE: Above comments are specifically about what happens when someState is true - jcalz

1 Answers

1
votes

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.