TypeScript:如何使泛型类型在函数内部进行推断



我正在努力减少该函数内函数参数的类型。在我看来,每当我执行if检查时,将可能的值缩小到更小的子集,类型检查器都会减少类型。令我惊讶的是,即使我显式检查泛型类型变量是否正好是一个特定值,泛型类型也不会减少。

下面是一个演示问题的示例(请注意FIXMEs):

type NewsId = number
type DbRequestKind =
| 'DbRequestGetNewsList'
| 'DbRequestGetNewsItemById'
type DbRequest<K extends DbRequestKind>
= K extends 'DbRequestGetNewsList'     ? { kind: K }
: K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
: never;
type DbResponse<K extends DbRequestKind>
= K extends 'DbRequestGetNewsList'     ? number[]
: K extends 'DbRequestGetNewsItemById' ? number
: never
function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
if (req.kind === 'DbRequestGetNewsList') {
const result = [10,20,30]
return result as DbResponse<K> // FIXME doesn’t check valid K
} else if (req.kind === 'DbRequestGetNewsItemById') {
// FIXME “Property 'newsId' does not exist on type 'DbRequest<K>'.”
// const result = req.newsId + 10
const result = 10
return result as DbResponse<K> // FIXME doesn’t check valid K
} else {
throw new Error('Unexpected kind!')
}
}
{
const x = dbQuery({ kind: 'DbRequestGetNewsList' })
// Check that response type is inferred
const y: typeof x = [10]
// const z: typeof x = 10 // fails (as intended, it’s good)
console.log('DB response (list):', x);
}
{
const x = dbQuery({ kind: 'DbRequestGetNewsItemById', newsId: 5 })
// Check that response type is inferred
// const y: typeof x = [10] // fails (as intended, it’s good)
const z: typeof x = 10
console.log('DB response (item by id):', x);
}

这只是从 https://github.com/unclechu/typescript-dependent-types-experiment/blob/master/index.ts 中获取的副本。如您所见,这是依赖类型的一个示例。我希望返回类型DbResponse<K>依赖于函数参数DbRequest<K>.

让我们看看FIXMEs:

  1. 例:

    if (req.kind === 'DbRequestGetNewsList') {
    return [10,20,30]
    }
    

    失败,并显示:Type 'number[]' is not assignable to type 'DbResponse<K>'.

    或:

    if (req.kind === 'DbRequestGetNewsItemById') {
    return 10
    }
    

    失败,并显示:Type 'number' is not assignable to type 'DbResponse<K>'.

    但是我明确检查了种类,您可以看到条件:K extends 'DbRequestGetNewsList' ? number[]K extends 'DbRequestGetNewsItemById' ? number.

    在示例中,您可以看到我正在将这些返回的值转换为泛型类型 (as DbResponse<K>),但这会杀死类型。例如,我可以这样做:

    if (req.kind === 'DbRequestGetNewsList') {
    return 10 as DbResponse<K>
    } else if (req.kind === 'DbRequestGetNewsItemById') {
    return [10,20,30] as DbResponse<K>
    }
    

    这是完全错误的,类型检查器只是吞下它而没有声音。

  2. 您可以看到的下一个是Property 'newsId' does not exist on type 'DbRequest<K>'..

    实际上,这可以通过使用 sum-type 而不是类型条件来解决DbRequest<K>。但这会产生另一个问题,其中dbQuery的调用将再次返回泛型类型而不是推断它,因此:

    const x = dbQuery({ kind: 'DbRequestGetNewsList' })
    const y: typeof x = [10]
    const z: typeof x = 10 // FIXME This must fail but it doesn’t with sum-type!
    

我相信这两个问题与同一个来源有关,即即使在对单个特定K进行显式if条件检查后也无法推断dbQuery函数体内的K。这确实有悖常理。它是否适用于任何情况,但不适用于泛型?我可以以某种方式克服这个问题并使类型检查器完成其工作吗?

UPD #1

甚至不可能编写类型证明器:

function proveDbRequestGetNewsListKind<K extends DbRequestKind>(
req: DbRequest<K>
): req is DbRequest<'DbRequestGetNewsList'> {
return req.kind === 'DbRequestGetNewsList'
}

它失败并显示:

A type predicate's type must be assignable to its parameter's type.
Type '{ kind: "DbRequestGetNewsList"; }' is not assignable to type 'DbRequest<K>'.

UPD #2

最初,我的解决方案是建立在重载之上的。它不能解决问题。见 https://stackoverflow.com/a/66119805/774228

考虑一下:

function dbQuery(req: DbRequest): number[] | number {
if (req.kind === 'DbRequestGetNewsList') {
return 10
} else if (req.kind === 'DbRequestGetNewsItemById') {
return [10,20,30]
} else {
throw new Error('Unexpected kind!')
}
}

此代码已损坏。不过类型检查器还可以。

重载的问题在于无法为每个重载提供单独的实现。相反,您提供包含更大类型子集的泛型实现。因此,您失去了类型安全性,它更容易在运行时出错。

除此之外,您必须手动为每种类型提供越来越多的重载(就像在 Go 中一样,meh)。

UPD #3

我通过添加带有类型转换的闭包来改进类型检查。它远非完美,但更好。

function dbNewsList(
req: DbRequest<'DbRequestGetNewsList'>
): DbResponse<'DbRequestGetNewsList'> {
return [10, 20, 30]
}
function dbNewsItem(
req: DbRequest<'DbRequestGetNewsItemById'>
): DbResponse<'DbRequestGetNewsItemById'> {
return req.newsId + 10
}
function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
return (req => {
if (req.kind === 'DbRequestGetNewsList') {
return dbNewsList(req)
} else if (req.kind === 'DbRequestGetNewsItemById') {
return dbNewsItem(req)
} else {
throw new Error('Unexpected kind!')
}
})(
req as DbRequest<'DbRequestGetNewsList' | 'DbRequestGetNewsItemById'>
) as DbResponse<K>;
}

UPD #4

我使用下面@jcalz提出的T[K]黑客稍微改进了最新示例(见 https://stackoverflow.com/a/66127276)。无需为每个kind提供附加功能。

type NewsId = number
type DbRequestKind = keyof DbResponseMap
type DbRequest<K extends DbRequestKind>
= K extends 'DbRequestGetNewsList'     ? { kind: K }
: K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
: never
interface DbResponseMap {
DbRequestGetNewsList: number[]
DbRequestGetNewsItemById: number
}
type DbResponse<K extends DbRequestKind> = DbResponseMap[K]
function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
return (req => {
if (req.kind === 'DbRequestGetNewsList') {
const result: DbResponseMap[typeof req.kind] = [10, 20, 30]
return result
} else if (req.kind === 'DbRequestGetNewsItemById') {
const result: DbResponseMap[typeof req.kind] = req.newsId + 10
return result
} else {
const _: never = req
throw new Error('Unexpected kind!')
}
})(req as DbRequest<DbRequestKind>) as DbResponse<K>
}

UPD #5

还有一个改进。我为闭包的返回类型添加了额外的约束。我还减少了模式中额外实体的数量。

type NewsId = number
type DbRequest<K extends keyof DbResponseMap>
= K extends 'DbRequestGetNewsList'     ? { kind: K }
: K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
: never
interface DbResponseMap {
DbRequestGetNewsList: number[]
DbRequestGetNewsItemById: number
}
function dbQuery<K extends keyof DbResponseMap>(req: DbRequest<K>): DbResponseMap[K] {
return ((req): DbResponseMap[keyof DbResponseMap] => {
if (req.kind === 'DbRequestGetNewsList') {
const result: DbResponseMap[typeof req.kind] = [10, 20, 30]
return result
} else if (req.kind === 'DbRequestGetNewsItemById') {
const result: DbResponseMap[typeof req.kind] = req.newsId + 10
return result
} else {
const _: never = req
throw new Error('Unexpected kind!')
}
})(req as DbRequest<keyof DbResponseMap>) as DbResponseMap[K]
}

正如评论中提到的,TypeScript 并不真正支持依赖类型,尤其是在类型检查函数的实现时,其调用签名暗示了这种依赖关系。 你面临的一般问题在许多GitHub问题中都有提及,特别是microsoft/TypeScript#33014和microsoft/TypeScript#27808。目前,这里的两种主要方法是:编写重载函数并小心实现,或者使用带有类型断言的泛型函数并小心实现。

<小时 />

过载:

对于重载,有意检查实现的方式比调用签名集更宽松。 基本上,只要您返回至少一个调用签名期望的值,该返回值就不会出错。 正如你所看到的,这是不安全的。 事实证明,TypeScript 并不完全安全或可靠;事实上,这显然不是TypeScript 语言的设计目标。 请参阅非目标 #3:

  1. 应用声音或"可证明正确"类型的系统。相反,在正确性和生产力之间取得平衡。

在重载函数的实现中,TS 团队更倾向于生产力而不是正确性。 保证类型安全本质上是实施者的工作;编译器并没有真正尝试这样做。

有关详细信息,请参阅 microsoft/TypeScript#13235。 有人建议抓住这种错误,但该建议以"太复杂"为由结束。 "以正确的方式"进行重载需要编译器做更多的工作,并且没有足够的证据表明这些类型的错误经常发生,足以使增加的复杂性和性能损失变得值得。


泛型函数:

这里的问题是相反的;编译器无法看到实现是安全的,并且会为你返回的任何内容提供错误。 控制流分析不会缩小未解析的泛型类型参数或未解析泛型类型的值。 您可以检查req.kind,但编译器不会使用它来对K类型执行任何操作。 可以说,您无法通过检查类型为K的值来缩小K,因为它可能仍然是完整的并集。

有关此问题的更多讨论,请参阅 microsoft/TypeScript#24085。 以"正确的方式"做这件事需要对泛型的处理方式进行一些根本性的改变。 这至少是一个悬而未决的问题,所以有一些希望在未来可能会有所作为,但我不会依赖它。

如果希望编译器接受它无法验证的内容,则应仔细检查是否正确,然后使用类型断言使编译器警告静音。


对于您的具体示例,我们可以做得更好一点。 TypeScript 尝试对依赖类型进行建模的少数几个地方之一是从文本键类型查找对象属性类型时。 如果你有一个类型为T的值t,以及一个类型为K extends keyof T的键k,那么编译器将理解t[k]T[K]类型。

以下是我们如何重写您正在执行的操作以采用此类对象属性查找的形式:

interface DbRequestMap {
DbRequestGetNewsList: {};
DbRequestGetNewsItemById: { newsId: NewsId }
}
type DbRequestKind = keyof DbRequestMap;
type DbRequest<K extends DbRequestKind> = DbRequestMap[K] & { kind: K };
interface DbResponseMap {
DbRequestGetNewsList: number[];
DbRequestGetNewsItemById: number;
}
type DbResponse<K extends DbRequestKind> = DbResponseMap[K]
function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
return {
get DbRequestGetNewsList() {
return [10, 20, 30];
},
get DbRequestGetNewsItemById() {
return 10; 
}
}[req.kind];
}

在这里,我们将DbRequest<K>表示为具有{kind: K}属性的值,DbResponse<K>表示为类型为DbResponseMap[K]的值。 在实现中,我们使用 getter 制作一个DbResponseMap类型的对象以防止计算整个对象,然后查找其K类型的req.kind属性......获得编译器满意的DbResponse<K>

不过,从远射到远景来看,这并不完美。 在实现中,编译器仍然无法将req缩小到具有newsId属性的任何内容。 所以你会发现自己仍然不安全地缩小范围:

return (req as DbRequest<DbRequestKind> as 
DbRequest<"DbRequestGetNewsItemById">).newsId + 10; //   

所以我认为在实践中你应该选择你的毒药,并处理在你的实现中的某个地方被违反的类型安全。 如果你小心的话,你至少可以为函数的调用者维护类型安全,这是我们无论如何都可以在TypeScript 4.1中得到的最好的。

>游乐场链接到代码

这里有工作代码:

type NewsId = number
type DbRequestKind =
| 'DbRequestGetNewsList'
| 'DbRequestGetNewsItemById'
type DbRequest<K extends DbRequestKind>
= K extends 'DbRequestGetNewsList' ? { kind: K }
: K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
: never;
type DbResponse<K extends DbRequestKind>
= K extends 'DbRequestGetNewsList' ? number[]
: K extends 'DbRequestGetNewsItemById' ? number
: never
type Distributive<T> = [T] extends [any] ? T : never

function dbQuery<K extends DbRequestKind>(req: DbRequest<'DbRequestGetNewsItemById'>): DbResponse<'DbRequestGetNewsItemById'>
function dbQuery<K extends DbRequestKind>(req: DbRequest<'DbRequestGetNewsList'>): DbResponse<'DbRequestGetNewsList'>
function dbQuery(req: DbRequest<DbRequestKind>): Distributive<DbResponse<DbRequestKind>> {
if (req.kind === 'DbRequestGetNewsList') {
const result = [10, 20, 30]
return result // FIXME doesn’t check valid K
} else if (req.kind === 'DbRequestGetNewsItemById') {
const result = req.newsId + 10 // error
//return '2' // error
return 2 // error
} else {
const x = req // never
throw new Error('Unexpected kind!')
}
}

请记住,K extends DbRequestKindDbRequestKind不同,因为K可以更宽。这成功了

最新更新