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)。

属性修饰符

对象类型中的每个属性都可以指定一些东西:属性类型、属性是否可选,属性是否可写。

可选属性

大多数时候,我们会发现自己处理的对象可能有一个属性集。这时候,我们可以在这些属性的名字后面加上 ? 符号,将它们标记为可选属性。

interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}
 
function paintShape(opts: PaintOptions) {
  // ...
}
 
const shape = getShape();
paintShape({ shape });
paintShape({ shape, xPos: 100 });
paintShape({ shape, yPos: 100 });
paintShape({ shape, xPos: 100, yPos: 100 });

在这个例子中,xPosyPos 都是可选属性。这两个属性我们可以选择提供或者不提供,所以上面的 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 的参数使用了解构模式,同时也为 xPosyPos 提供了默认值。现在,xPosyPospaintShape 函数体中就一定是有值的,且调用该函数的时候这两个参数仍然是可选的。

注意,目前没有任何方法可以在解构模式中使用类型注解。这是因为下面的语法在 JavaScript 中有其它的语义

function draw({ shape: Shape, xPos: number = 100 /*...*/ }) {
  render(shape);
        ^^^^^^
     // Cannot find name 'shape'. Did you mean 'Shape'?
  render(xPos);
         ^^^^^
    // Cannot find name 'xPos'.
}

在一个对象解构模式中,shape: Shape 表示“捕获 shape 属性并将其重新定义为一个名为 Shape 的局部变量”。同理,xPos: number 也会创建一个名为 number 的变量,它的值就是参数中 xPos 的值。

使用映射修饰符可以移除可选属性。

只读属性

在 TypeScript 中,我们可以将属性标记为 readonly,表示这是一个只读属性。虽然这不会改变运行时的任何行为,但标记为 readonly 的属性在类型检查期间无法再被重写。

interface SomeType {
  readonly prop: string;
}
 
function doSomething(obj: SomeType) {
  // 可以读取 obj.prop
  console.log(`prop has the value '${obj.prop}'.`);
 
  // 但无法重新给它赋值
  obj.prop = "hello";
// Cannot assign to 'prop' because it is a read-only property.
}

使用 readonly 修饰符并不一定意味着某个值是完全不可修改的 —— 或者换句话说,并不意味着它的内容是不可修改的。readonly 仅表示属性本身不可被重写。

interface Home {
  readonly resident: { name: string; age: number };
}
 
function visitForBirthday(home: Home) {
  // 我们可以读取并更新 home.resident 属性
  console.log(`Happy birthday ${home.resident.name}!`);
  home.resident.age++;
}
 
function evict(home: Home) {
  // 但我们无法重写 Home 类型的 resident 属性本身
  home.resident = {
       ^^^^^^^^
// Cannot assign to 'resident' because it is a read-only property.
    name: "Victor the Evictor",
    age: 42,
  };
}

理解 readonly 的含义非常重要。在使用 TypeScript 进行开发的过程中,它可以有效地表明一个对象应该如何被使用。TypeScript 在检查两个类型是否兼容的时候,并不会考虑它们的属性是否是只读的,所以只读属性也可以通过别名进行修改。

interface Person {
  name: string;
  age: number;
}
 
interface ReadonlyPerson {
  readonly name: string;
  readonly age: number;
}
 
let writablePerson: Person = {
  name: "Person McPersonface",
  age: 42,
};
 
// 可以正常执行
let readonlyPerson: ReadonlyPerson = writablePerson;
 
console.log(readonlyPerson.age); // 打印 42
writablePerson.age++;
console.log(readonlyPerson.age); // 打印 43

使用映射修饰符可以移除只读属性。

索引签名

有时候你无法提前知道某个类型所有属性的名字,但你知道这些属性值的类型。在这种情况下,你可以使用索引签名去描述可能值的类型。举个例子:

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

上面的代码中,StringArray 接口有一个索引签名。这个索引签名表明当 StringArraynumber 类型的值索引的时候,它将会返回 string 类型的值。

一个索引签名的属性类型要么是 string,要么是 number

当然,也可以同时支持两种类型……

但前提是,数值型索引返回的类型必须是字符串型索引返回的类型的一个子类型。这是因为,当使用数值索引对象属性的时候,JavaScript 实际上会先把数值转化为字符串。这意味着使用 100(数值)进行索引与使用 "100"(字符串)进行索引,效果是一样的,因此这两者必须一致。

interface Animal {
  name: string;
}
 
interface Dog extends Animal {
  breed: string;
}
 
// Error: indexing with a numeric string might get you a completely separate type of Animal!
interface NotOkay {
  [x: number]: Animal;
// 'number' index type 'Animal' is not assignable to 'string' index type 'Dog'.
  [x: string]: Dog;
}

不过,如果索引签名所描述的类型本身是各个属性类型的联合类型,那么就允许出现不同类型的属性了:

interface NumberOrStringDictionary {
  [index: string]: number | string;
  length: number; // length 是数字,可以
  name: string; // name 是字符串,可以
}

最后,可以设置索引签名是只读的,这样可以防止对应索引的属性被重新赋值:

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

因为索引签名设置了只读,所以无法再更改 myArray[2] 的值。

拓展类型

基于某个类型拓展出一个更具体的类型,这是一个很常见的需求。举个例子,我们有一个 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;
}

跟在某个接口后面的 extends 关键字允许我们高效地复制来自其它命名类型的成员,并且添加上任何我们想要的新成员。这对于减少我们必须编写的类型声明语句有很大的作用,同时也可以表明拥有相同属性的几个不同类型声明之间存在着联系。举个例子,AddressWithUnit 不需要重复编写 street 属性,且由于 street 属性来自于 BasicAddress,开发者可以知道这两个类型之间存在着某种联系。

接口也可以拓展自多个类型:

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

交叉类型

接口允许我们通过拓展原有类型去构建新的类型。TypeScript 还提供了另一种称为“交叉类型”的结构,可以用来结合已经存在的对象类型。

通过 & 操作符可以定义一个交叉类型:

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

这里,我们结合 ColorfulCircle类型,产生了一个新的类型,它拥有 ColorfulCircle 的所有成员。

function draw(circle: Colorful & Circle) {
  console.log(`Color was ${circle.color}`);
  console.log(`Radius was ${circle.radius}`);
}
 
// 可以运行
draw({ color: "blue", radius: 42 });
 
// 不能运行
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'?
*/ 

接口 VS 交叉类型

目前,我们了解到了可以通过两种方式去结合两个相似但存在差异的类型。使用接口,我们可以通过 extends 子句拓展原有类型;使用交叉类型,我们也可以实现类似的效果,并且使用类型别名去命名新类型。这两者的本质区别在于它们处理冲突的方式,而这个区别通常就是我们在接口和交叉类型的类型别名之间选择其一的主要理由。

泛型对象类型

假设我们有一个 Box 类型,它可能包含任何类型的值:stringnumberGiraffe 等。

interface Box {
    contents: any;
}

现在,contents 属性的类型是 any,这当然没问题,但使用 any 可能会导致类型安全问题。

因此我们可以改用 unknown。但这意味着只要我们知道了 contents 的类型,我们就需要做一个预防性的检查,或者使用容易出错的类型断言。

interface Box {
  contents: unknown;
}
 
let x: Box = {
  contents: "hello world",
};
 
// 我们可以检查 x.contents
if (typeof x.contents === "string") {
  console.log(x.contents.toLowerCase());
}
 
// 或者使用类型断言
console.log((x.contents as string).toLowerCase());

还有另一种确保类型安全的做法是,针对每种不同类型的 contents,创建不同的 Box 类型。

interface NumberBox {
  contents: number;
}
 
interface StringBox {
  contents: string;
}
 
interface BooleanBox {
  contents: boolean;
}

但这意味着我们需要创建不同的函数,或者是函数的重载,这样才能操作不同的 Box 类型。

function setContents(box: StringBox, newContents: string): void;
function setContents(box: NumberBox, newContents: number): void;
function setContents(box: BooleanBox, newContents: boolean): void;
function setContents(box: { contents: any }, newContents: any) {
  box.contents = newContents;
}

这会带来非常多的样板代码。而且,我们后续还可能引入新的类型和重载,这未免有些冗余,毕竟我们的 Box 类型和重载只是类型不同,实质上是一样的。

不妨改用一种方式,就是让 Box 类型声明一个类型参数并使用泛型。

interface Box<Type> {
    contents: Type;
}

你可以将这段代码解读为“Box 的类型是 Type,它的 contents 的类型是 Type”。接着,当我们引用 Box 的时候,我们需要传递一个类型参数用于替代 Type

let box: Box<string>;

如果把 Box 看作是实际类型的模板,那么 Type 就是一个会被其它类型代替的占位符。当 TypeScript 看到 Box<string> 的时候,它会将 Box<Type> 中的所有 Type 替换为 string,得到一个类似 { contents: string } 的对象。换句话说,Box<string> 和之前例子中的 StringBox 是等效的。

interface Box<Type> {
  contents: Type;
}
interface StringBox {
  contents: string;
}
 
let boxA: Box<string> = { contents: "hello" };
boxA.contents;
     ^^^^^^^^   
    // (property) Box<string>.contents: string
 
let boxB: StringBox = { contents: "world" };
boxB.contents;
     ^^^^^^^^   
    // (property) StringBox.contents: string

因为 Type 可以被任何类型替换,所以 Box 具有可重用性。这意味着当我们的 contents 需要一个新类型的时候,完全无需再声明一个新的 Box 类型(虽然这么做没有任何问题)。

还有 42% 的精彩内容
©著作权归作者所有,转载或内容合作请联系作者
支付 ¥5.99 继续阅读
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容