JS `Type-driven development` with TypeScript Advanced Patterns

各位观众老爷,早上好/下午好/晚上好!(取决于你什么时候读到这篇文章)

今天咱来聊聊一个听起来高大上,用起来贼爽的东西:基于类型的开发(Type-Driven Development,简称 TDD)。当然,别害怕,这玩意儿并非阳春白雪,配合 TypeScript 的高级模式,能让你写出安全、可靠、易于维护的代码。

咱们今天不搞虚的,直接上干货,争取让你听完之后,立马就能上手。

啥是 Type-Driven Development (TDD)?

TDD 并不是指用 TypeScript 写的测试驱动开发(Test-Driven Development),虽然两者缩写相同,但理念完全不同。

TDD 的核心思想是:先定义类型,再实现逻辑。

简单来说,就是先用 TypeScript 强大的类型系统把程序的骨架搭起来,然后根据类型约束,一步一个脚印地把代码填进去。这就像盖房子,先设计好图纸(类型定义),再按图施工(实现逻辑)。

为啥要用 TDD?

好处多多,简直能让你爱上写代码:

  • 更早发现 Bug: 类型系统会在编译时就发现很多潜在的错误,避免在运行时才爆出意想不到的 Bug。这就像给代码上了保险,让你心里踏实。
  • 代码更安全: 明确的类型定义可以防止你写出类型不匹配的代码,避免出现 undefined is not a function 这种让人崩溃的错误。
  • 代码更易于维护: 良好的类型定义可以清晰地表达代码的意图,让其他人更容易理解和修改你的代码。这就像给代码写了说明书,方便后人维护。
  • 更好的代码提示: TypeScript 的智能提示功能可以根据类型定义,提供更准确的代码补全和建议,提高开发效率。这就像有个贴心的助手,随时为你提供帮助。
  • 提高代码重构的信心: 类型系统可以确保重构后的代码仍然符合类型定义,减少引入新的 Bug 的风险。

TypeScript 高级模式助你飞

光有 TDD 的思想还不够,还得有趁手的兵器。TypeScript 提供了很多高级模式,可以帮助我们更好地进行 TDD。

  1. 条件类型 (Conditional Types)

    条件类型允许你根据类型之间的关系来定义类型。这就像编程中的 if...else 语句,但作用于类型层面。

    type IsString<T> = T extends string ? true : false;
    
    type Result1 = IsString<string>; // true
    type Result2 = IsString<number>; // false

    用处:根据不同的类型,返回不同的类型。

    例子:提取对象中的可空属性

    type NullableKeys<T> = {
       [K in keyof T]-?: T[K] extends null | undefined ? K : never
    }[keyof T];
    
    interface User {
       name: string;
       age?: number;
       email: string | null;
    }
    
    type NullableUserKeys = NullableKeys<User>; // "age" | "email"

    这个例子中,NullableKeys 类型会提取 User 接口中所有可空的属性名。

  2. 映射类型 (Mapped Types)

    映射类型允许你基于已有的类型,创建一个新的类型。这就像编程中的 map 函数,但作用于类型层面。

    type Readonly<T> = {
       readonly [K in keyof T]: T[K];
    };
    
    interface Person {
       name: string;
       age: number;
    }
    
    type ReadonlyPerson = Readonly<Person>;
    
    // ReadonlyPerson 的属性都是只读的
    // ReadonlyPerson = {
    //     readonly name: string;
    //     readonly age: number;
    // }

    用处:批量修改类型属性。

    例子:将对象中的所有属性变为可选

    type Partial<T> = {
       [K in keyof T]?: T[K];
    };
    
    interface Product {
       id: number;
       name: string;
       price: number;
    }
    
    type PartialProduct = Partial<Product>;
    
    // PartialProduct 的属性都是可选的
    // PartialProduct = {
    //     id?: number;
    //     name?: string;
    //     price?: number;
    // }
  3. 索引类型 (Index Types)

    索引类型允许你获取一个类型的所有属性名,并用这些属性名来访问该类型的属性。这就像编程中的 Object.keys 函数,但作用于类型层面。

    type Keys<T> = keyof T;
    
    interface Animal {
       name: string;
       age: number;
    }
    
    type AnimalKeys = Keys<Animal>; // "name" | "age"
    
    function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
       return obj[key];
    }
    
    const cat: Animal = { name: "Tom", age: 3 };
    const catName: string = getProperty(cat, "name");

    用处:动态获取类型属性。

  4. 类型推断 (Type Inference)

    TypeScript 强大的类型推断能力可以让你在很多情况下省略类型注解,让代码更简洁。

    const add = (x: number, y: number) => x + y; // 类型推断出 add 的返回值为 number
    
    const numbers = [1, 2, 3];
    const doubledNumbers = numbers.map(num => num * 2); // 类型推断出 doubledNumbers 的类型为 number[]

    用处:减少代码冗余,提高开发效率。

  5. 字面量类型 (Literal Types)

    字面量类型允许你将一个特定的值作为类型。这就像给变量指定了一个固定的值,只能是这个值,不能是其他值。

    type Direction = "north" | "south" | "east" | "west";
    
    let direction: Direction = "north"; // OK
    // direction = "up"; // Error: Type '"up"' is not assignable to type 'Direction'.

    用处:限制变量的取值范围。

    例子:定义一个表示 HTTP 方法的类型

    type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
    
    function request(url: string, method: HttpMethod) {
       // ...
    }
    
    request("/api/users", "GET"); // OK
    // request("/api/users", "OPTIONS"); // Error: Argument of type '"OPTIONS"' is not assignable to parameter of type 'HttpMethod'.
  6. 接口 (Interfaces) 和 类型别名 (Type Aliases)

    • 接口 (Interfaces): 用于描述对象的结构,可以包含属性和方法。
    interface Person {
       name: string;
       age: number;
       greet(): string;
    }
    
    const john: Person = {
       name: "John",
       age: 30,
       greet() {
           return `Hello, my name is ${this.name}`;
       }
    };
    • 类型别名 (Type Aliases): 用于给一个已有的类型起一个新的名字。
    type StringOrNumber = string | number;
    
    let value: StringOrNumber = "hello";
    value = 123;

    选择:

    • 接口: 主要用于描述对象的结构,可以被类实现 (implements)。
    • 类型别名: 可以用于任何类型的别名,包括联合类型、交叉类型、元组等等。

    一般来说,如果你的目标是定义一个对象的结构,那么使用接口;如果你的目标是给一个已有的类型起一个新的名字,或者定义一个复杂的类型,那么使用类型别名。

  7. 泛型 (Generics)

    泛型允许你编写可以处理多种类型的代码,而不需要为每种类型都编写一个单独的函数或类。

    function identity<T>(arg: T): T {
       return arg;
    }
    
    let myString: string = identity<string>("hello");
    let myNumber: number = identity<number>(123);

    例子:定义一个可以存储任何类型数据的数组

    class DataHolder<T> {
       data: T;
    
       constructor(data: T) {
           this.data = data;
       }
    
       getData(): T {
           return this.data;
       }
    }
    
    const stringHolder = new DataHolder<string>("hello");
    const numberHolder = new DataHolder<number>(123);
    
    console.log(stringHolder.getData()); // "hello"
    console.log(numberHolder.getData()); // 123

TDD 实战演练

光说不练假把式,咱们来个小例子,用 TDD 的思想和 TypeScript 的高级模式来写一个简单的函数:实现一个安全的除法函数,防止除数为零的错误。

  1. 定义类型

    首先,我们需要定义函数的输入和输出类型。

    type SafeDivideResult<T extends number> =
       | { success: true; result: T }
       | { success: false; error: "Division by zero" };

    这个类型定义了两种可能的结果:

    • success: true:表示除法成功,result 属性包含结果。
    • success: false:表示除法失败,error 属性包含错误信息。
  2. 编写函数签名

    接下来,我们根据类型定义,编写函数的签名。

    function safeDivide<T extends number>(numerator: T, denominator: T): SafeDivideResult<T> {
       // ...
    }
  3. 实现函数逻辑

    最后,我们根据类型约束,实现函数的逻辑。

    function safeDivide<T extends number>(numerator: T, denominator: T): SafeDivideResult<T> {
       if (denominator === 0) {
           return { success: false, error: "Division by zero" };
       } else {
           return { success: true, result: numerator / denominator };
       }
    }

    这个函数会检查除数是否为零,如果是,则返回一个包含错误信息的对象;否则,返回一个包含结果的对象。

  4. 使用函数

    现在,我们可以使用这个函数了。

    const result1 = safeDivide(10, 2); // { success: true, result: 5 }
    const result2 = safeDivide(10, 0); // { success: false, error: "Division by zero" }
    
    if (result2.success) {
       console.log("Result:", result2.result);
    } else {
       console.error("Error:", result2.error); // Error: Division by zero
    }

    可以看到,这个函数可以安全地处理除数为零的情况,避免程序崩溃。

更复杂的例子:一个简单的状态管理库

咱们再来一个稍微复杂点的例子,用 TDD 的思想和 TypeScript 的高级模式来写一个简单的状态管理库。

  1. 定义类型

    首先,我们需要定义状态的类型、Action 的类型和 Reducer 的类型。

    // 状态的类型
    type State = {
       count: number;
    };
    
    // Action 的类型
    type Action =
       | { type: "INCREMENT" }
       | { type: "DECREMENT" };
    
    // Reducer 的类型
    type Reducer<S, A> = (state: S, action: A) => S;
  2. 编写 Reducer

    接下来,我们编写 Reducer 函数,根据 Action 来更新状态。

    const reducer: Reducer<State, Action> = (state, action) => {
       switch (action.type) {
           case "INCREMENT":
               return { ...state, count: state.count + 1 };
           case "DECREMENT":
               return { ...state, count: state.count - 1 };
           default:
               return state;
       }
    };
  3. 编写 Store

    然后,我们编写 Store 类,用于存储状态和派发 Action。

    class Store<S, A> {
       private state: S;
       private reducer: Reducer<S, A>;
       private listeners: (() => void)[] = [];
    
       constructor(reducer: Reducer<S, A>, initialState: S) {
           this.reducer = reducer;
           this.state = initialState;
       }
    
       getState(): S {
           return this.state;
       }
    
       dispatch(action: A): void {
           this.state = this.reducer(this.state, action);
           this.listeners.forEach(listener => listener());
       }
    
       subscribe(listener: () => void): () => void {
           this.listeners.push(listener);
           return () => {
               this.listeners = this.listeners.filter(l => l !== listener);
           };
       }
    }
  4. 使用 Store

    最后,我们可以使用 Store 类来管理状态。

    const initialState: State = { count: 0 };
    const store = new Store<State, Action>(reducer, initialState);
    
    store.subscribe(() => {
       console.log("State changed:", store.getState());
    });
    
    store.dispatch({ type: "INCREMENT" }); // State changed: { count: 1 }
    store.dispatch({ type: "DECREMENT" }); // State changed: { count: 0 }

    这个例子展示了如何使用 TypeScript 的高级模式来编写一个简单的状态管理库,具有良好的类型安全性和可维护性。

总结

今天我们聊了 TDD 的思想,以及如何用 TypeScript 的高级模式来实现 TDD。希望你能从中学到一些东西,并在你的项目中尝试使用 TDD,写出更安全、可靠、易于维护的代码。

记住,TDD 并不是银弹,它只是一种编程思想,需要根据实际情况灵活运用。不要为了 TDD 而 TDD,重要的是理解其背后的思想,并将其融入到你的编程实践中。

最后,祝你写码愉快!

友情提示:

技巧 说明
从类型定义开始 在编写任何代码之前,先定义好类型。这可以帮助你更好地理解问题的本质,并避免在后续的开发过程中出现类型错误。
逐步迭代 不要试图一次性写出所有的代码,而是应该逐步迭代,每次只关注一个小的功能点。这可以让你更容易地发现和修复错误,并保持代码的清晰和简洁。
利用 TypeScript 的类型推断 TypeScript 强大的类型推断能力可以让你在很多情况下省略类型注解,让代码更简洁。但是,在某些情况下,显式地指定类型可以提高代码的可读性和可维护性。
编写测试用例 虽然我们今天主要讲的是基于类型的开发,但编写测试用例仍然非常重要。测试用例可以帮助你验证代码的正确性,并确保代码在修改后仍然能够正常工作。
不要害怕重构 随着项目的不断发展,代码可能会变得越来越复杂。不要害怕重构代码,只要确保重构后的代码仍然符合类型定义,并能够通过测试用例的验证即可。

希望这些技巧能帮助你更好地使用 TDD 和 TypeScript。

溜了溜了~ 祝大家 Bug 少少,头发多多!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注