如何根据提供给函数的键、值和形状缩小联合类型?

How can I narrow a union type based on a key, value and a shape provided to a function?

我想根据提供给函数的参数来区分联合类型,但出于某种原因,我不能对数据形状使用通用类型。它打破了我的狭窄。您认为我怎样才能做到这一点?

export type DiscriminateUnionType<Map, Tag extends keyof Map, TagValue extends Map[Tag]> = Map extends Record<
  Tag,
  TagValue
>
  ? Map
  : never;

function inStateOfType<Map extends { [index in Tag]: TagValue }, Tag extends keyof Map, TagValue extends Map[Tag], DiscriminatedState extends DiscriminateUnionType<Map, Tag, TagValue>>(tag: Tag, value: TagValue, state: Map): DiscriminatedState | undefined {
  return state[tag] === value ? state as DiscriminatedState : undefined
}

type State = { type: 'loading', a: string } | { type: 'loaded', b: string } | { type: 'someOtherState', c: string }

export function main(state: State) {
  const loadedState = inStateOfType('type', 'loading', state)

  if (loadedState) {
    loadedState.b // Property 'b' does not exist on type 'State'. Property 'b' does not exist on type '{ type: "loading"; a: string; }'
  }
}

function inStateOfType<Map extends { type: string }, Tag extends 'type', TagValue extends Map[Tag], DiscriminatedState extends DiscriminateUnionType<Map, Tag, TagValue>>(state: Map, value: TagValue): DiscriminatedState | undefined {
  return state['type'] === value ? state as DiscriminatedState : undefined
}

function main(state: State) {
  // { type: "loaded"; b: string }, everything is fine, narrowing works
  // but in this case, inStateOfType function is not generic
  const loadedState = inStateOfType(state, 'loaded')

  if (loadedState) {
    loadedState.b 
  }
}

为了对此进行调查,我创建了一个带有代码的可执行片段,因此您可以在 TS playground

上对其进行调试

你试图做一种反向歧视,这真的不可能。这是因为您的 TagValue extends Map[Tag],其中 Tag = type 不幸的是总是会产生联合 'loaded' | 'loading' | 'someOtherValue'。当你 extend Map[Tag] 它本身就是一种缩小,所以 TS 不会进一步缩小它,因为 'loaded' 会完成联合,它会通过那里,但然后继续通过整体加入 DiscriminatedState.

因此,当它尝试在 DiscriminateUnionType<> 中使用 TagValue 时,它会传递整个联合并且根本不会缩小它。

而不是 TagValue extends Map[Tag] 你应该允许任何字符串,然后有条件地检查它。这样你的缩小发生在通用之外,TS 可能会错误地推断和缩小它。

function inStateOfGenericTypeFix<
  Map extends Record<string, any>, 
  Tag extends keyof Map, 
  TagValue extends string = Map[Tag], 
> (tag: Tag, value: TagValue, state: Map): 
  TagValue extends Map[Tag] 
  ? DiscriminateUnionType<Map, Tag, typeof value> 
  : unknown
  | undefined 
{
  return state[tag] === value ? state as any : undefined
}

  const loadedState = inStateOfGenericTypeFix('type', 'loaded', state)
  //  ^?

  if (loadedState) {
    loadedState.b //No more error!
  }

不幸的是,这意味着您可以将任何字符串传递给 value,但如果该字符串不是有效键,它将 return unknown,基本上让您知道预期用途是错误的。您也可以根据需要将其配置为 return undefined 或其他内容。

Here is a lot of examples/the code on Playground

这是我进行这种类型推断的个人方法,如果有人有更好的方法,我很乐意听听。

在接下来的内容中,我将更改您的类型参数的名称,使其更符合 TypeScript 约定(单个大写字符); Map 将变为 MTag 将变为 K(因为它是 Mkey), TagValue 将变为 Vindex 将变为 I,而 DiscriminatedState 将变为 S。所以现在我们有:

function inStateOfType<
  M extends { [I in K]: V },
  K extends keyof M,
  V extends M[K], 
  S extends Extract<M, Record<K, V>>
>(tag: K, value: V, state: M): S | undefined {
  return state[tag] === value ? state as S : undefined
}

并注意 { [I in K]: V } 等同于 Record<K, V> 使用 the Record<K, V> utility type 并且

type DiscriminateUnionType<M, K extends keyof M, V extends M[K]> =
  M extends Record<K, V> ? M : never;

可以省去 built-in Extract<T, U> utility type 作为 Extract<M, Record<K, V>>,所以现在我们有:

function inStateOfType<
  M extends Record<K, V>,
  K extends keyof M,
  V extends M[K], S extends Extract<M, Record<K, V>>
>(tag: K, value: V, state: M): S | undefined {
  return state[tag] === value ? state as S : undefined
}

我们几乎完成了清理工作,可以回答问题了。还有一件事; S 类型参数是多余的。它没有好的推理站点(没有参数是 S 类型或 S 的函数)所以编译器将退回到让 S 正好是 Extract<M, Record<K, V>> ,这意味着它只是它的同义词。

如果你要写 return xxx ? yyy as S : undefined 那么你根本不需要注释 return 类型,因为它将被推断为 S | undefined.

因此您可以编写以下内容并使一切正常工作(或无法正常工作):

function inStateOfType<
  M extends Record<K, V>,
  K extends keyof M,
  V extends M[K]
>(tag: K, value: V, state: M) {
  return state[tag] === value ?
    state as Extract<M, Record<K, V>> :
    undefined
}

那为什么不起作用?这里的大问题是 M 应该是完整的 discriminated union type, so you can't constrainRecord<K, V>,因为 V 只是键 [=20] 的各种可能值之一=].如果您将 M 限制为 Record<K, V>,那么编译器不会让您为 state 传递一个值,除非它已经知道它的 tag 属性 是与 value 类型相同。或者,就像您的情况一样,编译器将扩​​大 V,使其成为 tag 的全部可能性。哎呀

那么,如果我们不能将 M 限制为 Record<K, V>,那么 应该 将其限制为什么?它在 K 处需要一个键,但是那里的值类型应该只被限制为一个可行的判别式 属性。像

type DiscriminantValues = string | number | boolean | null | undefined;

让我们试试看:

function inStateOfGenericType<
  M extends Record<K, DiscriminantValues>,
  K extends keyof M,
  V extends M[K]
>(tag: K, value: V, state: M) {
  return state[tag] === value ?
    state as Extract<M, Record<K, V>> :
    undefined
}

function main(state: State) {
  const loadedState = inStateOfGenericType('type', 'loaded', state)

  if (loadedState) {
    loadedState.b // okay
  }
}

就是这样!


请注意,在 TypeScript 中,将其重写为 user defined type guard function 更为传统,其中 inStateOfType() return 是一个 boolean,可用于决定是否编译器可能会将 state 缩小为 Record<K, V> 或不缩小:

function inStateOfGenericType<
  M extends Record<K, DiscriminantValues>,
  K extends keyof M,
  V extends M[K] 
>(tag: K, value: V, state: M):
  state is Extract<M, Record<K, V>> {
  return state[tag] === value
}

function main(state: State) {
  if (inStateOfGenericType('type', 'loaded', state)) {
    state.b // okay
  }
}

Playground link to code