我们在第 1 章中介绍了 TypeScript 的基础知识。现在,让我们更进一步,学习一些在使用 TypeScript 开发任何实际应用程序时都必不可少的高级功能。在本章中,我们将构建一些基础类型,这些类型将用于我们的后端和前端应用程序,同时,我们将学习以下概念:

• 高级类型技术

• 从其他类型创建类型

• 接口和面向对象编程(OOP)特性

• 泛型

• Promise

我们将从高级类型技术开始,这将包括类型缩小、空值类型和函数类型。

技术要求

在本章中,我们需要安装 TypeScript。我鼓励你尝试这里提供的代码,并自己编写示例来更好地理解我即将介绍的技术的原理。

你可以通过在你选择的终端中运行以下命令来全局安装 TypeScript:

$ npm install -g typescript

现在,你已经在系统范围内安装了 tsc 命令行工具。另外,我们在创建项目时需要为项目提供 TypeScript 配置。让我们创建一个名为 Chapter02 的文件夹,并在其中创建一个 tsconfig.json 文件。将以下配置放入其中: Tsconfig. Json

{
    "compilerOptions": {
        "module": "es2022",
        "target": "es2017",
        "strictNullChecks": true
    },
    "includes": [
        "main.ts"
    ]
}

这个配置是一个最小化的配置,用于将我们的 TypeScript 代码编译为 JavaScript,它还要求对空值进行严格检查,我们在上一章中提到过这一点。

要编译代码,你可以在 Chapter02 文件夹内的终端中运行以下命令:

$ tsc -p tsconfig.json

我们将把本章提到的所有代码都放在 main.ts 文件中。

我们在本章中讨论的项目代码也可以在这个仓库中找到: [第二章的源代码]( https://github.com/PacktPublishing/Full-Stack-Web-Development-withJ- TypeScript-5/tree/main/Chapter02)

高级类型技术

我们将从更高级的类型技术开始本章的内容。我们将探索一些关键概念,包括类型缩小(narrowing)、空值类型(null types)、函数类型(function types),以及一系列工具类型,例如 PartialReadonlyRequiredPickRecordOmit。我们将首先探讨的技巧是类型缩小,它将帮助我们限制一个类型可以是什么。

类型缩小 (Narrowing)

当我们谈论类型缩小的时候,我们指的是从一个不太精确的类型向一个更精确的类型转变的过程。例如,一个最初类型为 anyunknown 的变量可以被缩小为更具体的类型,如 stringnumber 或自定义类型。

让我们深入一个实际的例子,来说明类型缩小在现实场景中是如何工作的。在下面的代码中,我们将讨论一个 narrowToNumber 函数和一个 getChatMessagesWithNarrowing 函数,这两个函数都使用了类型缩小:

function narrowToNumber(value: unknown): number {
    if (typeof value !== 'number') {
        throw new Error('Value is not a number');
    }
    return value;
}

这个函数接受一个 unknown 类型的参数,并确保这个参数确实是一个数字。在 if 语句中使用 typeof 是一个经典的类型守卫(type guarding)的例子,也就是一种类型缩小的形式。如果值不是 number 类型,就会抛出一个错误;否则,函数安全地返回该值,此时可以确定它是一个数字。这是 TypeScript 中运行时类型检查的一个经典例子。

让我们来看一个如何在后端获取消息的例子。你不需要了解这个例子中后端相关代码的许多细节,但我将解释其中的要点和相关部分:

async function getChatMessagesWithNarrowing(chatId: unknown, req: {
    authorization: string
}) {
    const authToken = req.authorization;
    const numberChatId = narrowToNumber(chatId);
    const messages = await chatService.getChatMessages(numberChatId, authToken);
    if (messages !== null) {
        messages.map((message) => {
            console.log(`Message ID: ${message.id}, Feedback: ${message.feedback?.trim() ?? "no feedback"}`);
        });
        return { success: true, messages };
    } else {
        return { success: false, message: 'Chat not found or access denied' };
    }
}

在这里,chatIdunknown 类型传递给函数。然而,在我们的场景中,chatId 被期望是一个数字。这就是 narrowToNumber 函数发挥作用的地方。将其应用于 chatId,我们将类型从宽泛的 unknown 类型缩小到更具体的 number 类型。在代码的后续部分,我们遇到了以下类型缩小的实例:

if (messages !== null) {
    // ...
}

这一行是一个微妙但至关重要的类型缩小的例子。在这里,我们在继续之前检查 messages 是否不为 null。这一行为有效地将 messages 的类型从可能是 null 或消息数组缩小到确定是一个消息数组。这确保了 if 块内的操作是安全的,不会因为尝试访问 null 的属性而导致 TypeError 错误。

现在,让我们在下一节中看看另一种类型特性——空值类型。

空值类型 (Null Types)

在 JavaScript 和 TypeScript 中,null 是一个表示有意不存在任何对象值的原始值。当 TypeScript 的严格空值检查被启用时(这是非常推荐的),如果变量打算持有空值,它们必须被显式地类型化为包含 nullundefined。这与 JavaScript 形成对比,在 JavaScript 中,变量可以隐式地为 nullundefined,而没有这种严格的类型区分,如果不小心管理,通常会导致意外的错误。这迫使开发人员有意识地处理空值情况,从而导致更健壮、更抗错误的代码。

我们在上一节中已经看到了它的实际应用,但让我们更仔细地看看:

if (messages !== null) {
    // ...
}

在这里,messages !== null 的空值检查是处理空值类型的一个直接应用。在这种情况下,messages 被期望是一个数组或 null。这个检查确保了只有当 messages 确实是一个数组而不是 null 时,后续代码才会运行。这是一种简单而有效的方法,用于防止与空值相关的错误。

接下来,让我们再看一个代码片段:

console.log(`Message ID: ${message.id}, Feedback: ${message.feedback?.trim() ?? "no feedback"}`);

在这行代码中,? 可选链操作符和 ?? 空值合并操作符的使用,为处理空值提供了一种强大的组合方式,有助于避免运行时错误,即在我们期望得到一个值时却得到了 null。表达式 message.feedback?.trim() 只有在 feedback 不是 nullundefined 时才会尝试调用 trim() 方法,从而避免了潜在的运行时错误。如果 feedback 是空值(即 nullundefined),空值合并操作符就会发挥作用,提供一个“无反馈”的默认值。

在 TypeScript 中,空值类型不仅仅是一种语言特性,它还代表着一种思维方式的转变,促使我们采用更安全、更可预测的编码实践。因为在 JavaScript 中非常常见的错误“Uncaught TypeError: Cannot read properties of null”在 TypeScript 中完全被消除了。

接下来,在下一节中,让我们来看看函数类型。

函数类型

函数类型允许你指定一个函数的确切形式:它的输入参数类型和返回类型。当你在代码中将函数作为参数传递,或者创建一个函数类型的常量时,这个特性非常有用。

让我们来看一个例子,展示如何在代码中记录每条消息的详细信息时使用函数类型:

type MapCallback = (message: IMessage) => void;
 
const logMessage: MapCallback = (message) => {
    console.log(`Message ID: ${message.id}`);
};
 
messages.map((message: IMessage) => {
    logMessage(message);
});

在这里,MapCallback 是一个函数类型定义。它告诉 TypeScript,任何具有这种类型的函数都应该接受一个参数 message,其类型为 IMessage,并且它不应该返回任何内容(void)。这个函数类型成为创建具有这种特定结构的函数的模板。

logMessage 是一个明确声明为 MapCallback 类型的函数。这意味着 logMessage 必须符合 MapCallback 定义的结构——它接受一个 IMessage 对象作为参数,并且不返回任何内容。TypeScript 会强制执行这种结构,确保 logMessage 根据定义的函数类型正确使用。

最后,logMessagemap 函数中被使用。messages 中的每个项目(假设是一个 IMessage 对象的数组)都被传递给 logMessage,它遵循 MapCallback 定义的结构。这确保了该函数正确应用于每种消息类型,并且我们只能将 IMessage 类型的参数传递给 logMessage 函数。

有了这些知识,我们就可以继续下一个主题了:从其他类型创建类型。这将帮助我们修改现有的类型以获得新的类型。

从其他类型创建类型

在本节中,我们将介绍一些将一种类型适配为另一种类型的技术,例如工具类型(Utility Types)、联合类型(Union Types)和交叉类型(Intersection Types)。我们将首先展示 TypeScript 中用于此目的的一些有用的工具函数。

工具类型

工具类型是由 TypeScript 语言提供的一组类型,用于将现有的类型转换为新的、经过修改的版本。它们提供了一种便捷的方式来改变类型的属性,例如将属性设为可选的、只读的,或者排除某些属性等。让我们来介绍其中的几个:

Pick<Type, Keys>Pick 通过从现有类型中选择一组属性来构造一个新类型。当你需要一个只包含父类型中特定属性的类型时,Pick 非常有用。它有助于创建更专注、更简洁的类型。以下是一个使用 Pick 的例子:

  interface IUser {
      id: number;
      name: string;
      email: string;
  }
 
  type UserPreview = Pick<IUser, 'id' | 'name'>;
 
  const userPreview: UserPreview = { id: 1, name: 'John' };

在这里,UserPreview 只包含 IUser 中的 idname 属性。

Record<Keys, Type>Record 生成一个带有键集合的类型,并为这些键的值分配统一的类型。它非常适合创建键具有共同值类型的对象,通常用于映射或查找目的。以下是一个使用 Record 的例子:

  type UserId = string;
 
  type UserNamesById = Record<UserId, string>;
 
  const userNamesById: UserNamesById = { '1': 'John', '2': 'Alice' };

在这里,UserNamesById 是一个字典对象,将 UserId 键映射到字符串类型的名称。

Partial<Type>Partial 将给定类型的所有属性都变为可选属性。在更新对象的部分属性时,Partial 非常有用,因为你不需要提供所有属性。它为对象的创建提供了灵活性。以下是如何在代码中使用 Partial 的示例:

  type PartialIUser = Partial<IUser>;
 
  const partialUser: PartialIUser = { id: 1 };

PartialIUser 允许使用 IUser 的任意属性组合,包括不完整的对象,因为所有属性都是可选的。

Required<Type>Required 将类型的所有可选属性都变为必选属性。RequiredPartial 的对立面。它强制要求类型的所有属性都必须提供,确保对象定义的完整性。以下是一个使用 Required 的例子:

  type RequiredIUser = Required<PartialIUser>;
 
  const requiredUser: RequiredIUser = { id: 1, name: 'John', email: 'john@example.com' };

在这里,RequiredIUser 要求所有属性,包括在 PartialIUser 中可选的属性,都必须存在。

Omit<Type, Keys>Omit 通过从现有类型中排除指定的属性来创建一个新类型。Omit 适用于创建一个排除了某些属性的类型,这在排除敏感或不必要的属性时特别有用。以下是一个使用 Omit 的例子:

  type UserWithoutEmail = Omit<IUser, 'email'>;
 
  const userWithoutEmail: UserWithoutEmail = { id: 2, name: 'Alice' };

在这里,UserWithoutEmail 是一个类似于 IUser 的类型,但不包含 email 属性。

Readonly<Type>Readonly 在创建后使类型的属性不可变。Readonly 用于创建对象属性在创建后不应被更改的类型,这对于维护某些对象的完整性很重要。以下是一个使用 Readonly 的例子:

  type ReadonlyIUser = Readonly<IUser>;
 
  const user: ReadonlyIUser = { id: 1, name: 'John', email: 'john@example.com' };

在这里,ReadonlyIUser 保证一旦创建了 IUser 对象,其属性就无法被更改,因为我们无法在之后为其分配新字段。

介绍完这些工具类型后,让我们在下一节中看看如何通过联合类型将类型组合在一起。

联合类型 (Union Types)

联合类型通过竖线符号(|)表示,允许一个变量持有两种或多种类型的值的组合,为定义可以接受多种特定类型值的类型提供了灵活性。在变量或函数返回类型不限于单一类型的情况下,联合类型非常有用。

让我们将这个概念应用到提供的例子中,并为我们的 IMessage 接口创建一个类型:

type MessageType = "user" | "system";

在这里,MessageType 是一个联合类型,意味着它可以持有 "user""system" 的值。

接下来,我们将它添加到我们的接口中,如下所示:

interface IMessage {
    type: MessageType;
    // 其他属性
}

IMessage 接口中,type 属性必须是 "user""system",遵循 MessageType 联合类型。然而,联合类型不必是原始值。现在,让我们看看如何处理从函数返回联合类型的情况。以下的 getChatFromDb 函数展示了联合类型在函数返回值中的常见用例:

type DbChatSuccessResponse = {
    success: true;
    data: IChat;
};
 
type DbChatErrorResponse = {
    success: false;
    error: string;
};
 
function getChatFromDb(chatId: string): DbChatSuccessResponse | DbChatErrorResponse {
    const findChatById = (_: string) => ({} as IChat);
    const chat = findChatById(chatId);
    if (chat) {
        return {
            success: true,
            data: chat,
        };
    } else {
        return {
            success: false,
            error: "Chat not found in the database",
        };
    }
}

getChatFromDb 可以返回 DbChatSuccessResponse(表示成功操作)或 DbChatErrorResponse(包含失败响应的属性)。这种方法在错误处理和数据获取中非常有用,因为结果可能会有很大差异。现在,让我们通过以下代码中的类型缩小来处理函数的联合类型结果:

const dbResponse = getChatFromDb("chat123");
if (dbResponse.success === true) {
    console.log("Chat data:", dbResponse.data);
} else {
    console.error("Error:", dbResponse.error);
}

在这个代码片段中,getChatFromDb 的响应是一个成功对象或一个错误对象。if dbResponse.success === true 检查有效地区分了这两种可能的返回类型。如果 successtrue,TypeScript 理解 dbResponse 符合 DbChatSuccessResponse,并允许访问 dbResponse.data。否则,它将 dbResponse 视为 DbChatErrorResponse,暴露 error 属性。

接下来,让我们看看一种类似的技术,称为类型交叉(Type Intersections)。

类型交叉 (Type Intersections)

类型交叉是一种特性,它创建了一个新类型,该类型包含组合类型的所有属性。它通过符号“&”表示,特别适用于从较简单的类型组合出复杂的类型。在以下代码中,我们将为一个数据库聊天实体创建一个类型,该类型还必须有一个 id 值:

type IDBEntityWithId = {
    id: number;
};
 
type IChatEntity = {
    name: string;
};
 
type IChatEntityWithId = IDBEntityWithId & IChatEntity;
 
const chatEntity: IChatEntityWithId = {
    id: 1,
    name: "Typescript tutor",
};

在这里,chatEntity 被声明为 IChatEntityWithId 类型,因此它必须同时包含来自 IDBEntityWithIdid 和来自 IChatEntityname。这说明了交叉类型是如何强制要求组合类型的所有属性都必须存在。我们也可以通过扩展接口来实现类似的功能,我们将在接下来的主题中展示。

至此,我们已经涵盖了高级类型技术的核心部分,接下来我们可以进一步探讨如何通过面向对象编程(OOP)和接口来编写可重用和可扩展的代码。

接口和面向对象编程 (OOP)特性

OOP 和接口都服务于几个非常重要的目的——它们有助于清晰定义传递的对象的结构、重用代码以及编写可扩展的功能。我们之前已经讨论过接口,现在,让我们来谈谈如何扩展它们的定义。

接口扩展 (Interfaces)

扩展接口允许创建新的接口,这些接口继承现有接口的属性,从而增强代码的可重用性和组织性。让我们以前面用过的一个例子为基础,但这次使用接口扩展:

interface IMessage {
    id: number;
    chatId: number;
    userId: number;
    content: string;
    createdAt: Date;
}
 
interface IMessageWithType extends IMessage {
    type: MessageType;
}
 
const userMessage: IMessageWithType = {
    id: 10,
    chatId: 2,
    userId: 1,
    content: "Hello, world!",
    createdAt: new Date(),
    type: "user",
};

IMessageWithType 接口扩展了 IMessage 接口,这意味着它包含了 IMessage 的所有属性,以及在 IMessageWithType 中定义的任何额外属性。在这里,IMessageWithType 在现有的结构中添加了 type 属性,其类型为 MessageType

当创建一个类型为 IMessageWithTypeuserMessage 对象时,需要包含 IMessageIMessageWithType 中的所有属性。

现在,让我们来看看 TypeScript 中存在的面向对象编程(OOP)功能。

面向对象编程 (OOP)功能

面向对象编程(OOP)是一种编程范式,它通过对象和类来创建基于现实世界的模型。TypeScript 支持面向对象编程的核心原则,允许开发者使用多态性(Polymorphism)、抽象性(Abstraction)、继承性(Inheritance)和封装性(Encapsulation),并通过原生语法实现这些功能。让我们详细了解一下这些功能:

• 多态性(Polymorphism):允许子类的对象被视为一个共同超类的对象。它涉及到创建一个结构,使得一个函数可以互换地使用超类的子类。多态性主要通过接口和抽象类实现。通过定义一个共同的接口或抽象类,TypeScript 允许不同的类实现相同的结构或方法,从而使函数能够像处理基类一样处理这些不同类的对象。

• 抽象性(Abstraction):TypeScript 通过抽象类和接口实现抽象。这些构造允许你定义一个标准模板或契约,其他类可以实现它,封装复杂的逻辑并只暴露必要的部分。

• 继承性(Inheritance):这是一种新类扩展(继承)一个现有类的机制,允许代码重用,并在类之间创建层次关系。在 TypeScript 中,通过 extends 关键字实现继承。一个类可以扩展另一个类,继承其属性和方法。

• 封装性(Encapsulation):涉及将数据和操作数据的方法捆绑在一个单元内,通常是类,并限制对对象某些组件的访问,从而确保数据完整性。TypeScript 通过访问修饰符(如 publicprivateprotected)实现封装。这些修饰符控制类成员的可见性和可访问性,确保类的内部细节被隐藏,并且只通过定义的接口暴露。以下是访问修饰符的描述:

public:这是类成员的默认访问级别。声明为 public 的成员可以从任何地方访问,意味着没有访问限制。这包括从类本身、类的实例以及子类中访问。

private:声明为 private 的成员只能从类本身访问。它们不能从类的实例或子类中访问。这种访问级别用于隐藏类的内部状态和功能,防止外部访问,从而强制实现封装。

protected:声明为 protected 的成员可以在类本身和子类中访问。然而,它们不能从类的实例中访问(除非通过类或子类中定义的方法)。这允许更受控的访问形式,适用于子类需要对超类有更深入了解但又不想将成员暴露给公众的情况。

让我们来看一个结合了这些技术的例子。我们将定义一个带有公共方法和抽象方法的 AbstractDatabaseResource 抽象类。一个具体的 InMemoryChatResource 类将扩展这个抽象类,并为在内存中存储聊天提供具体的实现:

abstract class AbstractDatabaseResource {
    constructor(protected resourceName: string) {
    }
 
    protected logResource(resource: { id: number }): void {
        console.log(`[${this.resourceName}] Resource logged:`, resource);
    }
 
    abstract get(id: number): { id: number } | null;
 
    abstract getAll(): { id: number }[];
 
    abstract addResource(resource: { id: number }): void;
}
 
const inMemoryChatResource = new InMemoryChatResource();
 
const chat1: IChat = {
    id: 1,
    ownerId: 2,
    messages: []
};
 
inMemoryChatResource.addResource(chat1);
 
const retrievedChat1 = inMemoryChatResource.get(1);

让我们讨论一下这里使用到的各种技术:

• 抽象性(Abstraction):AbstractDatabaseResource 类提供了用于管理数据库元素的外部方法(如 getgetAlladdResource 方法),同时隐藏了具体实现的复杂性。AbstractDatabaseResource 类的用户只需要关注接口——可用的方法及其接受的参数——而不是这些方法是如何实现的。这种关注点分离(Separation of Concerns, SoC)使得系统更容易理解和使用。

• 继承性(Inheritance):InMemoryChatResource 是一个具体的类,它扩展了 AbstractDatabaseResource。这意味着它继承了其属性和方法,但同时也为基类中定义的抽象方法提供了具体的实现。

• 封装性(Encapsulation):在 InMemoryChatResource 中,resources 数组被标记为 private,这意味着它不能从类的外部直接访问。这种封装确保了聊天资源的内部表示对外部使用是隐藏的。抽象类中的 logResource 方法被标记为 protected,允许它在类及其子类中被访问,但不能在外部访问。

• 多态性(Polymorphism):尽管 InMemoryChatResource 对方法(如 addResourceget)有不同的实现,但它们可以在期望 AbstractDatabaseResource 类的上下文中互换使用。这就是多态性,不同类的对象可以被视为一个共同超类的对象。

• 实例化和使用:我们创建了一个 InMemoryChatResource 的实例,并用它来添加和检索聊天数据。尽管底层实现是具体的(内存中的数组),但代码只依赖于共享的抽象类结构定义来了解它可以检索的方法和属性。

使用接口和类时,重要的是要适当使用。一般来说,利用接口来定义契约和数据的形状,确保实现之间的一致性,并便于轻松重构。使用类来封装数据和行为,利用继承和多态性来促进代码重用和可维护性,同时保持类定义的专注性,避免过于复杂的继承层次。接口适用于定义你将在函数参数中操作的抽象结构,而类则非常适合封装复杂性,并提供一种简单直接的方式来与类的逻辑交互。

现在,我们可以继续我们之前介绍的类代码,以涵盖两个额外的概念:泛型(Generics)和 Promise。

泛型 (Generics)

TypeScript 中的泛型是一种用于创建可重用组件的工具,这些组件可以与多种类型而不是单一类型一起工作。这不仅允许更好地抽象组件,同时还能保持类型安全性。它还有助于编写更灵活的代码,使其能够适应不同的类型。让我们从这里给出的基本示例开始:

function printValue<T>(value: T): void {
    console.log(value);
}
 
printValue<number>(123);
printValue<string>("Hello");

<T> 是一个泛型类型参数,允许这个函数接受任何类型的值。当你调用 printValue<number>(123)printValue<string>("Hello") 时,TypeScript 分别将 T 视为 numberstring。这展示了泛型如何在不失去类型检查好处的情况下提供灵活性。

我们还可以改进之前定义的 InMemoryChatResource。它现在运行良好,但如果我们还需要一个用于 IUser 的内存实现 InMemoryUserResource,除了 IChat 之外呢?它们基本上具有相同的功能;它们之间的唯一区别是它们处理的资源类型。泛型在这里可以提供帮助。为了说明这一点,让我们定义一个泛型 GenericsInMemoryResource 类,并在这里创建它的聊天和用户实例:

class GenericsInMemoryResource<T extends { id: number }> extends AbstractDatabaseResource {
    private resources: T[] = [];
    constructor(resourceName: string) {
        super(resourceName);
    }
    get(id: number): T | null {
        const resource = this.resources.find((item) => item.id === id);
        return resource ? { ...resource } : null;
    }
    getAll(): T[] {
        return [...this.resources];
    }
    addResource(resource: T): void {
        this.resources.push(resource);
        this.logResource(resource);
    }
}
 
const userInMemoryResource = new GenericsInMemoryResource<IUser>('user');
const chatInMemoryResource = new GenericsInMemoryResource<IChat>('chat');
 
userInMemoryResource.addResource({ id: 1, name: 'Admin', email: 'admin@admin.com' });
chatInMemoryResource.addResource({ id: 10, ownerId: userInMemoryResource.get(1)!.id, messages: [] });

<T extends { id: number }> 的语法表示 T 可以是任何类型,但它必须有一个类型为 numberid 属性。这在允许不同资源类型的同时确保了类型安全。与仅限于处理 IChat 对象的 InMemoryChatResource 不同,GenericsInMemoryResource 可以处理满足约束条件的任何类型。这消除了为每种资源类型(如聊天或用户)创建单独类的需要。

userInMemoryResourcechatInMemoryResource 这样的实例展示了如何使用 IUserIChat 类型与 GenericsInMemoryResource 一起使用。像 addResourceget 这样的方法使用泛型类型 T,使它们能够适应正在使用的特定资源类型。

原来的 InMemoryChatResource 类仅限于处理聊天数据,而 GenericsInMemoryResource 更灵活且可扩展,能够处理各种类型的资源。这使得代码更简洁、更易于维护,因为你无需为每种资源类型创建一个新类。

泛型通过使单个类能够管理多种数据类型,显著减少了代码重复。这种方法简化了维护工作,并增强了我们代码库的适应性。

现在,让我们简要谈谈如何用类型处理 Promise。

Promise

在 TypeScript 中,当你声明一个 Promise 时,你可以使用泛型来指示 Promise 最终将返回的数据类型。这种类型指示确保了解析值与预期一致,并允许 TypeScript 提供相关的类型检查和自动补全。让我们在这里声明一个 fetchData 函数,它将用 setTimeout 模拟网络请求,并使用 Promise 类型为其返回值提供类型:

function fetchData(): Promise<string> {
    return new Promise((resolve) => {
        setTimeout(() => resolve("Data Fetched"), 1000);
    });
}

fetchData 是一个返回 Promise<string> 实例的函数。这意味着当 Promise 解析时,它将返回一个字符串。在 Promise 内部,我们用 setTimeout 模拟获取数据,并用一个字符串值解析它,符合声明的返回类型 string。这种对 Promise 解析值的显式类型化确保了 fetchData 的任何使用者都可以期望得到一个字符串。

至此,我们已经涵盖了构建聊天应用程序所需的 TypeScript 的大部分方面。

总结

在本章中,我们深入探讨了 TypeScript 的高级特性,更深入地理解了类型缩小、空值类型、函数类型和工具类型等概念,这些是开发健壮 Web 应用程序的基础。这些知识为我们提供了编写更安全、更可预测且更高效代码的工具,减少了常见的 JavaScript 陷阱。接下来,下一章将标志着我们动手实践之旅的开始,我们将通过用 Bun 配置后端运行时环境来开始构建我们的应用程序,为我们的全栈 TypeScript 项目奠定基础。