NodeJS系列(15)- TypeScript (二) | 对象类型 (Object Types)

发布时间 2023-11-07 13:19:47作者: 垄山小站

在 “NodeJS系列(14)- TypeScript (一) | 安装 TypeScript、常用类型” 里,我们简单介绍了 TypeScript 的安装配置,讲解和演示了 TypeScript 常用类型。

本文继续介绍 TypeScript 对象类型 (Object Types)。


TypeScript:https://www.typescriptlang.org/(中文版:https://ts.nodejs.cn/)
Microsoft TypeScript: https://devblogs.microsoft.com/typescript/
GitHub:https://github.com/microsoft/TypeScript
NPMJS: https://www.npmjs.com/package/typescript


在 JavaScript 中,我们分组和传递数据的基本方式是通过对象。在 TypeScript 中,我们通过对象类型来表示它们。

正如我们所见,它们可以是匿名的:
    function greet(person: { name: string; age: number }) {
        return "Hello " + person.name;
    }

 

或者它们可以通过使用接口来命名:
    interface Person {
        name: string;
        age: number;
    }
    
    function greet(person: Person) {
        return "Hello " + person.name;
    }

 

或类型别名:
    type Person = {
        name: string;
        age: number;
    };
    
    function greet(person: Person) {
        return "Hello " + person.name;
    }

 

在上述所有三个示例中,我们编写的函数接受包含属性 name(必须是 string)和 age(必须是 number)的对象。


1. 属性修饰符

    1) 可选属性

        在处理可能具有属性集的对象,可以通过在其名称末尾添加问号 (?) 来将这些属性标记为可选。

            interface PaintOptions {
                shape: string;
                xPos?: number;
                yPos?: number;
            }
            
            function paintShape(opts: PaintOptions) {
                let xPos = opts.xPos;   // (property) PaintOptions.xPos?: number | undefined
                let yPos = opts.yPos;   // (property) PaintOptions.yPos?: number | undefined

                console.log(opts.shape, ": ", xPos, ",", yPos);
            }
            
            paintShape({ shape: "circle" });
            paintShape({ shape: "rectangle", xPos: 100 });
            paintShape({ shape: "triangle", yPos: 100 });
            paintShape({ shape: "diamond", xPos: 100, yPos: 100 });


        在这个例子中,xPos 和 yPos 都被认为是可选的。 我们可以选择提供其中任何一个,因此上面对 paintShape 的每个调用都是有效的。 所有的可选性真正说明的是,如果设置了属性,它最好有一个特定的类型。

        我们也可以从这些属性中读取 - 但是当我们在 strictNullChecks 下读取时,TypeScript 会告诉我们它们可能是 undefined。

            function paintShape(opts: PaintOptions) {
                let xPos = opts.xPos;   // (property) PaintOptions.xPos?: number | undefined
                let yPos = opts.yPos;   // (property) PaintOptions.yPos?: number | undefined

                // ...
            }


        在 JavaScript 中,即使该属性从未被设置,我们仍然可以访问它 - 它只会给我们值 undefined。 我们可以通过检查来专门处理 undefined。

            function paintShape(opts: PaintOptions) {
                let xPos = opts.xPos === undefined ? 0 : opts.xPos;     // let xPos: number
                let yPos = opts.yPos === undefined ? 0 : opts.yPos;     // let yPos: number

                // ...
            }


        这种为未指定值设置默认值的模式非常普遍,以至于 JavaScript 有语法来支持它。

            function paintShape({ shape, xPos = 0, yPos = 0 }: PaintOptions) {
                console.log("x coordinate at", xPos);   // (parameter) xPos: number
                console.log("y coordinate at", yPos);   // (parameter) yPos: number

                // ...
            }


        这里我们使用 解构模式 作为 paintShape 的参数,并为 xPos 和 yPos 提供了 默认值。 现在 xPos 和 yPos 都肯定存在于 paintShape 的主体中,但对于 paintShape 的任何调用者都是可选的。

    2) readonly 属性

        对于 TypeScript,属性也可以标记为 readonly。 虽然它不会在运行时改变任何行为,但在类型检查期间无法写入标记为 readonly 的属性。

            interface SomeType {
                readonly prop: string;
            }
            
            function doSomething(obj: SomeType) {
                // We can read from 'obj.prop'.
                console.log(`prop has the value '${obj.prop}'.`);
            
                // But we can't re-assign it.
                obj.prop = "hello";     // Cannot assign to 'prop' because it is a read-only property.
            }


        使用 readonly 修饰符并不一定意味着一个值是完全不可变的 - 或者换句话说,它的内部内容不能改变。 这只是意味着属性本身不能被重写。

            interface Home {
                readonly resident: { name: string; age: number };
            }
            
            function visitForBirthday(home: Home) {
                // We can read and update properties from 'home.resident'.
                console.log(`Happy birthday ${home.resident.name}!`);
                home.resident.age++;
            }
            
            function evict(home: Home) {
                // But we can't write to the 'resident' property itself on a 'Home'.
                home.resident = {   // Cannot assign to 'resident' because it is a read-only property.
                    name: "Victor the Evictor",
                    age: 42,
                };
            }


        管理对 readonly 含义的期望很重要。 在 TypeScript 的开发期间触发关于如何使用对象的意图很有用。 TypeScript 在检查两种类型是否兼容时不会考虑这两种类型的属性是否为 readonly,因此 readonly 属性也可以通过别名来更改。

            interface Person {
                name: string;
                age: number;
            }
            
            interface ReadonlyPerson {
                readonly name: string;
                readonly age: number;
            }
            
            let writablePerson: Person = {
                name: "Person McPersonface",
                age: 42,
            };
            
            // works
            let readonlyPerson: ReadonlyPerson = writablePerson;
            
            console.log(readonlyPerson.age); // prints '42'
            writablePerson.age++;
            console.log(readonlyPerson.age); // prints '43'


            使用 映射修饰符,你可以删除 readonly 属性。

    3) 索引签名

        有时你并不提前知道类型属性的所有名称,但你确实知道值的形状。

        在这些情况下,你可以使用索引签名来描述可能值的类型,例如:

            interface StringArray {
                [index: number]: string;
            }
            
            const myArray: StringArray = getStringArray();
            const secondItem = myArray[1];
                    
            const secondItem: string


        上面,我们有一个 StringArray 接口,它有一个索引签名。 这个索引签名表明当一个 StringArray 被一个 number 索引时,它将返回一个 string。

        索引签名属性只允许使用某些类型: string、number、symbol、模板字符串模式以及仅由这些组成的联合类型。

        可以支持两种类型的索引器......
        
        虽然字符串索引签名是描述 “dictionary” 模式的强大方式,但它们还强制所有属性与其返回类型匹配。 这是因为字符串索引声明 obj.property 也可用作 obj["property"]。 在下面的例子中,name 的类型与字符串索引的类型不匹配,类型检查器给出错误:

            interface NumberDictionary {
                [index: string]: number;
                
                length: number; // ok
                name: string;
                Property 'name' of type 'string' is not assignable to 'string' index type 'number'.
            }


        但是,如果索引签名是属性类型的联合,则可以接受不同类型的属性:

            interface NumberOrStringDictionary {
                [index: string]: number | string;
                length: number; // ok, length is a number
                name: string; // ok, name is a string
            }


        最后,你可以制作索引签名 readonly 以防止分配给它们的索引:

            interface ReadonlyStringArray {
                readonly [index: number]: string;
            }
            
            let myArray: ReadonlyStringArray = getReadOnlyStringArray();
            myArray[2] = "Mallory";
            Index signature in type 'ReadonlyStringArray' only permits reading.


        不能设置 myArray[2],因为索引签名是 readonly。


2. 溢出属性检查

    在何处以及如何为对象分配类型可以在类型系统中产生差异。 这方面的一个关键示例是过度属性检查,它会在创建对象时更彻底地验证对象并在创建期间将其分配给对象类型。

        interface SquareConfig {
            color?: string;
            width?: number;
        }
        
        function createSquare(config: SquareConfig): { color: string; area: number } {
            return {
                color: config.color || "red",
                area: config.width ? config.width * config.width : 20,
            };
        }
        
        let mySquare = createSquare({ colour: "red", width: 100 });     // Argument of type '{ colour: string; width: number; }' is not assignable to parameter of type 'SquareConfig'.
                                                                        // Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?


    请注意,createSquare 的给定参数拼写为 colour 而不是 color。 在普通的 JavaScript 中,这种事情会悄无声息地失败。

    你可能会争辩说这个程序的类型是正确的,因为 width 属性是兼容的,没有 color 属性存在,额外的 colour 属性是微不足道的。

    但是,TypeScript 认为这段代码中可能存在错误。 对象字面在将它们分配给其他变量或将它们作为参数传递时会得到特殊处理并进行额外的属性检查。 如果一个对象字面量有任何 “目标类型” 没有的属性,你会得到一个错误:

        let mySquare = createSquare({ colour: "red", width: 100 });     // Argument of type '{ colour: string; width: number; }' is not assignable to parameter of type 'SquareConfig'.
                                                                        // Object literal may only specify known properties, but 'colour' does not exist in type 'SquareConfig'. Did you mean to write 'color'?

    绕过这些检查实际上非常简单。 最简单的方法是只使用类型断言:

        let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);

    但是,如果你确定该对象可以具有一些以某种特殊方式使用的额外属性,则更好的方法可能是添加字符串索引签名。 如果 SquareConfig 可以具有上述类型的 color 和 width 属性,但也可以具有任意数量的其他属性,那么我们可以这样定义它:

        interface SquareConfig {
            color?: string;
            width?: number;
            [propName: string]: any;
        }


    这里我们说 SquareConfig 可以有任意数量的属性,只要它们不是 color 或 width,它们的类型并不重要。

    绕过这些检查的最后一种方法(可能有点令人惊讶)是将对象分配给另一个变量: 由于分配 squareOptions 不会进行过多的属性检查,因此编译器不会给你错误:

        let squareOptions = { colour: "red", width: 100 };
        let mySquare = createSquare(squareOptions);

    只要你在 squareOptions 和 SquareConfig 之间具有共同属性,上述变通方法就会起作用。 在此示例中,它是属性 width。 但是,如果变量没有任何公共对象属性,它将失败。 例如:

        let squareOptions = { colour: "red" };
        let mySquare = createSquare(squareOptions);     // Type '{ colour: string; }' has no properties in common with type 'SquareConfig'.

    请记住,对于像上面这样的简单代码,你可能不应该尝试对这些检查进行 “到处走走”。 对于具有方法和保持状态的更复杂的对象字面量,你可能需要牢记这些技术,但大多数过多的属性错误实际上是错误。

    这意味着如果你遇到诸如选项包之类的过多属性检查问题,你可能需要修改一些类型声明。 在这种情况下,如果可以将具有 color 或 colour 属性的对象传递给 createSquare,则应该修改 SquareConfig 的定义以反映这一点。


3. 扩展类型

    拥有可能是其他类型的更具体版本的类型是很常见的。 例如,我们可能有一个 BasicAddress 类型,它描述了在美国发送信件和包所需的字段。

        interface BasicAddress {
            name?: string;
            street: string;
            city: string;
            country: string;
            postalCode: string;
        }


    在某些情况下这就足够了,但如果某个地址的架构物有多个单元,则地址通常有一个与之关联的单元号。 然后我们可以描述一个 AddressWithUnit。

        interface AddressWithUnit {
            name?: string;
            unit: string;
            street: string;
            city: string;
            country: string;
            postalCode: string;
        }


    这可以完成工作,但这里的缺点是当我们的更改纯粹是添加时,我们必须重复 BasicAddress 中的所有其他字段。 相反,我们可以扩展原来的 BasicAddress 类型,只添加 AddressWithUnit 独有的新字段。

        interface BasicAddress {
            name?: string;
            street: string;
            city: string;
            country: string;
            postalCode: string;
        }
        
        interface AddressWithUnit extends BasicAddress {
            unit: string;
        }


    interface 上的 extends 关键字允许我们有效地从其他命名类型复制成员,并添加我们想要的任何新成员。 这对于减少我们必须编写的类型声明样板的数量以及表明同一属性的几个不同声明可能相关的意图很有用。 例如,AddressWithUnit 不需要重复 street 属性,因为 street 源自 BasicAddress,所以读者会知道这两种类型在某种程度上是相关的。

    interface 也可以从多种类型扩展。

        interface Colorful {
            color: string;
        }
        
        interface Circle {
            radius: number;
        }
        
        interface ColorfulCircle extends Colorful, Circle {}
        
        const cc: ColorfulCircle = {
            color: "red",
            radius: 42,
        };


4. 交集类型

    interface 允许我们通过扩展其他类型来构建新类型。 TypeScript 提供了另一种称为交集类型的构造,主要用于组合现有的对象类型。

    交集类型是使用 & 运算符定义的。

        interface Colorful {
            color: string;
        }

        interface Circle {
            radius: number;
        }
        
        type ColorfulCircle = Colorful & Circle;


    在这里,我们将 Colorful 和 Circle 相交以生成一个包含 Colorful 和 Circle 的所有成员的新类型。

        function draw(circle: Colorful & Circle) {
            console.log(`Color was ${circle.color}`);
            console.log(`Radius was ${circle.radius}`);
        }
        
        // okay
        draw({ color: "blue", radius: 42 });
        
        // oops
        draw({ color: "red", raidus: 42 });     // Argument of type '{ color: string; raidus: number; }' is not assignable to parameter of type 'Colorful & Circle'.
                                                // Object literal may only specify known properties, but 'raidus' does not exist in type 'Colorful & Circle'. Did you mean to write 'radius'?

 


5. 泛型对象类型

    1) Array 类型

        泛型对象类型通常是某种容器类型,它们独立于它们所包含的元素类型工作。 数据结构以这种方式工作是理想的,这样它们就可以在不同的数据类型中重用。

        事实证明,在本手册中,我们一直在使用一种类型: Array 型。 每当我们写出像 number[] 或 string[] 这样的类型时,这实际上只是 Array<number> 和 Array<string> 的简写。

            function doSomething(value: Array<string>) {
                // ...
            }
            
            let myArray: string[] = ["hello", "world"];
            
            // either of these work!
            doSomething(myArray);
            doSomething(new Array("hello", "world"));


        很像上面的 Box 类型,Array 本身是一个泛型类型。

            interface Array<Type> {
                /**
                * Gets or sets the length of the array.
                */
                length: number;
                
                /**
                * Removes the last element from an array and returns it.
                */
                pop(): Type | undefined;
                
                /**
                * Appends new elements to an array, and returns the new length of the array.
                */
                push(...items: Type[]): number;
                
                // ...
            }


        现代 JavaScript 还提供了其他泛型的数据结构,如 Map<K, V>、Set<T> 和 Promise<T>。 所有这一切真正意味着由于 Map、Set 和 Promise 的行为方式,它们可以与任何类型的集合一起使用。

    2) ReadonlyArray 类型

        ReadonlyArray 是一种特殊类型,用于描述不应更改的数组。

            function doStuff(values: ReadonlyArray<string>) {
                // We can read from 'values'...
                const copy = values.slice();
                console.log(`The first value is ${values[0]}`);
                
                // ...but we can't mutate 'values'.
                values.push("hello!");      // Property 'push' does not exist on type 'readonly string[]'.
            }


        就像属性的 readonly 修饰符一样,它主要是我们可以用于意图的工具。 当我们看到一个返回 ReadonlyArray 的函数时,它告诉我们根本不打算更改内容,而当我们看到一个消耗 ReadonlyArray 的函数时,它告诉我们可以将任何数组传递给该函数,而不必担心它会改变它的内容。

        与 Array 不同,我们没有可以使用的 ReadonlyArray 构造函数。

            new ReadonlyArray("red", "green", "blue");  // 'ReadonlyArray' only refers to a type, but is being used as a value here.

        相反,我们可以将常规的 Array 分配给 ReadonlyArray。

            const roArray: ReadonlyArray<string> = ["red", "green", "blue"];

        正如 TypeScript 为 Array<Type> 和 Type[] 提供简写语法一样,它也为 ReadonlyArray<Type> 和 readonly Type[] 提供简写语法。

            function doStuff(values: readonly string[]) {
                // We can read from 'values'...
                const copy = values.slice();
                console.log(`The first value is ${values[0]}`);
                
                // ...but we can't mutate 'values'.
                values.push("hello!");      // Property 'push' does not exist on type 'readonly string[]'.
            }


        最后要注意的一点是,与 readonly 属性修饰符不同,可分配性在常规 Array 和 ReadonlyArray 之间不是双向的。

            let x: readonly string[] = [];
            let y: string[] = [];
            
            x = y;
            y = x;      // The type 'readonly string[]' is 'readonly' and cannot be assigned to the mutable type 'string[]'.


    3) 元组类型

        元组类型是另一种 Array 类型,它确切地知道它包含多少个元素,以及它在特定位置包含哪些类型。

            type StringNumberPair = [string, number];

        这里,StringNumberPair 是 string 和 number 的元组类型。 与 ReadonlyArray 一样,它在运行时没有表示,但对 TypeScript 很重要。 对于类型系统,StringNumberPair 描述了 0 索引包含 string 和 1 索引包含 number 的数组。

            function doSomething(pair: [string, number]) {
                const a = pair[0];
                    
                const a: string
                const b = pair[1];
                    
                const b: number
                // ...
            }
 
            doSomething(["hello", 42]);


        如果我们试图索引超过元素的数量,我们会得到一个错误。

            function doSomething(pair: [string, number]) {
                // ...
            
                const c = pair[2];    // Tuple type '[string, number]' of length '2' has no element at index '2'.
            }


        我们也可以使用 JavaScript 的数组解构来 解构元组。

            function doSomething(stringHash: [string, number]) {
                const [inputString, hash] = stringHash;
            
                console.log(inputString);   // const inputString: string
                console.log(hash);      // const hash: number
            }


        元组类型在大量基于约定的 API 中很有用,其中每个元素的含义都是 “明确的”。 这使我们在解构变量时可以灵活地命名变量。 在上面的示例中,我们可以将元素 0 和 1 命名为我们想要的任何名称。

        但是,由于并非每个用户都对显而易见的事物持有相同的看法,因此可能值得重新考虑使用具有描述性属性名称的对象是否更适合你的 API。

        除了那些长度检查之外,像这样的简单元组类型等价于为特定索引声明属性的 Array 版本,以及使用数字字面类型声明 length 的类型。

            interface StringNumberPair {
                // specialized properties
                length: 2;
                0: string;
                1: number;
                
                // Other 'Array<string | number>' members...
                slice(start?: number, end?: number): Array<string | number>;
            }


        你可能感兴趣的另一件事是元组可以通过写出问号(元素类型后的 ?)来具有可选属性。 可选的元组元素只能放在最后,也会影响 length 的类型。

            type Either2dOr3d = [number, number, number?];
            
            function setCoordinate(coord: Either2dOr3d) {
                const [x, y, z] = coord;    // const z: number | undefined
                console.log(`Provided coordinates had ${coord.length} dimensions`);     // (property) length: 2 | 3
            }


        元组也可以有剩余元素,它们必须是数组/元组类型。

            type StringNumberBooleans = [string, number, ...boolean[]];
            type StringBooleansNumber = [string, ...boolean[], number];
            type BooleansStringNumber = [...boolean[], string, number];


        StringNumberBooleans 描述了一个元组,其前两个元素分别是 string 和 number,但后面可以有任意数量的 boolean。
        StringBooleansNumber 描述了一个元组,它的第一个元素是 string,然后是任意数量的 boolean,并以 number 结尾。
        BooleansStringNumber 描述了一个元组,其起始元素是任意数量的 boolean,以 string 和 number 结尾。

        带有剩余元素的元组没有集合 “length” - 它只有一组位于不同位置的知名元素。

            const a: StringNumberBooleans = ["hello", 1];
            const b: StringNumberBooleans = ["beautiful", 2, true];
            const c: StringNumberBooleans = ["world", 3, true, false, true, false, true];


        为什么 optional 和 rest 元素可能有用? 好吧,它允许 TypeScript 将元组与参数列表对应起来。 元组类型可以在 剩余形参和实参 中使用,因此如下:

            function readButtonInput(...args: [string, number, ...boolean[]]) {
                const [name, version, ...input] = args;
                // ...
            }


        基本上相当于:

            function readButtonInput(name: string, version: number, ...input: boolean[]) {
                // ...
            }

        当你想用一个剩余参数获取可变数量的参数时,这很方便,并且你需要最少数量的元素,但你不想引入中间变量。

    4) readonly 元组类型

        关于元组类型的最后一点注意事项 - 元组类型有 readonly 变体,可以通过在它们前面加上 readonly 修饰符来指定 - 就像数组速记语法一样。

            function doSomething(pair: readonly [string, number]) {
                // ...
            }

        正如你所料,TypeScript 中不允许写入 readonly 元组的任何属性。

            function doSomething(pair: readonly [string, number]) {
                pair[0] = "hello!";     // Cannot assign to '0' because it is a read-only property.
            }

        在大多数代码中,元组往往被创建并保持不变,因此尽可能将类型注释为 readonly 元组是一个很好的默认设置。 这一点也很重要,因为带有 const 断言的数组字面将使用 readonly 元组类型来推断。

            let point = [3, 4] as const;
            
            function distanceFromOrigin([x, y]: [number, number]) {
                return Math.sqrt(x ** 2 + y ** 2);
            }
            
            distanceFromOrigin(point);      // Argument of type 'readonly [3, 4]' is not assignable to parameter of type '[number, number]'.
                                            // The type 'readonly [3, 4]' is 'readonly' and cannot be assigned to the mutable type '[number, number]'.


        在这里,distanceFromOrigin 从不修改其元素,但需要一个可变元组。 由于 point 的类型被推断为 readonly [3, 4],它不会与 [number, number] 兼容,因为该类型不能保证 point 的元素不会发生修改。