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



我想根据提供给函数的参数来区分联合类型,但由于某种原因,我不能对数据形状使用泛型类型。它打破了我的狭窄。您认为我该如何实现这一目标?

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 操场上调试它

在下文中,我将更改类型参数的名称,使其更符合 TypeScript 约定(单个大写字符);Map会变成MTag会变成K(因为它是M的ky),TagValue会变成Vindex会变成IDiscriminatedState会变成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>实用程序类型的Record<K, V>,并且

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

可以省略,转而使用内置的Extract<T, U>实用程序类型作为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那么你根本不需要注释返回类型,因为它将被推断为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应该是完全可区分的联合类型,所以你不能将其限制为Record<K, V>,因为V只是键K的各种可能值之一。 如果将M限制为Record<K, V>,则编译器不会允许您传入state的值,除非它已经知道其tag属性与value的类型相同。 或者,就像您的情况一样,编译器将扩大V,使其成为tag的全套可能性。 哎呀。

那么,如果我们不能约束MRecord<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 中,将其重写为用户定义的类型保护函数会更传统一些,其中inStateOfType()返回一个可用于决定编译器是否可以将state缩小到Record<K, V>boolean

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

操场链接到代码

你试图做一个反向歧视,这真的是不可能的。这是因为您的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,但如果字符串不是有效的键,它将返回unknown,基本上让您知道预期的用途是错误的。您还可以根据需要将其配置为返回未定义或其他内容。

这是很多例子/操场上的代码

这是我个人进行这种类型推理的方法,如果其他人有更好的方法,我很想听听。

最新更新