0

I've got the following code that I'm using to track async request statuses. It uses _type as a discriminator, as well as status.

In the following code I define two AsyncStatus types: LoginAsyncStatus and SearchAsyncStatus. They differ by _type and by the success value.

The problems is that TypeScript seems to be incorrectly narrowing the type of the discriminated union.

export type AsyncStatus<BrandT extends string, T = undefined> =
  | { id: string; _type: BrandT; error?: never; state: "loading"; value?: never; }
  | { id: string; _type: BrandT; error: Error; state: "error"; value?: never }
  | { id: string; _type: BrandT; error?: never; state: "success"; value: T };

export type ExtractAsyncStatusByType<
  TName extends ApiAsyncStatus["_type"],
  TType
> = TType extends AsyncStatus<TName, any> ? TType : never;

export type LoginAsyncStatus = AsyncStatus<"LOGIN", { refreshToken: string }>;
export type SearchAsyncStatus = AsyncStatus<"SEARCH", string[]>;
export type ApiAsyncStatus = LoginAsyncStatus | SearchAsyncStatus;

export type Registry = Partial<Record<ApiAsyncStatus["id"], ApiAsyncStatus>>;

export const getApiAsyncStatus = <T extends ApiAsyncStatus["_type"]>(
  registry: Registry,
  id: string,
  type: T,
): ExtractAsyncStatusByType<T, ApiAsyncStatus> | undefined => {
  let status = registry[id];
  if (status !== undefined && status._type !== type) {
    /**
     * Property 'value' is missing in type 
     *   '{ _type: T; error: Error; id: string; state: "error"; }'
     * but required in type 
     *   '{ id: string; _type: "SEARCH"; error?: undefined; state: "success"; value: string[]; }'
     * .ts(2322)
     */
    status = {
      _type: type,
      error: new Error(`Expected _type ${type}, but received ${status._type}`),
      id,
      state: "error",
    }; // err
  }
  return status as ExtractAsyncStatusByType<T, ApiAsyncStatus> | undefined;
};

I've updated the initial question where the question was about returning the appropriate type in the case where I wasn't trying to dynamically create a status.

Devin
  • 2,025
  • 2
  • 20
  • 25
  • Discriminated unions only really work as you expect with concrete types, not generics. Furthermore, inside the implementation of `getApiAsyncStatus()`, the type `T` is an unresolved generic parameter, and the compiler doesn't do much work attempting to verify that a value is assignable to a conditional type dependent on such an unresolved generic. Your best bet here is to just use a type assertion (`return status as Extract` or the like) or something equivalent (e.g., use an overload signature). The benefit of that conditional type is for callers, not implementers. – jcalz Jun 02 '19 at 17:28
  • btw I don't see any [mapped types](https://www.typescriptlang.org/docs/handbook/advanced-types.html#mapped-types) here, or at least not any that have bearing on this situation (e.g., `Registry` uses `Partial` and `Record` but doesn't seem to be the issue here) – jcalz Jun 02 '19 at 17:31
  • @jcalz i updated the question to reflect your insight in the comment, but also showing a more full example of what I'm trying to accomplish. For some reason, I'm having trouble with the generic type when creating a new status. – Devin Jun 02 '19 at 20:16

1 Answers1

0

I'll repeat my comments and continue on from there:

Discriminated unions only really work as you expect with concrete types, not generics. Furthermore, inside the implementation of getApiAsyncStatus(), the type T is an unresolved generic parameter, and the compiler doesn't do much work attempting to verify that a value is assignable to a conditional type dependent on such an unresolved generic. Your best bet here is to just use a type assertion or something equivalent like an overload signature. The benefit of that conditional type is for callers, not implementers.

If you are using TypeScript 3.5 with its smarter union type checking you can fix your above code like this:

export const getApiAsyncStatus = <T extends ApiAsyncStatus["_type"]>(
  registry: Registry,
  id: string,
  type: T,
) => {
  let status = registry[id];
  if (status !== undefined && status._type !== type) {
    // annotate as error state
    const errorStatus: Extract<ApiAsyncStatus, { state: "error" }> = {
      _type: type as ApiAsyncStatus["_type"], // widen to concrete union
      error: new Error(`Expected _type ${type}, but received ${status._type}`),
      id,
      state: "error"
    }; 
    status = errorStatus;  // this assignment is okay
  }
  // still need this assertion
  return status as ExtractAsyncStatusByType<T, ApiAsyncStatus> | undefined;
};

The widening of type from T to ApiAsyncStatus["_type"] changes it from a generic type (for which the compiler's deductive skills are lacking) to a concrete union (which is better). The smarter union checking in TS3.5 is necessary for the compiler to understand that a value of type {_type: A | B, error: Error, state: "error"} is assignable to a variable of type {_type: A, error: Error, state: "error"} | {_type: B, error: Error, state: "error"}. For TS3.4 and below, the compiler would not do such analysis at all. So even the above will still be an error in those earlier versions.

To support those you might as well just make your assertions wider and less type safe:

export const getApiAsyncStatus = <T extends ApiAsyncStatus["_type"]>(
  registry: Registry,
  id: string,
  type: T,
) => {
  let status = registry[id];
  if (status !== undefined && status._type !== type) {
    status = {
      _type: type,
      error: new Error(`Expected _type ${type}, but received ${status._type}`),
      id,
      state: "error"
    } as Extract<ApiAsyncStatus, { state: "error", _type: T }>; // assert as error type
  }
  // still need this assertion
  return status as ExtractAsyncStatusByType<T, ApiAsyncStatus> | undefined;
};

Link to code

So either of those should work depending on the version of TypeScript you're using. I tend to think of this problem as a general class of issues related to correlated types, where things would work out fine if you could convince the compiler to type check a block of code multiple times for each possible narrowing of some union-typed variable. In your case, for each possible value of T ("LOGIN" and "SEARCH" here), your code should check fine. But when looking at unions or generic extensions of the unions "all at once", the compiler thinks some prohibited situations are possible and balks. There's no great answer for this, I'm afraid... my advice is to just assert your way out of it and move on.

Okay, hope that helps; good luck!

jcalz
  • 125,133
  • 11
  • 145
  • 170