首页 > 解决方案 > TypeScript:如何使泛型类型在函数内部进行推断?

问题描述

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

这是一个演示问题的示例(注意FIXME):

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>

让我们看一下FIXME

  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>'.

    但是我明确检查了kind,你可以看到条件: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 forDbRequest<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体内的事实。这真的是违反直觉的。它是否适用于任何情况但不适用于泛型?我能以某种方式克服这个问题并让类型检查器完成它的工作吗?dbQueryifK

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 中一样)。

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

T[K]我使用下面@jcalz 提出的 hack稍微改进了最新示例(请参阅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]
}

标签: typescriptgenericstypesdependent-type

解决方案


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


重载:

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

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

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

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


通用函数:

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

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

如果你想让编译器接受它无法验证的东西,你应该仔细检查你做对了,然后使用类型断言来消除编译器警告。


对于您的具体示例,我们可以做得更好。TypeScript 尝试为依赖类型建模的少数地方之一是从文字键类型中查找对象属性类型。如果你有一个t类型的值和一个类型T的键,那么编译器就会理解它是类型的。kK extends keyof Tt[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]。在实现中,我们DbResponseMap使用getter创建一个类型的对象,以防止计算整个对象,然后查找其req.kind类型的属性K... 以获得DbResponse<K>编译器满意的结果。

不过,从长远来看,它并不完美。在实现内部,编译器仍然不能缩小req到任何具有newsId属性的东西。所以你会发现自己仍然不安全地缩小范围:

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

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


Playground 代码链接


推荐阅读