【问题标题】:TypeScript: How to make generic type to infer inside a function?TypeScript:如何使泛型类型在函数内部进行推断?
【发布时间】:2021-05-13 02:14:12
【问题描述】:

我正在努力减少该函数中函数参数的类型。在我看来,每当我执行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&lt;K&gt; 取决于函数参数 DbRequest&lt;K&gt;。

让我们看看FIXMEs:

  1. 例子:

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

    失败:Type 'number[]' is not assignable to type 'DbResponse&lt;K&gt;'.

    或者:

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

    失败:Type 'number' is not assignable to type 'DbResponse&lt;K&gt;'.

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

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

    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&lt;K&gt;'.。

    实际上,这可以通过对DbRequest&lt;K&gt; 使用 sum-type 而不是类型条件来解决。但这会产生另一个问题,即调用 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!
    

我相信这两个问题与同一来源有关,即dbQuery 函数体内的K 即使在显式if-条件检查单个特定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>'.

更新 #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

我使用@jcalz 下面提出的T[K] 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>
}

更新 #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#13995 并建议类型断言(您所谓的“强制转换”)可能是最接近您想要的行为的,因为编译器无法为您验证类型安全性。我可以看的更详细,但当人们似乎在这里投反对票时,我有点不愿意卷入这场争论中
  • @jcalz 谢谢你的回答,很高兴至少得到一个答案,这在技术上是不可能的。虽然我不明白对否决票的担忧。如果你不喜欢他们的工作方式,那就怪 SO。我的意思是赞成表示“有用”,反对表示“无用”,对吗?这就是人们如何知道哪个答案可以解决问题,哪个不能解决问题的方式,对吗?

标签: typescript generics types dependent-type


【解决方案1】:

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


重载:

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

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

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

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


通用函数:

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

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

如果您希望编译器接受它无法验证的内容,您应该仔细检查您所做的是否正确,然后使用type assertion 来消除编译器警告。


对于您的具体示例,我们可以做得更好一点。 TypeScript 尝试对依赖类型建模的少数地方之一是 looking up 来自文字键类型的对象属性类型。如果您有 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&lt;K&gt; 表示为具有{kind: K} 属性的值,将DbResponse&lt;K&gt; 表示为DbResponseMap[K] 类型的值。在实现中我们用getters创建了一个DbResponseMap类型的对象,以防止整个对象被计算,然后查找它的K类型的req.kind属性...得到一个DbResponse&lt;K&gt;编译器很满意。

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

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

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


Playground link to code

【讨论】:

  • T[K] 的破解很聪明。但只要我能做到这一点:get DbRequestGetNewsList() { return [(req as DbRequest&lt;DbRequestKind&gt; as DbRequest&lt;'DbRequestGetNewsItemById'&gt;).newsId + 10] },即运行时的NaN,我就不满意了。感谢您提供非常好的和详细的答案!
  • 参见主题中的 UPD #4 部分。我使用您的T[K] hack 稍微改进了我的最新示例。这是我认为最安全的模式。
  • 我什至在 UPD #5 部分添加了一项改进,闭包返回类型约束。我还减少了额外实体的数量。
【解决方案2】:

这里有工作代码:

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 DbRequestKind 与 DbRequestKind 不同,因为 K 可以更宽。这成功了

【讨论】:

  • 我只是给你一个例子来说明问题出在哪里,但同样的问题在主题中也得到了证明,我在上面的评论中也提到了重载(参见 UPD #2 部分)。我的示例编译,这就是问题所在。因为我为“新闻项目”响应返回number[],而为“新闻列表”返回number,这是绝对错误的。我在我的第一个初始示例中以及在 UPD #2 部分中准确地证明了这一点。我投了反对票,因为它无助于解决问题。不适合我,也不适合其他任何人,投票正是为了这个目的。
猜你喜欢
  • 2020-09-09
  • 2020-04-21
  • 1970-01-01
  • 2019-09-17
  • 2016-12-05
  • 2017-10-04
  • 1970-01-01
  • 2020-09-02
  • 2020-01-23
相关资源
最近更新 更多