解锁TypeScript的潜力:改进标准库类型

在 TypeScript 项目中,我们的编写代码并不是唯一的代码。标准库和运行环境也会参与类型检查。这些包括在全局范围内可用的JavaScript方法和Web平台API,包括用于处理数组、window对象、Fetch API等方法。本文将探讨TypeScript标准库最常见的问题以及编写更安全、可靠的代码的方法!

TypeScript 标准库的问题

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

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

typescript 复制代码
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方法的工作方式与直觉相反,需要手动进行类型转换或编写类型保护。

typescript 复制代码
// filteredArray 的类型是 Array<number | undefined>。
const filteredArray = [1, 2, undefined].filter(Boolean);

// filteredArray的类型是 Array<number>
const filteredArray = [1, 2, undefined].filter(
  (item): item is number => Boolean(item)
);

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

下面来以 Fetch API 为例探讨一些选项。

使用类型断言

一个快速解决方案是手动指定类型 。为了做到这一点,需要描述响应的格式并将any类型转换为所需的类型。这样就可以将对any的使用限制在代码库的一小部分中,比在整个程序中都使用返回的any类型要好很多。

typescript 复制代码
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;
  return data;
}

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

另外,TypeScript 现在将会突出显示对不存在字段的访问的错误。然而,类型转换给我们带来了额外的责任,即准确描述从服务端返回的类型。

typescript 复制代码
pokemons.data.map(pokemon => pokemon.name);
//       ^  Error: "Pokemon List Response"类型中不存在属性"data"
//          应该在这里使用 'results' 字段

类型断言可能存在风险,应谨慎使用。 如果断言不正确,可能会导致意外行为。例如,在描述类型时容易犯错,比如忽略字段可能为nullundefined的情况。

使用类型保护

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

typescript 复制代码
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类型缩小到想要处理的类型。这样,如果响应数据格式发生变化,可以迅速检测到并在应用逻辑中处理该情况。

typescript 复制代码
async function fetchPokemons() {
    const response = await fetch('https://pokeapi.co/api/v2/pokemon');
    const data = (await response.json()) as unknown;
    if (!isPokemonListResponse(data)) {
        throw new Error('error');
    }

    return data;
}

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

然而,编写类型守卫可能会很繁琐,特别是在处理大量数据时。此外,在类型守卫中犯错的风险很高,这等同于在类型定义本身上犯错。

使用 Zod 库

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

typescript 复制代码
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接口的需要,并避免了重复工作。

typescript 复制代码
type PokemonListResponse = z.infer<typeof schema>;

这个函数本质上就像一个类型守卫,而我们无需手动编写它。

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

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

因此,我们得到了一种可靠的解决方案,不留下任何犯错的机会。由于不再手动编写类型定义,因此无法出现类型定义的错误。类型守卫也不会出现错误。模式中可能会出现错误,但可以在开发过程中察觉到这些错误。

Zod 的替代品

Zod有许多不同的替代品,它们在功能、捆绑大小和性能上有所区别。对于每个应用程序,您可以选择最合适的选项。

例如,superstruct 库是Zod的一个更轻量级的替代方案。由于该库相对较小(13.1 kB 对比 3.4 kB),因此更适合在客户端使用。

typia 库是一种稍微不同的方法,它采用提前编译的方式。由于编译阶段,数据验证的速度更快。这对于大量数据或服务端重型代码特别重要。

解决根本问题

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

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

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

typescript 复制代码
interface User {
    name: string;
}

interface User {
    age: number;
}

const user: User = {
    name: 'CUGGZ',
    age: 25,
};

这个特性不仅在单个文件内有效,而是在整个项目范围内全局有效。这意味着可以使用这个特性来扩展 Window 类型,甚至是扩展外部库的类型,包括标准库。

typescript 复制代码
declare global {
    interface Window {
        sayHello: () => void;
    }
}

window.sayHello();
//     ^  TypeScript 现在知道这个方法了

通过使用声明合并,可以完全解决TypeScript标准库中 any 类型的问题。

更好的 Fetch API 类型

为了改进标准库中的 Fetch API,需要更正 json() 方法的类型,以便它始终返回 unknown 而不是any。 首先,确定json方法是 Response 接口的一部分。

typescript 复制代码
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;
}

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

typescript 复制代码
interface Body {
    readonly body: ReadableStream<Uint8Array> | null;
    readonly bodyUsed: boolean;
    arrayBuffer(): Promise<ArrayBuffer>;
    blob(): Promise<Blob>;
    formData(): Promise<FormData>;
    text(): Promise<string>;
    json(): Promise<any>;
}

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

typescript 复制代码
declare global {
    interface Body {
        json(): Promise<unknown>;
    }
}

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

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

JSON.parse 更好的类型

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

typescript 复制代码
const data = JSON.parse(text);
//    ^?  any

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

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

现在,JSON 解析总是返回unknow类型,这可以带来更安全、更易于维护的代码库。

typescript 复制代码
const data = JSON.parse(text);
//    ^?  unknown

Array.isArray 更好的类型

另一个常见的例子是检查变量是否是数组。 默认情况下,此方法返回包含any类型的数组,基本上与使用any没有区别。

typescript 复制代码
if (Array.isArray(userInput)) {
    console.log(userInput);
    //          ^?  any[]
}

这里可以扩展数组构造函数的类型,如下所示,该方法现在返回一个包含unknown类型的数组,这样更安全和准确。

typescript 复制代码
declare global {
    interface ArrayConstructor {
        isArray(arg: any): arg is unknown[];
    }
}

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

structuredClone 更好的类型

最近引入的克隆对象方法 structuredClone 也返回 any

typescript 复制代码
const user = {
    name: 'John',
    age: 30,
};

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

修复这个问题和前面的方法一样。不过,在这种情况下,需要添加一个新的函数签名而不是扩展接口。幸运的是,声明合并对函数也同样适用。因此,可以按照以下方式修复这个问题:

typescript 复制代码
declare global {
    declare function structuredClone<T>(value: T, options?: StructuredSerializeOptions): T;
}

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

typescript 复制代码
const user = {
    name: 'John',
    age: 30,
};

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

Array.filter 更好的类型

声明合并不仅对修复any类型的问题很有用,还可以改进标准库的易用性。下面来看一下Array.filter方法的例子。

typescript 复制代码
const filteredArray = [1, 2, undefined].filter(Boolean);
//    ^?  Array<number | undefined>

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

typescript 复制代码
type NonFalsy<T> = T extends false | 0 | "" | null | undefined | 0n ? never : T;

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

现在可以使用filter的简写形式,并获得正确的数据类型作为结果。

ts-reset

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

typescript 复制代码
import "@total-typescript/ts-reset";

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

避免在库中使用

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

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

总结

TypeScript的标准库是TypeScript编译器的一个关键组成部分,提供了一系列内置类型,用于处理JavaScript和Web平台API。然而,标准库并不完美,其中一些类型声明存在问题,可能导致代码中的类型检查质量较低。本文探讨了一些TypeScript标准库常见的问题以及编写更安全、更可靠的代码的方法。

通过使用类型断言、类型守卫和类似Zod的库,可以改善代码库的类型安全性和代码质量。此外,还可以通过使用声明合并来修复问题的根本,从而改善TypeScript标准库的类型安全性和人性化程度。

相关推荐
zhanghaisong_201516 分钟前
Caused by: org.attoparser.ParseException:
前端·javascript·html·thymeleaf
Eric_见嘉19 分钟前
真的能无限试(白)用(嫖)cursor 吗?
前端·visual studio code
DK七七1 小时前
多端校园圈子论坛小程序,多个学校同时代理,校园小程序分展示后台管理源码
开发语言·前端·微信小程序·小程序·php
老赵的博客1 小时前
QSS 设置bug
前端·bug·音视频
Chikaoya1 小时前
项目中用户数据获取遇到bug
前端·typescript·vue·bug
南城夏季1 小时前
蓝领招聘二期笔记
前端·javascript·笔记
Huazie1 小时前
来花个几分钟,轻松掌握 Hexo Diversity 主题配置内容
前端·javascript·hexo
NoloveisGod1 小时前
Vue的基础使用
前端·javascript·vue.js
GISer_Jing1 小时前
前端系统设计面试题(二)Javascript\Vue
前端·javascript·vue.js
海上彼尚2 小时前
实现3D热力图
前端·javascript·3d