(2)释放 TypeScript 的力量:改进标准库类型

 释放 TypeScript 的力量(系列 3 部分)

1、释放 TypeScript 的力量:tsconfig 中的关键注意事项

2、释放 TypeScript 的力量:改进标准库类型

3、让 TypeScript 真正成为“强类型"

       

        在我的上一篇文章中,我们讨论了如何配置 TypeScript 的编译器以捕获更多错误、减少any类型的使用并获得更好的开发人员体验。然而,正确配置 tsconfig 文件还不够。即使遵循所有建议,我们的代码库中仍然存在类型检查质量不理想的重大风险。

问题是我们的代码并不是构建应用程序所需的唯一代码。标准库和运行时环境也参与类型检查。这些指的是全局范围内可用的 JavaScript 方法和 Web 平台 API,包括用于处理数组、窗口对象、Fetch API 等的方法。

在本文中,我们将探讨 TypeScript 标准库的一些最常见问题以及编写更安全、更可靠的代码的方法。

TypeScript 标准库的问题

虽然 TypeScript 的标准库在很大程度上提供了高质量的类型定义,但一些广泛使用的 API 的类型声明要么过于宽松,要么过于严格。

过于宽松的类型最常见的问题是使用 ,any而不是更精确的类型,例如unknown. Fetch API 是标准库中类型安全问题最常见的来源。该json()方法返回 type 的值any,这可能导致运行时错误和类型不匹配。方法也是如此JSON.parse

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = await response.json();
    return data;
}

const pokemons = await fetchPokemons();
//    ^?  any

pokemons.data.map(pokemon => pokemon.name);
//            ^  TypeError: Cannot read properties of undefined

另一方面,有些 API 具有不必要的限制性类型声明,这可能会导致开发人员体验较差。例如,该Array.filter方法的工作方式与直觉相反,需要开发人员手动进行类型转换或编写类型保护。

// the type of filteredArray is Array
const filteredArray = [1, 2, undefined].filter(Boolean);

// the type of filteredArray is Array
const filteredArray = [1, 2, undefined].filter(
    (item): item is number => Boolean(item)
);

没有简单的方法来升级或替换标准库的类型声明,因为它的类型定义是随 TypeScript 编译器一起提供的。然而,如果我们想充分利用 TypeScript,有多种方法可以解决这个问题。让我们以 Fetch API 为例探讨一些选项。

使用类型断言

很快我想到的一个解决方案是手动指定类型。为此,我们需要描述响应格式并转换any为所需的类型。通过这样做,我们可以将 的使用隔离到代码库的一小部分,这已经比在整个程序中any使用返回类型要好得多。any

interface PokemonListResponse {
    count: number;
    next: string | null;
    previous: string | null;
    results: Pokemon[];
}

interface Pokemon {
    name: string;
    url: string;
}

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = await response.json() as PokemonListResponse;
    //                                 ^  Manually cast the any
    //                                    to a more precise type
    return data;
}

const pokemons = await fetchPokemons();
//    ^?  PokemonListResponse

此外,TypeScript 现在将突出显示访问不存在字段的错误。然而,应该理解的是,类型转换给我们带来了额外的责任,以准确描述从服务器返回的类型。

pokemons.data.map(pokemon => pokemon.name);
//       ^  Error: Property 'data' does not exist on type 'PokemonListResponse'
//          We shold use the 'results' field here.

类型断言可能存在风险,应谨慎使用。如果断言不正确,它们可能会导致意外行为。例如,在描述类型时犯错误的风险很高,例如忽略字段为null或 的可能性undefined

此外,如果服务器上的响应格式意外更改,我们可能无法尽快意识到这一点。

使用类型保护

any我们可以通过首先强制转换为来增强解决方案unknown。这清楚地表明该fetch函数可以返回任何类型的数据。然后我们需要通过编写类型保护来验证响应是否具有我们需要的数据,如下所示:

function isPokemonListResponse(data: unknown): data is PokemonListResponse {
    if (typeof data !== 'object' || data === null) return false;
    if (typeof data.count !== 'number') return false;
    if (data.next !== null && typeof data.next !== 'string') return false;
    if (data.previous !== null && typeof data.previous !== 'string') return false;

    if (!Array.isArray(data.results)) return false;
    for (const pokemon of data.results) {
        if (typeof pokemon.name !== 'string') return false;
        if (typeof pokemon.url !== 'string') return false;
    }

    return true;
}

类型保护函数将具有类型的变量unknown作为输入。运算is符用于指定输出类型,表明我们已经检查过data变量中的数据并且它具有这种类型。在函数内部,我们编写所有必要的检查来验证我们感兴趣的所有字段。

我们可以使用生成的类型保护将unknown类型范围缩小到我们想要使用的类型。这样,如果响应数据格式发生变化,我们可以快速检测到并在应用程序逻辑中处理这种情况。

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = (await response.json()) as unknown;
    //                                   ^  1. Cast to unknown

    // 2. Validate the response
    if (!isPokemonListResponse(data)) {
        throw new Error('Неизвестный формат ответа');
    }

    return data;
}

const pokemons = await fetchPokemons();
//    ^?  PokemonListResponse

然而,编写类型保护可能很乏味,尤其是在处理大量数据时。此外,在类型保护中犯错误的风险很高,这相当于在类型定义本身中犯错误。

使用 Zod 库

为了简化类型保护的编写,我们可以使用Zod等数据验证库。使用 Zod,我们可以定义一个数据模式,然后调用一个函数来根据该模式检查数据格式。

import { z } from 'zod';

const schema = z.object({
    count: z.number(),
    next: z.string().nullable(),
    previous: z.string().nullable(),
    results: z.array(
        z.object({
            name: z.string(),
            url: z.string(),
        })
    ),
});

这些类型的库最初是根据 TypeScript 开发的,因此它们具有很好的功能。它们允许我们描述一次数据模式,然后自动获取类型定义。这消除了手动描述 TypeScript 接口的需要并消除了重复。

type PokemonListResponse = z.infer;

该函数本质上充当类型保护,我们不必手动编写。

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = (await response.json()) as unknown;
    // Validate the response
    return schema.parse(data);
}

const pokemons = await fetchPokemons();
//    ^?  PokemonListResponse

因此,我们得到了一个可靠的解决方案,不留任何人为错误的余地。类型定义中不可能出现错误,因为我们不手动编写它们。类型保护中的错误也是不可能的。模式中可能会出现错误,但我们会在开发过程中很快意识到它们。

Zod 的替代品

Zod 有许多在功能、捆绑包大小和性能方面有所不同的替代方案。对于每个应用程序,您可以选择最合适的选项。

例如,superstruct库是 Zod 的更轻的替代品。该库更适合在客户端使用,因为它的大小相对较小(13.1 kB vs 3.4 kB)。

typya库是一种稍微不同的提前编译方法。由于编译阶段,数据验证的速度明显加快。这对于繁重的服务器代码或大量数据尤其重要。

解决根本原因

使用 Zod 等库进行数据验证可以帮助克服anyTypeScript 标准库中的类型问题。但是,了解返回 的标准库方法仍然很重要,并在使用这些方法时any将这些类型替换为。unknown

理想情况下,标准库应该使用unknown类型而不是any. 这将使编译器能够建议所有需要类型保护的地方。幸运的是,TypeScript 的声明合并功能提供了这种可能性。

在 TypeScript 中,接口有一个有用的功能,即同名接口的多个声明将合并为一个声明。例如,如果我们有一个User带有姓名字段的接口,然后声明另一个User带有年龄字段的接口,则生成的User接口将同时具有姓名和年龄字段。

interface User {
    name: string;
}

interface User {
    age: number;
}

const user: User = {
    name: 'John',
    age: 30,
};

此功能不仅适用于单个文件,而且适用于整个项目的全局。这意味着我们可以使用此功能来扩展类型Window,甚至扩展外部库(包括标准库)的类型。

declare global {
    interface Window {
        sayHello: () => void;
    }
}

window.sayHello();
//     ^  TypeScript now knows about this method

通过声明合并,我们可以完全解决anyTypeScript标准库中的类型问题。

更好的 Fetch API 类型

为了改进标准库中的 Fetch API,我们需要更正该json()方法的类型,以便它始终返回unknown而不是any. 首先,我们可以使用IDE中的“转到类型定义”功能来确定该json方法是否是接口的一部分Response

interface Response extends Body {
    readonly headers: Headers;
    readonly ok: boolean;
    readonly redirected: boolean;
    readonly status: number;
    readonly statusText: string;
    readonly type: ResponseType;
    readonly url: string;
    clone(): Response;
}

json()然而,我们在 的方法中找不到该方法Response。相反,我们可以看到Response接口继承自Body接口。因此,我们查看Body接口以找到我们需要的方法。正如我们所看到的,该json()方法实际上返回any类型。

interface Body {
    readonly body: ReadableStream | null;
    readonly bodyUsed: boolean;
    arrayBuffer(): Promise;
    blob(): Promise;
    formData(): Promise;
    text(): Promise;
    json(): Promise;
    //              ^  We are going to fix this
}

为了解决这个问题,我们可以Body在项目中定义一次接口,如下所示:

declare global {
    interface Body {
        json(): Promise;
    }
}

由于声明合并,该json()方法现在将始终返回unknown类型。

async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = await response.json();
    //    ^?  unknown
    return data;
}

这意味着忘记编写类型保护将不再可能,并且any类型将不再能够潜入我们的代码中。

JSON.parse 的更好类型

同样的,我们可以修复JSON解析。默认情况下,该parse()方法返回any类型,这可能会导致使用解析数据时出现运行时错误。

const data = JSON.parse(text);
//    ^?  any

为了解决这个问题,我们需要弄清楚该parse()方法是接口的一部分JSON。然后我们可以在项目中声明类型,如下所示:

declare global {
    interface JSON {
        parse(
            text: string, 
            reviver?: (this: any, key: string, value: any) => any
        ): unknown;
    }
}

现在,JSON 解析总是返回unknown类型,我们绝对不会忘记为此编写类型保护。这会带来更安全、更易于维护的代码库。

const data = JSON.parse(text);
//    ^?  unknown

Array.isArray 的更好类型

另一个常见的例子是检查变量是否是数组。默认情况下,此方法返回一个 数组any,这与仅使用 本质上相同any

if (Array.isArray(userInput)) {
    console.log(userInput);
    //          ^?  any[]
}

我们已经学会了如何解决这个问题。通过扩展数组构造函数的类型(如下所示),该方法现在返回一个 数组unknown,这更加安全和准确。

declare global {
    interface ArrayConstructor {
        isArray(arg: any): arg is unknown[];
    }
}

if (Array.isArray(userInput)) {
    console.log(userInput);
    //          ^?  unknown[]
}

更好的结构化克隆类型

不幸的是,最近引入的克隆对象方法也返回了any

const user = {
    name: 'John',
    age: 30,
};

const copy = structuredClone(user);
//    ^?  any

修复它就像以前的方法一样简单。但是,在这种情况下,我们需要添加新的函数签名而不是扩充接口。幸运的是,声明合并适用于函数,就像适用于接口一样。因此,我们可以按如下方式修复该问题:

declare global {
    declare function structuredClone(value: T, options?: StructuredSerializeOptions): T;
}

克隆的对象现在将与原始对象具有相同的类型。

const user = {
    name: 'John',
    age: 30,
};

const copy = structuredClone(user);
//    ^?  { name: string, age: number }

Array.filter 的更好类型

声明合并不仅对于解决any类型问题有用,而且还可以改善标准库的人体工程学。让我们考虑一下该Array.filter方法的示例。

const filteredArray = [1, 2, undefined].filter(Boolean);
//    ^?  Array

我们可以教 TypeScript 在应用布尔过滤函数后自动缩小数组类型。为此,我们需要扩展Array接口,如下所示:

type NonFalsy = T extends false | 0 | "" | null | undefined | 0n ? never : T;

declare global {
    interface Array {
      filter(predicate: BooleanConstructor, thisArg?: any): Array>;
    }
}

描述该NonFalsy类型的工作原理需要一篇单独的文章,因此我将在下次再对此进行解释。重要的是,现在我们可以使用过滤器的简写形式并获得正确的数据类型作为结果。

const filteredArray = [1, 2, undefined].filter(Boolean);
//    ^?  Array
介绍 ts-reset

介绍 ts-reset

TypeScript 的标准库包含超过 1,000 个该any类型的实例。在使用严格类型化的代码时,有很多机会可以改善开发人员的体验。避免亲自修复标准库的一种解决方案是使用ts-reset库。它易于使用,只需在项目中导入一次。

import "@total-typescript/ts-reset";

该库相对较新,因此它对标准库的修复还没有我想要的那么多。然而,我相信这只是一个开始。需要注意的是,ts-reset仅包含对全局类型的安全更改,不会导致潜在的运行时错误。

关于在图书馆中使用的注意事项

改进 TypeScript 的标准库有很多好处。然而,值得注意的是,重新定义标准库的全局类型限制了这种方法仅适用于应用程序。它主要不适合库,因为使用这样的库会意外地改变应用程序的全局类型的行为。

一般来说,建议避免在库中修改 TypeScript 的标准库类型。相反,您可以使用静态分析工具在代码质量和类型安全方面获得类似的结果,这适用于库开发。我很快就会写另一篇关于此的文章。

结论

TypeScript 的标准库是 TypeScript 编译器的重要组成部分,提供全面的内置类型来使用 JavaScript 和 Web 平台 API。然而,标准库并不完美,并且某些类型声明存在问题,可能导致我们的代码库中的类型检查质量不佳。在本文中,我们探讨了 TypeScript 标准库的一些最常见问题以及编写更安全、更可靠的代码的方法。

通过使用类型断言、类型防护和 Zod 等库,我们可以提高代码库中的类型安全性和代码质量。此外,我们可以通过使用声明合并来解决问题的根本原因,以提高 TypeScript 标准库的类型安全性和人体工程学。

我希望您从本文中学到了新的东西。在下一篇文章中,我们将讨论如何使用静态分析工具来进一步提高类型安全性。感谢您的阅读!

你可能感兴趣的:(javascript,typescript)