MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

从API和规范生成TypeScript类型的策略

2023-04-283.5k 阅读

从现有 API 提取类型信息

在实际开发中,我们常常需要与各种已有的 API 进行交互,无论是第三方库提供的 API,还是自己团队内部的 API。从这些 API 中提取类型信息并转化为 TypeScript 类型,是一项重要的技能。

基于函数参数和返回值推导

对于函数类型的 API,我们可以通过分析其参数和返回值来推导类型。例如,假设我们有一个简单的 JavaScript 函数,用于将两个数字相加:

function add(a, b) {
    return a + b;
}

在 TypeScript 中,我们可以这样为它定义类型:

function add(a: number, b: number): number {
    return a + b;
}

这里,我们通过观察函数接受两个参数且都应是数字类型,返回值也是数字类型,从而准确地定义了 add 函数的 TypeScript 类型。

当 API 变得更复杂时,比如函数接受对象作为参数:

function printUser(user) {
    console.log(`Name: ${user.name}, Age: ${user.age}`);
}

我们可以定义如下的 TypeScript 类型:

interface User {
    name: string;
    age: number;
}
function printUser(user: User) {
    console.log(`Name: ${user.name}, Age: ${user.age}`);
}

通过分析函数内部对 user 对象属性的访问,我们确定了 User 接口的结构。

处理回调函数

许多 API 会使用回调函数来处理异步操作或事件。例如,Node.js 的 fs.readFile 函数:

const fs = require('fs');
fs.readFile('example.txt', 'utf8', function (err, data) {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

在 TypeScript 中,我们可以这样定义其类型:

import { readFile } from 'fs';
readFile('example.txt', 'utf8', (err: NodeJS.ErrnoException | null, data: string) => {
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

这里,readFile 的第三个参数是一个回调函数,它接受两个参数,err 可能是 NodeJS.ErrnoException 类型或者 nulldatastring 类型。通过对回调函数参数的分析,我们准确地定义了其类型。

泛型 API

一些 API 设计为通用的,以适应不同的数据类型,这时候就会用到泛型。例如,JavaScript 的数组 map 方法:

const numbers = [1, 2, 3];
const squared = numbers.map(function (num) {
    return num * num;
});

在 TypeScript 中,map 方法的类型定义如下:

interface Array<T> {
    map<U>(callbackfn: (value: T, index: number, array: T[]) => U, thisArg?: any): U[];
}
const numbers: number[] = [1, 2, 3];
const squared: number[] = numbers.map((num: number) => num * num);

这里,map 方法使用了泛型 T 表示数组元素的类型,U 表示回调函数返回值的类型。通过泛型,我们可以灵活地为不同类型数组的 map 操作定义准确的类型。

依据规范生成类型

除了从现有 API 提取类型信息,依据特定的规范生成 TypeScript 类型也是常见的需求。规范可以是自定义的数据格式规范,也可以是行业标准规范。

自定义数据格式规范

假设我们有一个自定义的数据格式规范,用于表示一个简单的图书信息。图书信息包含书名、作者和出版年份,并且出版年份必须是 4 位数字。我们可以这样定义 TypeScript 类型:

interface Book {
    title: string;
    author: string;
    publicationYear: number;
}
function validateBook(book: Book) {
    if (String(book.publicationYear).length!== 4) {
        throw new Error('Publication year must be 4 digits');
    }
    return true;
}

这里,我们通过接口 Book 定义了图书信息的结构,并通过 validateBook 函数对 publicationYear 进行了格式验证。

如果我们的规范更复杂,比如图书可以有多个作者,并且有一个可选的简介字段,我们可以这样扩展类型定义:

interface Book {
    title: string;
    authors: string[];
    publicationYear: number;
    description?: string;
}
function validateBook(book: Book) {
    if (String(book.publicationYear).length!== 4) {
        throw new Error('Publication year must be 4 digits');
    }
    return true;
}

通过增加 authors 数组类型和 description 可选字段,我们适应了更复杂的规范。

行业标准规范

以 JSON Schema 为例,JSON Schema 是一种用于定义 JSON 数据格式的规范。假设我们有一个如下的 JSON Schema 定义:

{
    "type": "object",
    "properties": {
        "name": {
            "type": "string"
        },
        "age": {
            "type": "number",
            "minimum": 0
        }
    },
    "required": ["name", "age"]
}

我们可以使用工具如 json-schema-to-ts 将其转换为 TypeScript 类型:

// 使用 json - schema - to - ts 转换后的类型
export interface GeneratedType {
    name: string;
    age: number;
}

这个工具根据 JSON Schema 的定义,准确地生成了对应的 TypeScript 接口。在实际应用中,许多行业标准规范都有相应的工具可以帮助我们生成 TypeScript 类型,大大提高了开发效率。

结合工具自动化生成类型

手动从 API 和规范生成 TypeScript 类型虽然可行,但对于大型项目来说,工作量巨大且容易出错。因此,结合工具自动化生成类型是一个更好的选择。

使用 dts - generator 从 JavaScript 生成.d.ts 文件

dts - generator 是一个可以从 JavaScript 代码生成 TypeScript 声明文件(.d.ts)的工具。假设我们有一个 JavaScript 模块 mathUtils.js

function add(a, b) {
    return a + b;
}
function subtract(a, b) {
    return a - b;
}
exports.add = add;
exports.subtract = subtract;

通过 dts - generator,我们可以生成如下的 .d.ts 文件:

declare function add(a: number, b: number): number;
declare function subtract(a: number, b: number): number;
export { add, subtract };

这样,我们就可以在 TypeScript 项目中引入这个模块,并获得准确的类型提示。

使用 openapi - typescript - codegen 从 OpenAPI 规范生成 TypeScript 类型

如果我们有一个 OpenAPI 规范文件(通常是 openapi.jsonopenapi.yaml),描述了一个 RESTful API 的接口。例如,下面是一个简单的 OpenAPI 规范片段:

openapi: 3.0.0
info:
    title: Example API
    version: 1.0.0
paths:
    /users:
        get:
            summary: Get a list of users
            responses:
                '200':
                    description: A list of users
                    content:
                        application/json:
                            schema:
                                type: array
                                items:
                                    type: object
                                    properties:
                                        name:
                                            type: string
                                        age:
                                            type: number

使用 openapi - typescript - codegen 工具,我们可以生成对应的 TypeScript 类型和 API 调用函数:

// 生成的部分 TypeScript 类型
export interface User {
    name: string;
    age: number;
}
export type GetUsersResponse = User[];
// 生成的 API 调用函数示例
import { request } from 'http';
export async function getUsers(): Promise<GetUsersResponse> {
    return new Promise((resolve, reject) => {
        const req = request({
            method: 'GET',
            url: '/users'
        }, (res) => {
            let data = '';
            res.on('data', (chunk) => {
                data += chunk;
            });
            res.on('end', () => {
                resolve(JSON.parse(data) as GetUsersResponse);
            });
        });
        req.on('error', (err) => {
            reject(err);
        });
        req.end();
    });
}

通过这种方式,我们可以根据 OpenAPI 规范自动生成 TypeScript 类型和相关的 API 调用代码,减少了手动编写类型和接口调用代码的工作量,同时提高了代码的准确性和一致性。

类型生成中的常见问题及解决方法

在从 API 和规范生成 TypeScript 类型的过程中,会遇到一些常见的问题,需要我们采取相应的解决方法。

处理缺失或不明确的类型信息

有时候,API 文档可能没有提供足够的类型信息,或者 JavaScript 代码中的类型不明确。例如,有一个函数接受一个参数,但不清楚这个参数具体的类型:

function processData(data) {
    // 函数内部对 data 进行了一些操作,但不清楚 data 的类型
    console.log(data.length);
}

在这种情况下,我们可以通过分析函数内部对参数的操作来推测类型。从 console.log(data.length) 可以看出,data 可能是一个数组或者具有 length 属性的对象。我们可以这样定义 TypeScript 类型:

interface HasLength {
    length: number;
}
function processData(data: HasLength | any[]) {
    console.log(data.length);
}

这里,我们定义了一个 HasLength 接口,同时也考虑了 data 可能是任意类型数组的情况。如果通过进一步分析可以确定更具体的类型,就可以进一步细化类型定义。

处理复杂的嵌套结构

当 API 或规范涉及复杂的嵌套结构时,生成类型可能会变得棘手。例如,假设我们有一个表示组织结构的规范,其中一个部门可以包含多个子部门,每个部门又有员工列表:

{
    "department": {
        "name": "Engineering",
        "subDepartments": [
            {
                "name": "Frontend",
                "employees": [
                    {
                        "name": "Alice",
                        "age": 25
                    },
                    {
                        "name": "Bob",
                        "age": 28
                    }
                ]
            },
            {
                "name": "Backend",
                "employees": [
                    {
                        "name": "Charlie",
                        "age": 30
                    }
                ]
            }
        ]
    }
}

在 TypeScript 中,我们可以这样定义类型:

interface Employee {
    name: string;
    age: number;
}
interface SubDepartment {
    name: string;
    employees: Employee[];
}
interface Department {
    name: string;
    subDepartments: SubDepartment[];
}
interface Organization {
    department: Department;
}

通过逐步分解嵌套结构,我们为复杂的组织结构定义了清晰的 TypeScript 类型。

处理类型兼容性问题

在项目中,可能会使用多个库,这些库生成的类型之间可能存在兼容性问题。例如,一个库定义了一个 User 接口,另一个库也定义了一个 User 接口,但结构略有不同:

// library1.d.ts
interface User {
    name: string;
}
// library2.d.ts
interface User {
    name: string;
    age: number;
}

在这种情况下,我们可以通过类型合并或别名来解决兼容性问题。例如,使用类型合并:

interface User extends library1.User, library2.User {}

这样,新的 User 接口就包含了两个库中 User 接口的所有属性。如果属性名冲突,可能需要进一步调整,比如重命名属性或者通过类型断言来明确使用哪个属性。

优化生成的类型以提高代码质量

生成 TypeScript 类型后,我们还可以对其进行优化,以提高代码的可读性、可维护性和性能。

简化类型定义

有时候生成的类型可能过于复杂,包含了一些不必要的冗余信息。例如,通过工具从复杂的 JSON Schema 生成的类型可能有很多嵌套的接口和重复的定义。我们可以对其进行简化。假设生成了如下复杂的类型:

interface InnerObject1 {
    property1: string;
    property2: number;
}
interface InnerObject2 {
    inner: InnerObject1;
    anotherProperty: boolean;
}
interface MainObject {
    mainInner: InnerObject2;
    someValue: string;
}

如果我们发现 InnerObject1InnerObject2 只在 MainObject 中使用,并且可以合并,我们可以简化为:

interface MainObject {
    property1: string;
    property2: number;
    anotherProperty: boolean;
    someValue: string;
}

通过这种简化,类型定义更加简洁,代码的可读性也得到提高。

使用类型别名提高可读性

对于一些复杂的联合类型或泛型类型,使用类型别名可以使代码更易读。例如,假设我们有一个函数接受一个可能是字符串或者数字的参数,并且返回值类型取决于参数类型:

function processValue(value: string | number): string | number {
    if (typeof value ==='string') {
        return value.length.toString();
    } else {
        return value * 2;
    }
}

我们可以使用类型别名来使类型更清晰:

type StringOrNumber = string | number;
function processValue(value: StringOrNumber): StringOrNumber {
    if (typeof value ==='string') {
        return value.length.toString();
    } else {
        return value * 2;
    }
}

这样,在函数定义和其他使用这个类型的地方,代码更加简洁明了。

利用交叉类型和索引类型增强类型表达能力

交叉类型可以用于组合多个类型,索引类型则可以动态地访问对象属性。例如,假设我们有两个接口 UserInfoUserSettings

interface UserInfo {
    name: string;
    age: number;
}
interface UserSettings {
    theme: string;
    notifications: boolean;
}
// 使用交叉类型创建一个包含用户信息和设置的新类型
type FullUser = UserInfo & UserSettings;
// 索引类型示例
interface User {
    name: string;
    age: number;
}
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}
const user: User = { name: 'John', age: 30 };
const name = getProperty(user, 'name');

通过交叉类型,我们可以方便地组合不同的接口,而索引类型则为我们提供了一种灵活访问对象属性的方式,增强了类型系统的表达能力,同时提高了代码的灵活性和可维护性。

通过以上从 API 和规范生成 TypeScript 类型的策略、结合工具自动化生成以及对生成类型的优化,我们能够更高效地在项目中使用 TypeScript,充分发挥其类型系统的优势,提高代码质量和开发效率。无论是处理简单的函数 API 还是复杂的行业规范,都能通过合适的方法准确地生成和管理 TypeScript 类型。