TypeScript 类型深入探究 - 第 3 部分:函数

2025-06-07

TypeScript 类型深入探究 - 第 3 部分:函数

本文最初发表于Barbarian Meets Coding

TypeScript 是 JavaScript 的一个更现代、更安全的版本,它席卷了 Web 开发领域。它是 JavaScript 的一个超集,添加了一些附加功能、语法糖和静态类型分析,旨在提高您的工作效率并扩展 JavaScript 项目。

这是系列文章第三部分,我们将探讨 TypeScript 全面的类型系统,并学习如何利用它构建非常健壮且易于维护的 Web 应用程序。今天,我们来学习函数

还没看过这系列的第一部分和第二部分吗?如果你还没看过,不妨看看。里面有很多有趣又实用的内容。

函数是 JavaScript 程序最基本的组成元素之一,这一点在 TypeScript 中也丝毫不变。在 TypeScript 中,函数类型最常见的使用方式是内联,与函数本身混合使用。

想象一个简单的 JavaScript 函数来添加几个数字:

function add(a, b){
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

虽然,由于 JavaScript 中没有静态类型,所以没有任何规定说你只能用这个函数添加数字,但你可以添加任何东西(这不一定是错误,而可能是功能)。

add(1, 2)            // => 3
add(1, " banana")    // => "1 banana"
add(22, {"banana"})  // => "1[object Object]"
add([], 1)           // => "1"
Enter fullscreen mode Exit fullscreen mode

然而,在我们的特定情况下,我们试图构建一个神奇的计算器来帮助我们计算烘烤 1 万亿块姜饼所需的面团量(因为我们喜欢圣诞节和烘焙,而且我们将一劳永逸地获得吉尼斯世界纪录)。

所以我们需要ab为数字。我们可以利用 TypeScript 来确保参数和返回类型符合我们的预期:

// Most often you'll type functions inline
function add(a: number, b: number): number{
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

因此,当我们执行此功能时,它仅适用于数字:

add(1, 2)            // => 3
add(1, " banana")    // => 💥
add(22, {"banana"})  // => 💥
add([], 1)           // => 💥
Enter fullscreen mode Exit fullscreen mode

由于 TypeScript 编译器非常智能,它可以推断出两个数字相加的结果类型是另一个数字。这意味着我们可以省略返回值的类型:

function add(a: number, b: number) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

如果你更喜欢箭头函数符号,你可以这样写:

const add = (a: number, b: number) => a + b;
Enter fullscreen mode Exit fullscreen mode

TypeScript 实现了基于控制流的类型分析,更确切地说,它会遍历你的代码,计算并理解不同变量的类型以及操作的结果值。利用这些信息,它可以推断类型并为你节省大量工作(如上所述),甚至可以优化函数主体中的类型,以提供更清晰的错误消息、更高的类型安全性和更完善的语句补全(我们将在后续文章中详细介绍)。

内联函数类型化是目前为止在 TypeScript 中使用函数类型最常用的方式。现在,让我们进一步探讨如何使用参数以及将函数类型化为值来实现不同的功能。

可选参数

JavaScript 函数非常灵活。例如,你可以定义一个带有一组参数的函数,但调用该函数时并不一定需要传入相同数量的参数。

让我们回到这个add函数:

function add(a, b) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

在 JavaScript 中,没有人阻止你像这样调用此函数:

add(1, 2, 3); // => 3
add(1, 2);    // => 3
add(1);       // => NaN
add();        // => NaN
Enter fullscreen mode Exit fullscreen mode

TypeScript 更加严格。它要求你编写更具针对性的 API,这样它才能帮助你遵守这些 API。因此,TypeScript 假设,如果你定义了一个包含两个参数的函数,那么你也会使用这两个参数来调用该函数。这很好,因为如果我们add像这样定义一个函数:

function add(a: number, b: number) {
  return a + b;
}
Enter fullscreen mode Exit fullscreen mode

TypeScript 将确保我们按照代码作者的设计调用该函数,从而避免NaN之前导致的那些可怕的极端情况:

add(1, 2, 3); // => 💥 Expected 2 arguments, but got 3
add(1, 2);    // => 3
add(1);       // => 💥 Expected 2 arguments, but got 1
add();        // => 💥 Expected 2 arguments, but got 0
Enter fullscreen mode Exit fullscreen mode

保持 JavaScript 的灵活性至关重要,因为在某些情况下,参数应该是可选的。TypeScript 可以让你像在 JavaScript 中一样灵活,但你需要有意识地明确定义参数是否可选。

假设我们在应用程序中添加了一些日志功能,以便更好地了解用户如何与其交互。了解用户如何使用我们的应用程序非常重要,这样我们才能做出明智的决定,例如哪些功能更重要或更不重要、更有用或更无用,以及如何让重要功能更容易被发现等等……因此,我们定义了这个日志函数:

function log(msg: string, userId) {
  console.log(new Date(), msg, userId);
}
Enter fullscreen mode Exit fullscreen mode

我们可以像这样使用:

log("Purchased book #1232432498", "123fab");
Enter fullscreen mode Exit fullscreen mode

然而,在我们的系统中,用户无需登录。这意味着 可能userId可用,也可能不可用。也就是说,userId参数是可选的。我们可以在 TypeScript 中使用可选参数来模拟这种情况,如下所示:

// Optional params
function log(msg: string, userId?: string){
  console.log(new Date(), msg, userId ?? 'anonymous user');
}
Enter fullscreen mode Exit fullscreen mode

这样,现在可以调用省略第二个参数的函数:

log("Navigated to about page");
Enter fullscreen mode Exit fullscreen mode

或者使用undefined第二个参数:

// get userId from user management system
// because the user isn't logged in the system
// returns undefined
const userId = undefined;
log("Navigated to home page", userId);
Enter fullscreen mode Exit fullscreen mode

这提示您可选参数是此参数的简写:

function log(msg: string, userId: string | undefined){
  console.log(new Date(), msg, userId ?? 'anonymous user');
}
Enter fullscreen mode Exit fullscreen mode

可选参数必须始终在函数参数列表的末尾声明。这是有道理的,因为如果没有参数,TypeScript 编译器在调用函数时就无法知道要引用哪个参数。如果你在编写函数时恰好犯了这个错误,TypeScript 编译器会立即显示以下信息来帮助你:💥 A required parameter cannot follow an optional parameter.

默认参数

我不太喜欢undefined在我的函数中存在大量的值(出于我们之前讨论过的许多原因),因此如果可能的话,我更喜欢使用默认参数而不是可选参数。

使用默认参数,我们可以将上面的函数重写为:

// Default params
function log(msg: string, userId = 'anonymous user'){
  console.log(new Date(), msg, userId);
}
Enter fullscreen mode Exit fullscreen mode

此函数的行为与我们之前的函数一样:

log("Navigated to about page");
log("Sorted inventory table", undefined);
log("Purchased book #1232432498", "123fab");
Enter fullscreen mode Exit fullscreen mode

但并没有等待发生的空引用异常。

剩余参数

JavaScript 有一个很棒的特性,叫做“剩余参数”,可以用来定义可变参数函数可变参数函数是指具有无限个参数的函数,换句话说,它表示一个函数可以接受任意数量的参数。

假设我们想创建一个记录器,允许我们记录任意数量的事情,并附加一个描述这些事情发生时间的时间戳。在 JavaScript 中,我们可以编写以下函数:

function log(...msgs){
  console.log(new Date(), ...msgs);
}
Enter fullscreen mode Exit fullscreen mode

在 TypeScript 中,由于msgs本质上是一个参数数组,因此我们将对其进行如下注释:

// Typed as an array
function log(...msgs: string[]){
  console.log(new Date(), ...msgs);
}
Enter fullscreen mode Exit fullscreen mode

现在我们可以使用它来传递任意数量的参数:

log('ate banana', 'ate candy', 'ate doritos');
// Thu Dec 26 2019 11:10:16 GMT+0100 
// ate banana
// ate candy
// ate doritos
Enter fullscreen mode Exit fullscreen mode

由于它是一个复杂的可变参数函数,它会直接吞噬所有参数。另外,12月26日星期四是这个家庭的“作弊日”。

将函数类型化为值

好的。到目前为止,我们大部分内容都了解了如何使用函数声明来内联地键入函数。但是 JavaScript 非常非常喜欢函数,并且喜欢将函数作为值来传递,以及从其他函数返回它们。

这是一个作为值的函数(我们将其存储在变量中add):

const add = (a: number, b: number) => a + b;
Enter fullscreen mode Exit fullscreen mode

这个变量的类型是什么add?这个函数的类型是什么?

该函数的类型为:

(a: number, b: number) => number;
Enter fullscreen mode Exit fullscreen mode

这意味着我们可以add像这样重写函数,而不是使用内联类型:

const add : (a: number, b: number) => number = (a, b) => a + b;
Enter fullscreen mode Exit fullscreen mode

或使用别名:

type Add = (a: number, b: number) => number
const add : Add = (a, b) => a + b;
Enter fullscreen mode Exit fullscreen mode

上面这个例子相当有趣,因为类型信息的流动方向与我们习惯的相反。到目前为止,我们一直在右侧的表达式中定义类型,这些类型会流向被赋值的变量(左侧)。然而,在本例中,我们定义了左侧变量的类型,类型会流向右侧的表达式。很有趣,不是吗?

这种能够从上下文中获取类型的特性称为上下文类型,这是一个很棒的特性,因为它提高了 TypeScript 类型推断能力,并且避免了输入比最低要求更多的注释。

如果这听起来很有趣,您可能想看看有关 TypeScript 类型推断的文档

在使用新的成熟类型定义重写函数后,TypeScript 会心领神会,因为它既可以使用内联类型,也可以使用其他单独的类型定义。如果你将两种编写此函数的方式并排比较一下:

// # 1. Inline
const add = (a: number, b: number) => a + b;

// # 2. With full type definition
const add : (a: number, b: number) => number = (a, b) => a + b;
Enter fullscreen mode Exit fullscreen mode

您可能更喜欢选项 1,因为它更简洁易懂,而且类型与它们所应用的参数非常接近,从而更容易理解。那么,选项 2 什么时候有用呢?

当您需要存储函数以及使用高阶函数时,选项 2 或完整类型定义很有用。

高阶函数是指可以接受另一个函数作为参数或返回一个函数的函数。你可以在这篇关于该主题的优秀文章中了解更多关于高阶函数和其他函数式编程概念的知识

让我们用一个例子来说明将函数类型化为值的实用性。假设我们要设计一个仅在某些情况下记录信息的记录器。这个记录器可以建模为如下所示的高阶函数:

// Takes a function as a argument
function logMaybe(
  shouldLog: () => bool,
  msg: string){
    if (shouldLog()) console.log(msg);
}
Enter fullscreen mode Exit fullscreen mode

logMaybe函数是一个高阶函数,因为它接受另一个函数shoudLog作为参数。该shouldLog函数是一个谓词,用于返回是否应该记录某些内容。

我们可以使用这个函数来记录某些怪物是否死于惨痛,就像这样:

function attack(target: Target) {
  target.hp -= 10;
  logMaybe(
     () => target.isDead, 
     `${target} died horribly`
  );
}
Enter fullscreen mode Exit fullscreen mode

另一个有用的用例是创建一个记录器工厂:

type Logger = (msg: string) => void
// Returns a function
function createLogger(header: string): Logger {
    return function log(msg: string) {
       console.log(`${header} ${msg}`);
    }
}
Enter fullscreen mode Exit fullscreen mode

createLogger是一个高阶函数,因为它返回另一个类型的函数,Logger该函数允许你记录字符串。我们可以用它createLogger来创建我们想要的记录器:

const jaimeLog = createLogger('Jaime says:')

jaimeSays('banana');
// Jaime says: banana
Enter fullscreen mode Exit fullscreen mode

TypeScript 非常擅长推断返回类型,所以我们实际上不需要显式地指定返回函数的类型。下面这样也可以:

function createLogger(header: string) {
    return function log(msg: string) {
       console.log(`${header} ${msg}`);
    }
}
Enter fullscreen mode Exit fullscreen mode

函数重载

我有点怀念 C# 等强类型语言的一个特性,那就是函数重载。你可以为同一个函数定义多个签名,接收多个不同类型的参数,调用该函数时,编译器能够区分不同的函数并选择正确的实现。这是一种非常好的方法,可以用略有不同的 API 来解决同一个问题。比如,召集亡灵大军的问题:

raiseSkeleton()
// don't provide any arguments and you raise an skeleton
// => raise a skeleton
raiseSkeleton(4)
// provide a number and you raise a bunch of skeletons
// => raise 4 skeletons
raiseSkeleton('king')
// provide a string and you raise a special type of skeleton
// => raise skeleton king
Enter fullscreen mode Exit fullscreen mode

然而,JavaScript 对函数重载的支持并不完善。你可以在 JavaScript 中模拟函数重载,但这需要大量的样板代码来手动区分不同的函数签名。例如,raiseSkeleton上面函数的一种可能实现如下:

function raiseSkeleton(options) {
  if (typeof options === 'number') {
    raiseSkeletonsInNumber(options)
  } else if (typeof options === 'string') {
    raiseSkeletonCreature(options)
  } else {
    console.log('raise a skeleton')
  }

  function raiseSkeletonsInNumber(n) {
    console.log('raise ' + n + ' skeletons')
  }
  function raiseSkeletonCreature(creature) {
    console.log('raise a skeleton ' + creature)
  }
}
Enter fullscreen mode Exit fullscreen mode

您可以在另一篇文章中阅读有关JavaScript 中函数重载的危险的更多信息。

TypeScript 尝试在某种程度上减轻编写函数重载的负担,但由于它仍然是 JavaScript 的超集,因此并未完全实现。TypeScript 函数重载真正令人欣喜的部分在于其与类型相关的部分。

让我们回到之前示例中使用的日志函数:

function log(msg: string, userId: string){
  console.log(new Date(), msg, userId);
}
Enter fullscreen mode Exit fullscreen mode

该函数的类型可以通过以下别名定义:

type Log = (msg: string, userId: string) => void
Enter fullscreen mode Exit fullscreen mode

此类型定义等同于另一个类型定义:

type Log = {
  (msg: string, id: string): void
}
Enter fullscreen mode Exit fullscreen mode

如果我们想让log函数提供适合不同用例的多个 API,我们可以扩展类型定义以包含多个函数签名,如下所示:

type Log = {
  (msg: string, id: string): void
  (msg: number, id: string): void
}
Enter fullscreen mode Exit fullscreen mode

现在,我们不仅可以像以前一样记录字符串消息,还可以记录消息代码,这些消息代码是混淆为数字的消息,我们可以将其与后端中的特定事件进行匹配。

按照同样的方法,我们的函数的类型定义raiseSkeleton将如下所示:

type raiseSkeleton = {
  (): void
  (count: number): void
  (typeOfSkeleton: string): void
}
Enter fullscreen mode Exit fullscreen mode

我们可以通过这种方式将其附加到实际实现中:

const raiseSkeleton : raiseSkeleton = (options?: number | string) => {
  if (typeof options === 'number') {
    raiseSkeletonsInNumber(options)
  } else if (typeof options === 'string') {
    raiseSkeletonCreature(options)
  } else {
    console.log('raise a skeleton')
  }

  function raiseSkeletonsInNumber(n: number) {
    console.log('raise ' + n + ' skeletons')
  }
  function raiseSkeletonCreature(creature: string) {
    console.log('raise a skeleton ' + creature)
  }
}
Enter fullscreen mode Exit fullscreen mode

不需要创建别名的替代类型定义(但我发现它更冗长)如下:

// Alternative syntax
function raiseSkeleton(): void;
function raiseSkeleton(count: number): void;
function raiseSkeleton(skeletonType: string): void;
function raiseSkeleton(options?: number | string): void {
  // implementation
}
Enter fullscreen mode Exit fullscreen mode

如果我们花一点时间思考一下 TypeScript 中的函数重载,我们可以得出一些结论:

  • TypeScript 函数重载主要影响类型世界
  • 通过查看类型定义,可以非常清楚地看到重载函数支持的不同 API,这非常好
  • 你仍然需要提供一个底层实现来处理所有可能的情况

总而言之,TypeScript 中的函数重载为重载函数的使用者提供了非常好的开发体验,但对于实现该函数的人来说,体验却不那么好。因此,代码作者需要付出一些代价,为该函数的使用者提供更好的 DX。

另一个例子是document.createElement我们在 Web 中创建 DOM 元素时经常使用的方法(尽管在如今框架和高级抽象的时代,我们很少使用这种方法)。该document.createElement方法是一个重载函数,它根据给定的标签创建不同类型的元素:

type CreateElement = {
  (tag: 'a'): HTMLAnchorElement
  (tag: 'canvas'): HTMLCanvasElement
  (tag: 'svg'): SVGSVGElement
  // etc...
}
Enter fullscreen mode Exit fullscreen mode

在 TypeScript 中提供这样的 API 非常有用,因为 TypeScript 编译器可以帮助你完成语句补全(在某些圈子里也称为 IntelliSense)。也就是说,当你使用a标签创建一个元素时,TypeScript 编译器知道它会返回一个,HTMLAnchorElement并且可以为你提供编译器支持,让你只使用该元素中可用的属性,而不使用其他属性。是不是很棒?

论证解构

如今,JavaScript 中实现函数的一种非常流行的模式是参数解构。想象一下,我们有一个冰锥咒语,我们会不时用它来惹恼邻居。它看起来就像这样:

function castIceCone(caster, options) {
  caster.mana -= options.mana;
  console.log(`${caster} spends ${options.mana} mana 
and casts a terrible ice cone ${options.direction}`);
}
Enter fullscreen mode Exit fullscreen mode

我经常用它对付楼上吵闹的邻居,他开派对不让我儿子睡觉。我会砰的一声!!冰淇淋太劲爆了!

castIceCone('Jaime', {mana: 10, direction: "towards the upstairs' neighbors balcony for greater justice"});
// => Jaime spends 10 mana and casts a terrible ice cone
// towars the upstairs' neighbors balcony for greater justice
Enter fullscreen mode Exit fullscreen mode

options但是,如果这个参数对这个函数签名没有任何作用,那就太浪费了。一个更具描述性、更精简的替代方案是利用参数解构来提取我们需要的属性,这样我们就可以直接使用它们了:

function castIceCone(caster, {mana, direction}) {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

这消除了很多噪音,也允许我们内联设置合理的默认值,这是有意义的,因为第二个参数应该是可选的:

function castIceCone(
  caster, 
  {mana=1, direction="forward"}={}) {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

那么,我们如何在 TypeScript 中定义这个参数的类型呢?你可能会想这样写:

function castIceCone(
  caster: SpellCaster, 
  {mana: number, direction:string}): void {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

但这行不通。因为这是合法的 ES2015 解构语法。当你想将对象的属性投影到名称不同的变量时,就会用到这种模式。在上面的例子中,我们将其投影options.mana到名为 的变量中number,然后又投影options.direction到名为 的变量中string。哎呀。

对上述函数进行类型化最常见的方式是为整个参数提供一种类型(就像我们通常对任何其他参数所做的那样):

function castIceCone(
  caster: SpellCaster, 
  {mana=1, direction="forward"}={} : {mana?: number, direction?:string} 
  ): void {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

这两个参数都是可选的,因为它们都有默认值,所以如果函数的用户不想提供它们作为参数,则无需提供。这个例子中有一个你可能没有注意到的特别有趣的地方:函数声明中定义的参数类型与函数内部参数的类型不同。怎么回事?这个函数的调用者和函数体看到的类型是不同的。怎么回事?

  • 的调用者castIceCone认为mana必须是 类型numberundefined。但由于mana具有默认值,因此在函数主体内它将始终是 类型number
  • 同样,函数的调用者将看到directionstringundefined而函数主体知道它始终是 类型string

TypeScript 参数解构很快就会变得非常冗长,因此您可能需要考虑声明一个别名:

type IceConeOptions = {mana?: number, direction?: string}
function castIceCone(
  caster: SpellCaster, 
  {mana=1, direction="forward"}={} : IceConeOptions): void {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

或者完全不使用内联类型:

type castIceCone = (caster: SpellCaster, options: IceConeOptions) => void;

const castIceCone : castIceCone = (
  caster, 
  { mana = 1, direction = "forward" } = {}
  ) => {
  caster.mana -= mana;
  console.log(`${caster} spends ${mana} mana 
and casts a terrible ice cone ${direction}`);
}
Enter fullscreen mode Exit fullscreen mode

总之

JavaScript 函数极其灵活。TypeScript 函数也同样灵活,并且支持 JavaScript 中最常用的函数模式,但它要求你在设计 API 时更加明确、更具针对性。这并非坏事,只是意味着你的 API 仅限于你作为开发者定义的用例。这种额外的约束有助于防止你的 API 被恶意或意外地使用(例如,当一个函数需要两个参数时,却调用了一个没有参数的函数)。

定义函数类型最常见的方式是使用内联类型,将类型放在它们影响的内容(参数和返回值类型)旁边。TypeScript 非常擅长通过分析函数内部发生的事情来推断返回类型,因此在很多情况下,省略返回值也是可以的。

TypeScript 支持 JavaScript 中常见的函数模式。您可以使用可选参数来定义可能接收或不接收参数的函数。您可以使用默认参数、剩余参数和参数解构来编写类型安全的函数。TypeScript 甚至比 JavaScript 更好地支持函数重载。此外,您还可以将函数类型表示为值,这在编写高阶函数时很常见。

总而言之,TypeScript 拥有令人惊叹的功能,可以帮助你编写更健壮、更易于维护的函数。哇喔!

希望你喜欢这篇文章!保重身体,善待身边的人!

文章来源:https://dev.to/vintharas/typescript-types-deep-dive-part-3-functions-3n6l
PREV
想把事情办好?看!终极 Git 系统,搞定一切!
NEXT
TypeScript:JavaScript + 类型 = 卓越的开发人员生产力