Фильтрация размеченного объединения TypeScript не работает на дискриминированном ключе

У меня есть следующий код, который я использую для отслеживания статусов асинхронных запросов. В качестве дискриминатора используется _type, а также status.

В следующем коде я определяю два типа AsyncStatus: LoginAsyncStatus и SearchAsyncStatus. Они различаются _type и success value.

Проблема в том, что TypeScript, похоже, неправильно сужает тип размеченного объединения.

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;
};

Я обновил исходный вопрос, в котором вопрос касался возврата соответствующего типа в случае, когда я не пытался динамически создавать статус.


person Devin    schedule 02.06.2019    source источник
comment
Дискриминационные союзы действительно работают так, как вы ожидаете, только с конкретными типами, а не с универсальными. Более того, внутри реализации getApiAsyncStatus() тип T является неразрешенным универсальным параметром, и компилятор не выполняет много работы, пытаясь проверить, можно ли присвоить значение условному типу, зависящему от такого неразрешенного универсального параметра. Лучше всего здесь просто использовать утверждение типа (return status as Extract<ApiAsyncStatus, {_type: T}> или подобное) или что-то подобное (например, использовать подпись перегрузки). Преимущество этого условного типа для вызывающих, а не для разработчиков.   -  person jcalz    schedule 02.06.2019
comment
кстати, я не вижу здесь сопоставленных типов , или, по крайней мере, не имеет отношения к этой ситуации (например, Registry использует Partial и Record, но, похоже, здесь не проблема)   -  person jcalz    schedule 02.06.2019
comment
@jcalz Я обновил вопрос, чтобы отразить ваше понимание в комментарии, а также показать более полный пример того, чего я пытаюсь достичь. По какой-то причине у меня возникают проблемы с универсальным типом при создании нового статуса.   -  person Devin    schedule 02.06.2019


Ответы (1)


Я повторю свои комментарии и продолжу оттуда:

Дискриминационные союзы действительно работают так, как вы ожидаете, только с конкретными типами, а не с универсальными. Кроме того, внутри реализации getApiAsyncStatus() тип T является неразрешенным универсальным параметром, и компилятор не выполняет большой работы, пытаясь проверить, может ли значение присваиваться условному типу, зависящему от такого неразрешенного универсального параметра. Лучше всего использовать здесь утверждение типа или что-то подобное, например подпись overload. Преимущество этого условного типа для вызывающих, а не для разработчиков.

Если вы используете TypeScript 3.5 с его более умная проверка типов объединения, вы можете исправить приведенный выше код следующим образом:

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;
};

Расширение type с T до ApiAsyncStatus["_type"] изменяет его с общего типа (для которого отсутствуют дедуктивные навыки компилятора) к конкретному объединению (что лучше). Более умная проверка объединений в TS3.5 необходима для того, чтобы компилятор понял, что значение типа {_type: A | B, error: Error, state: "error"} может быть присвоено переменной типа {_type: A, error: Error, state: "error"} | {_type: B, error: Error, state: "error"}. Для TS3.4 и ниже компилятор вообще не будет выполнять такой анализ. Таким образом, даже указанное выше будет ошибкой в ​​более ранних версиях.

Чтобы поддержать их, вы могли бы просто сделать свои утверждения шире и менее типобезопасными:

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;
};

Ссылка на код

Так что любой из них должен работать в зависимости от используемой вами версии TypeScript. Я склонен рассматривать эту проблему как общий класс проблем, связанных с коррелированными типами, где все было бы хорошо, если бы вы могли убедить компилятор ввести проверку блока кодируйте несколько раз для каждого возможного сужения некоторой переменной типа объединения. В вашем случае для каждого возможного значения T (здесь "LOGIN" и "SEARCH") ваш код должен проверяться нормально. Но, глядя на объединения или общие расширения объединений «сразу», компилятор думает, что некоторые запрещенные ситуации возможны, и отказывается. Боюсь, на этот вопрос нет отличного ответа ... Мой совет - просто выберитесь из этого и двигайтесь дальше.

Хорошо, надеюсь, что это поможет; удачи!

person jcalz    schedule 02.06.2019