JavaScript的物件功能十分複雜,這個章節將會用TypeScript嘗試釐清JavaScript的物件建立方式,並且利用物件導向觀念來實作程式。



建立物件

JavaScript建立物件的方式有三種,第一種是用大括號{},第二種是用函數,第三種是用類別。在TypeScript中,任何物件的型別均屬於object型別,而上一章介紹的陣列和函數也是屬於物件的一種。

大括號{}物件

在JavaScript中,可以直接用一對大括號,也就是{}來表示一個沒有任何欄位的「空物件」。

如下:

let o = {};

以上o變數儲存的值為{}這個物件的參考(reference)。此時的o變數的型別也為{}。如下:

let o: {} = {};

利用大括號產生出來的物件時,可以在大括號中直接以key: value的格式來添加物件屬性(欄位或方法)。如果有多個屬性,就用逗號,隔開。例如:

let o = {
    name: "David",
    age: 18,
    toString: function () {
        return `${this.age}: ${this.name}`;
    },
};

console.log(o.toString());

以上程式會輸出:

18: David

以上程式的o變數的型別為{ name: string, age: number, toString: () => string }(用逗號,分隔屬性),或{ name: string; age: number; toString: () => string }(用分號;分隔屬性)。toStringo物件的方法,在這方法的程式實作區塊中,使用了this關鍵字來表示目前這個物件的參考。這邊要注意的是,如果是使用Lambda語法來定義函數,則無法使用this關鍵字來取得物件自身的參考。如下:

let o: { name: string, age: number, toString: () => string } = {
    name: "David",
    age: 18,
    toString: () => `${this.age}: ${this.name}`,
};

console.log(o.toString());

以上程式會編譯失敗。

不過在JavaScript中,我們可以在物件的方法內直接存取用來存放物件參考的變數或常數。因此以上程式可以修改為:

let o: { name: string, age: number, toString: () => string } = {
    name: "David",
    age: 18,
    toString: () => `${o.age}: ${o.name}`,
};

console.log(o.toString());

雖然我們在用大括號語法來建立物件時,屬性的鍵值是直接填上一個名稱,但這個名稱實際上會被當作字串來處理。所以以上程式其實就等同於:

let o: { name: string, age: number, toString: () => string } = {
    "name": "David",
    "age": 18,
    "toString": () => `${o.age}: ${o.name}`,
};

console.log(o.toString());

在某些情況下,我們必須明確地用字串來當作物件屬性的鍵值。例如student-number這個名稱,因為其中帶有減號-,所以必須要明確地用字串來表示。如下:

let o: { name: string, age: number, toString: () => string, "student-number": string } = {
    name: "David",
    age: 18,
    toString: () => `${o.age}: ${o.name}`,
    "student-number": "B12345678",
};

console.log(o.toString());

在存取物件的屬性時,也可以在物件後使用中括號[]語法,在中括號中傳入要存取的字串鍵值。如下:

let o: { name: string, age: number, toString: () => string, "student-number": string } = {
    name: "David",
    age: 18,
    toString: () => `${o.age}: ${o.name}`,
    "student-number": "B12345678",
};

console.log(o["toString"]());
console.log(o["student-number"]);

如果想要讓物件擁有可選的屬性,可以在定義物件型別時,在屬性名稱後面加上問號?。例如要將student-number這個屬性變成可選的,程式如下:

let o: { name: string, age: number, toString: () => string, "student-number"?: string } = {
    name: "David",
    age: 18,
    toString: () => `${o.age}: ${o.name}`,
};

console.log(o["student-number"]);

o["student-number"] = "B12345678";

console.log(o["student-number"]);

以上程式第7行,會輸出undefined,因為我們在建立o物件時,並沒有替其添加student-number屬性,如果查詢的鍵值並不是該物件的所擁有的話,就會回傳undefined。所以student-number這個屬性的型別雖然被明確定義為string,但它實際上是string | undefined

若想要用Lambda語法來實作可以回傳大括號物件的函數,根據前面的章節提到的在=>後直接加上表達式的語法,我們很可能會寫出如下的程式:

let createObject = (name: string, age: number, studentNumber?: string) => {
    name: name,
    age: age,
    toString: function () {
        return `${this.age}: ${this.name}`;
    },
    "student-number": studentNumber,
};

let o1 = createObject("David", 18, "B12345678");

let o2 = createObject("Steven", 19);

然而以上程式有嚴重的語法錯誤,因為在=>後接上大括號{}的話就變成是程式敘述區塊了。為了解決這個問題,我們可以替大括號物件再圍上小括號()。如下:

let createObject = (name: string, age: number, studentNumber?: string) => ({
    name: name,
    age: age,
    toString: function () {
        return `${this.age}: ${this.name}`;
    },
    "student-number": studentNumber,
});

let o1 = createObject("David", 18, "B12345678");

let o2 = createObject("Steven", 19);

注意這邊的toString方法必須要使用this關鍵字來存取物件本身的屬性。

物件的屬性名稱除了可以直接打上名稱或是使用字串之外,也可以用型別為numberstringsymbol的變數和常數來代替,只要在定義屬性名稱時替這些變數和常數加上中括號[]包起來即可。例如:

const NAME = "name";
const AGE = "age";
const TO_STRING = "toString";
const STUDENT_NUMBER = "student-number";

let o: { [NAME]: string, [AGE]: number, [TO_STRING]: () => string, [STUDENT_NUMBER]: string } = {
    [NAME]: "David",
    [AGE]: 18,
    [TO_STRING]: () => `${o[AGE]}: ${o[NAME]}`,
    [STUDENT_NUMBER]: "B12345678",
};

console.log(o.toString());
console.log(o["student-number"]);

再例如:

const NAME = Symbol();
const AGE = Symbol();
const TO_STRING = Symbol();
const STUDENT_NUMBER = Symbol();

let o: { [NAME]: string, [AGE]: number, [TO_STRING]: () => string, [STUDENT_NUMBER]: string } = {
    [NAME]: "David",
    [AGE]: 18,
    [TO_STRING]: () => `${o[AGE]}: ${o[NAME]}`,
    [STUDENT_NUMBER]: "B12345678",
};

console.log(o[TO_STRING]());
console.log(o[STUDENT_NUMBER]);

值得注意的是,即便我們使用除了字串以外的變數作為物件的屬性名稱,number型別實際上在執行階段仍會被自動轉成字串來使用。在TypeScript中,還是要遵照正確型別的鍵值來存取屬性才行。

const i = "5";
const ii = 5;

const o = {
    [i]: "567",
    [ii]: "567",
};

for (const k in o) {
    console.log(k, typeof k);
}

以上程式的執行結果為:

5 string

再來看看下面這個程式:

const i = 5;

const o = { [i]: "567" };

console.log(o[5]);
console.log(o["5"]);

以上程式可以通過編譯,但是若將程式改為以下這樣:

const i = 5;

const o = { [i]: "567" };

console.log(o[6]);
console.log(o["6"]);

以上程式,在嚴格模式下,第5行和第6行都會編譯失敗。這是因為TypeScript在嚴格模式下知道o物件此時只有一個屬性名稱為5的屬性,所以用6或是"6"作為鍵值來存取屬性才不行的嗎?也不是,事實上,i的型別是5,而不是我們前面所說的number,這是因為i是一個「常數」。所以TypeScript在嚴格模式下只允許存取o物件時須使用型別5或是"5"的鍵值來存取屬性。有關於將定數作為型別的用法,會在之後的章節更加詳細介紹。

若將i改用let或是var關鍵字來宣告,會使得i的型別變為number,則只有第6行會編譯失敗。但是,此時我們也不能使用"5"作為鍵值來存取o物件的屬性。

let i = 5;

const o = { [i]: "567" };

console.log(o[6]);
console.log(o["5"]);

以上程式,在嚴格模式下,第6行會編譯失敗。

函數物件

在JavaScript中,任意函數都可以利用new關鍵字來建立出物件實體。例如:

function F() {

}

let a = new F();

console.log(typeof a);
console.log(a);

以上程式會輸出:

object
F {}

F是一個沒有參數也沒有程式敘述的函數,new F()可以建立出一個未知型別的物件。

在嚴格模式下,以上程式會編譯失敗,因為編譯器無法將未知型別的物件直接推論為any。在這個情況下,我們也只能利用前面的章節介紹過的as關鍵字,將F函數強制轉型為any,來避開編譯器的規則。

如下:

function F() {

}

let a = new (F as any)();

console.log(typeof a);
console.log(a);

事實上,F函數的建構子即為自己本身,F函數的參數就是建構子的參數,在F函數的主體中可以直接使用this關鍵字來存取到自身的屬性。例如:

function F(name: string, age: number, studentNumber?: string) {
    this.name = name;
    this.age = age;
    this.toString = () => `${this.name}: ${this.age}`;
    this.studentNumber = studentNumber;
}

let o1 = new (F as any)("David", 18, "B12345678");

let o2 = new (F as any)("Steven", 19);

console.log(o1.toString());
console.log(o2.toString());

以上程式在嚴格模式下會編譯失敗,因為TypeScript的編譯器無法將this的型別自動推論為any,所以在存取this.namethis.age等屬性時會有問題。為了解決這個問題,我們必須要替this指定型別。

在TypeScript中,可以將this作為參數名稱使其成為函數的第一個參數,如此一來就可以設定this的型別了。如下:

function F(this: any, name: string, age: number, studentNumber?: string) {
    this.name = name;
    this.age = age;
    this.toString = () => `${this.name}: ${this.age}`;
    this.studentNumber = studentNumber;
}

let o1 = new (F as any)("David", 18, "B12345678");

let o2 = new (F as any)("Steven", 19);

console.log(o1.toString());
console.log(o2.toString());

或是:

function F(this: {name: string, age: number, toString: () => string, studentNumber?: string}, name: string, age: number, studentNumber?: string) {
    this.name = name;
    this.age = age;
    this.toString = () => `${this.name}: ${this.age}`;
    this.studentNumber = studentNumber;
}

let o1 = new (F as any)("David", 18, "B12345678");

let o2 = new (F as any)("Steven", 19);

console.log(o1.toString());
console.log(o2.toString());

以上程式輸出結果為:

David: 18
Steven: 19

還記得我們在上一章有提到symbol型別的值可以拿來實作私有的屬性嗎?我們只要在物件的建構子中用Symbol函數建立symbol型別的值,來當物件屬性的鍵值,這個屬性就會是私有的,因為外部無法建立一個一模一樣的symbol型別的值出來當作要查詢的鍵值。

例如要將F函數產生出來的物件的nameage欄位都變成私有的話,程式改寫如下:

function F(this: any, name: string, age: number, studentNumber?: string) {
    const NAME = Symbol();
    const AGE = Symbol();

    this[NAME] = name;
    this[AGE] = age;
    this.toString = () => `${this[NAME]}: ${this[AGE]}`;
    this.studentNumber = studentNumber;
}

let o1 = new (F as any)("David", 18, "B12345678");

let o2 = new (F as any)("Steven", 19);

console.log(o1.toString());
console.log(o2.toString());

不過如果要使用私有屬性的話,this的型別貌似就只能是any了,除非我們把它做成雙層函數,如下:

function F(name: string, age: number, studentNumber?: string) {
    const NAME = Symbol();
    const AGE = Symbol();

    function inner(this: { [NAME]: string, [AGE]: number, toString: () => string, studentNumber?: string }, name: string, age: number, studentNumber?: string) {
        this[NAME] = name;
        this[AGE] = age;
        this.toString = () => `${this[NAME]}: ${this[AGE]}`;
        this.studentNumber = studentNumber;
    }

    return new (inner as any)(name, age, studentNumber);
}

let o1 = new (F as any)("David", 18, "B12345678");

let o2 = new (F as any)("Steven", 19);

console.log(o1.toString());
console.log(o2.toString());

在函數物件的建構子中回傳的值,即為使用new關鍵字時建立出來的物件。如果函數的建構子不回傳值,則會用this作為使用new關鍵字時建立出來的物件。

類別物件

JavaScript的函數物件非常不適合在TypeScript上使用,因為型別實在是太不好處理了!還好在ES6之後,JavaScript新增了類別物件的功能,使我們可以直接使用class關鍵字來定義類別。

在TypeScript中,類別的語法結構如下:

class 類別名稱 {
    欄位1
    欄位2
    ...
    欄位n

    建構子

    方法1
    方法2
    ...
    方法n
}

欄位的撰寫方式類似使用varletconst關鍵字來宣告字串或常數那樣,但不需要寫上varletconst關鍵字,並且在欄位名稱的後面可以加上問號?來表示該欄位為可選欄位。欄位、建構子和方法的撰寫順序可以隨意,並不會影響程式的運作。類別不一定要明確寫出建構子,如果不撰寫建構子的話,會被當作是有一個無任何參數的建構子。

建構子和方法的撰寫方式類似使用function關鍵字來建立函數那樣,但不需要寫上function關鍵字。

拿剛才撰寫過的函數物件來舉例:

function F(this: any, name: string, age: number, studentNumber?: string) {
    this.name = name;
    this.age = age;
    this.toString = () => `${this.name}: ${this.age}`;
    this.studentNumber = studentNumber;
}

以上程式可以用類別物件改寫為:

class F {
    name: string;
    age: number;
    studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

若要建立類別F的物件實體,與函數物件一樣,也是要使用new關鍵字。不過TypeScript可以正常推論類別的建構子,所以不需要再像函數物件那樣用as關鍵字來轉型了!而且產生出來的物件型別就是類別本身。

例如:

class F {
    name: string;

    age: number;

    studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o1: F = new F("David", 18, "B12345678");

let o2: F = new F("Steven", 19);

對於私有屬性,我們也只要在屬性名稱前,加上private修飾子即可。如下:

class F {
    private name: string;

    private age: number;

    studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

以上程式將F類別的nameage物件欄位的存取權限設為私有的了。

建構子也可以使用private來修飾,如果這樣做的話,這個類別就無法被實體化。

既然有private修飾子,那有public修飾子嗎?當然有!public修飾子可以用在建構子的參數上,能使該參數直接變成一般的物件屬性,並且可以直接透過建構子參數來初始化,不必再在建構子主體中撰寫this.xxx = xxx這樣的程式敘述。

例如:

class F {
    name: string;

    age: number;

    studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o: F = new F("David", 18, "B12345678");

console.log(o.name);
console.log(o.age);
console.log(o.studentNumber);

以上程式可以修改成:

class F {
    constructor(public name: string, public age: number, public studentNumber?: string) {
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o: F = new F("David", 18, "B12345678");

console.log(o.name);
console.log(o.age);
console.log(o.studentNumber);

物件的原型(Prototype)與繼承(Inheritance)

事實上,我們剛才寫的類別物件:

class F {
    name: string;
    age: number;
    studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

它其實更接近於以下這個函數物件:

function F(this: any, name: string, age: number, studentNumber?: string) {
    this.name = name;
    this.age = age;
    this.studentNumber = studentNumber;
}

F.prototype.toString = function() {
    return `${this.name}: ${this.age}`;
};

直接在類別物件中定義的方法,都會變成類別本身的prototype屬性下的方法。

當我們要存取一個物件的屬性時,例如要存取toString這個屬性好了,JavaScript會先去尋找物件本身有沒有toString屬性,如果沒有的話,就會去尋找該物件的上級物件的prototype屬性下有沒有toString屬性。如果還是沒有,就會去尋找這個上級物件的prototype屬性下的__proto__屬性有沒有toString屬性。如果還是沒有,就會去尋找這個上級物件的prototype屬性下的__proto__屬性下的__proto__屬性下有沒有toString屬性。依此類推,直到找不到可用的__proto__屬性為止。此時若物件是陣列,就會去找Array這個JavaScript內建物件的prototype屬性下的toString屬性,如果還是沒有,就會去找Object這個JavaScript內建物件的prototype屬性下的toString屬性;若此時物件不是陣列,就會直接去找Objectprototype屬性下的toString屬性。

順帶一提,number型別的值在尋找屬性的時候,會先去找Number這個JavaScript內建物件的prototype屬性,再去找Objectprototype屬性;string型別的值在尋找屬性的時候,會先去找String這個JavaScript內建物件的prototype屬性,再去找Objectprototype屬性。依此類推。

使用new關鍵字產生出來的函數物件實體,其上級物件即為該函數本身。如果我們要讓函數物件去繼承其它的函數物件或是類別的話,可以直接去設定函數的prototype屬性的__proto__屬性為其它函數物件或是類別的prototype屬性。

舉例來說:

function User(this: { name: string, type: string }, name: string) {
    this.name = name;
    this.type = "user";
}

User.prototype.whoAmI = function () {
    return `My name is ${this.name}. I am a ${this.type}.`;
};

function Human(this: { name: string, type: string }, name: string) {
    User.apply(this, [name]);
    this.type = "human";
}

Human.prototype.__proto__ = User.prototype;

function Robot(this: { name: string, type: string }, name: string) {
    User.apply(this, [name]);
    this.type = "robot";
}

Robot.prototype.__proto__ = User.prototype;

function Student(this: { name: string, type: string }, name: string) {
    Human.apply(this, [name]);
    this.type = "student";
}

Student.prototype.__proto__ = Human.prototype;

let o1 = new (User as any)("Unknown");
let o2 = new (Human as any)("David");
let o3 = new (Robot as any)("WALL-E");
let o4 = new (Student as any)("Steven");

console.log(o1.whoAmI());
console.log(o2.whoAmI());
console.log(o3.whoAmI());
console.log(o4.whoAmI());

以上程式,Student繼承HumanHuman繼承UserRobot繼承User。利用了函數提供的apply方法來指定要使用相同的this所儲存的物件參考來呼叫函數。

執行結果如下:

My name is Unknown. I am a user.
My name is David. I am a human.
My name is WALL-E. I am a robot.
My name is Steven. I am a student.

當然,以上程式如果使用類別物件來改寫的話,會變得易讀許多。改寫結果如下:

class User {
    name: string;

    type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }
}

class Human extends User {
    constructor(name: string) {
        super(name);
        this.type = "human";
    }
}

class Robot extends User {
    constructor(name: string) {
        super(name);
        this.type = "robot";
    }
}

class Student extends Human {
    constructor(name: string) {
        super(name);
        this.type = "student";
    }
}

let o1 = new User("Unknown");
let o2 = new Human("David");
let o3 = new Robot("WALL-E");
let o4 = new Student("Steven");

console.log(o1.whoAmI());
console.log(o2.whoAmI());
console.log(o3.whoAmI());
console.log(o4.whoAmI());

在類別名稱後面加上extends關鍵字,可以指定該類別要繼承的類別。在子類別的建構子中,必須要在第一個程式敘述使用super關鍵字來呼叫父類別的建構子。

如果我們把User類別的type物件欄位改成私有的,確保其不能被外部程式更改的話,會發現其子類別均無法成功存取type物件欄位而造成TypeScript編譯錯誤。像這種需要讓屬性能夠只公開到子類別的情況,我們要將屬性的修飾子改為protected,而不是private。如下:

class User {
    name: string;

    protected type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }
}

class Human extends User {
    constructor(name: string) {
        super(name);
        this.type = "human";
    }
}

class Robot extends User {
    constructor(name: string) {
        super(name);
        this.type = "robot";
    }
}

class Student extends Human {
    constructor(name: string) {
        super(name);
        this.type = "student";
    }
}

let o1 = new User("Unknown");
let o2 = new Human("David");
let o3 = new Robot("WALL-E");
let o4 = new Student("Steven");

console.log(o1.whoAmI());
console.log(o2.whoAmI());
console.log(o3.whoAmI());
console.log(o4.whoAmI());

替物件的屬性添加適合的存取修飾子,正是物件導向程式設計的封裝(Encapsulation)觀念。

判斷物件是否屬於指定的類別

既然物件有了繼承關係,我們就很可能會需要在程式執行階段去判斷物件所屬的類別。在JavaScript中,提供了instanceof關鍵字,可以用來判斷一個物件是否屬於指定的類別。

例如:

class User {
    name: string;

    protected type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }
}

class Human extends User {
    constructor(name: string) {
        super(name);
        this.type = "human";
    }
}

class Robot extends User {
    constructor(name: string) {
        super(name);
        this.type = "robot";
    }
}

class Student extends Human {
    constructor(name: string) {
        super(name);
        this.type = "student";
    }
}

const o1: User = new User("Unknown");
const o2: User = new Human("David");
const o3: User = new Robot("WALL-E");
const o4: User = new Student("Steven");

console.log(o1 instanceof User);
console.log(o2 instanceof User);
console.log(o3 instanceof User);
console.log(o4 instanceof User);
console.log(o4 instanceof Human);
console.log(o4 instanceof Robot);
console.log(o4 instanceof Student);

以上程式的執行結果如下:

true
true
true
true
true
false
true

以上程式的36行到39行,由於HumanRobotStudent類別都繼承User類別,因此它們的實體的型別也可以是User。這就是物件導向的多型(Polymorphism)觀念。

類別(靜態)屬性

除了將類別實體化出來後的物件能夠擁有屬性之外,我們也可以讓類別本身也擁有屬性。只要在撰寫類別程式的時候,在屬性名稱前加上static關鍵字,即可使其成為類別屬性。

class User {
    name: string;

    protected type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }

    static printInformation() {
        console.log("User");
    }
}

class Human extends User {
    constructor(name: string) {
        super(name);
        this.type = "human";
    }

    static printInformation() {
        console.log("Human");
    }
}

class Robot extends User {
    constructor(name: string) {
        super(name);
        this.type = "robot";
    }

    static printInformation() {
        console.log("Robot");
    }
}

class Student extends Human {
    constructor(name: string) {
        super(name);
        this.type = "student";
    }
}

User.printInformation();
Human.printInformation();
Robot.printInformation();
Student.printInformation();

以上程式的輸出結果如下:

User
Human
Robot
Human

類別屬性是可以被繼承的,但是我們無法透過類別實體化出來的物件來存取類別屬性。如果要替類別屬性加上存取修飾子,要添加在static關鍵字之前。

唯讀欄位

在撰寫類別程式的時候,在欄位名稱前加上readonly關鍵字,即可使其成為唯讀欄位。

class User {
    readonly name: string;

    protected type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }

    static printInformation() {
        console.log("User");
    }
}

class Human extends User {
    constructor(name: string) {
        super(name);
        this.type = "human";
    }

    static printInformation() {
        console.log("Human");
    }
}

class Robot extends User {
    constructor(name: string) {
        super(name);
        this.type = "robot";
    }

    static printInformation() {
        console.log("Robot");
    }
}

class Student extends Human {
    constructor(name: string) {
        super(name);
        this.type = "student";
    }
}

User.printInformation();
Human.printInformation();
Robot.printInformation();
Student.printInformation();

若我們想嘗試修改User或是其子類別的物件的name欄位,就會編譯錯誤。只有在類別自己的建構子中,才可以修改唯讀欄位。

如果有存取修飾子的存在,readonly關鍵字要加在存取修飾子之後。如果有static關鍵字的存在,readonly關鍵字要加在static關鍵字之後。

大括號物件也可以有唯讀欄位,只要在型別中的屬性名稱前加上readonly關鍵字就好。例如:

let o: { readonly name: string, readonly age: number, toString: () => string } = {
    name: "David",
    age: 18,
    toString: () => `${o.age}: ${o.name}`,
};

函數(方法)多載

類別中的函數,或者說物件的方法,當然也可以多載。例如:

class F {
    name: string;

    age: number;

    studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString(): string;

    toString(includeStudentNumber: boolean): string;

    toString(includeStudentNumber?: boolean) {
        if (includeStudentNumber) {
            if (typeof this.studentNumber === "string") {
                return `${this.name}: ${this.age}, ${this.studentNumber}`;
            }
        }
        return `${this.name}: ${this.age}`;
    }
}

呼叫父類別的方法

super關鍵字也可以用來呼叫父類別(被覆寫前的)的方法。例如:

class S {
    a() {
        console.log("a");
    }

    b() {
        console.log("b");
    }
}

class C extends S {
    a() {
        console.log("A");
    }

    b() {
        console.log("B");
    }

    ab() {
        this.a();
        super.b();
    }
}

new C().ab();

以上程式會輸出:

A
b

getter 和 setter

對於私有欄位,我們可以透過物件方法來存取它。例如:

class User {
    readonly name: string;

    protected type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }

    getType(): string {
        return this.type;
    }

    setType(t: string) {
        if (t.length > 0) {
            this.type = t;
        } else {
            this.type = "user";
        }
    }
}

let user = new User("David");

console.log(user.getType());

user.setType("human");

console.log(user.getType());

以上User物件的getType方法即為一個「getter」,setType方法即為一個「setter」。

事實上,若TypeScript的編譯目標為ES5或是以上的版本的話,我們可以在類別的方法前加上set或是get關鍵字,來讓該方法變成一個「getter」或一個「setter」,使該方法的屬性名稱變成「可以被安全存取的欄位」。例如:

class User {
    readonly name: string;

    protected _type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }

    get type(): string {
        return this._type;
    }

    set type(t: string) {
        if (t.length > 0) {
            this._type = t;
        } else {
            this._type = "user";
        }
    }
}

let user = new User("David");

console.log(user.type);

user.type = "human";

console.log(user.type);

被加上set或是get關鍵字的方法不可以設定存取修飾子,但是可以在其前面加上static關鍵字使其變成靜態屬性。

anyobject與物件型別的轉換

前面有提到過,在TypeScript中,任何物件的型別均屬於object型別。object型別即{}型別,它無法存取任何的屬性。

我們在先前的章節也有提到過,any可以用來表示任意型別,所以我們可以將任意物件轉成any型別。

例如:

class F {
    readonly name: string;

    readonly age: number;

    readonly studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o: any = new F("David", 18);

o.name = 18;
o.age = "David";
o.other = true;

以上程式,雖然o變數是F類別的物件,但它型別被明確定義為any,因此可以無限制地操作F物件的屬性。

事實上,以上這個F類別的物件型別,也可以用{readonly name: string, readonly age: number, readonly studentNumber?: string, toString: () => string }來表示。例如:

class F {
    readonly name: string;

    readonly age: number;

    readonly studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o: {readonly name: string, readonly age: number, readonly studentNumber?: string, toString: () => string} = new F("David", 18);

我們可以透過型別來「隱藏」物件的某些屬性。例如要隱藏studentNumber屬性:

class F {
    readonly name: string;

    readonly age: number;

    readonly studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o: {readonly name: string, readonly age: number, toString: () => string} = new F("David", 18);

我們甚至可以把型別中的readonly關鍵字直接拿掉,讓它的唯讀欄位變成可讀寫欄位。

class F {
    readonly name: string;

    readonly age: number;

    readonly studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o: {name: string, age: number, toString: () => string} = new F("David", 18);

o.name = "Steven";
o.age = 19;

令人震驚的是,我們其實可以把任意值的型別改為其它完全不相關的型別,只要利用any來做中介轉型即可。

class F {
    readonly name: string;

    readonly age: number;

    readonly studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o: { firstName: string, lastName: string } = new F("David", 18) as any;

o.firstName = "Steven";
o.lastName = "Nigel";

以上程式是可以通過編譯並且成功執行的。

由於TypeScript的型別並不是這麼的安全,因此在使用冒號:接上型別來明確定義變數或是參數的型別時,或是使用as關鍵字接上型別來進行轉型時,要特別注意原本值的型別。

能夠任意添加屬性的物件型別

any型別除了能夠讓我們自由存取物件的屬性之外,TypeScript還提供了另一種比較安全的方式。先看以下程式:

class F {
    readonly name: string;

    readonly age: number;

    readonly studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o: {name: string, age: number, toString: () => string} = new F("David", 18);

如果我們想要讓o物件能夠在保有類別F的屬性限制下,新增額外的任意屬性,可以在其型別上加上這樣的屬性[_: string]: any。如下:

class F {
    readonly name: string;

    readonly age: number;

    readonly studentNumber?: string;

    constructor(name: string, age: number, studentNumber?: string) {
        this.name = name;
        this.age = age;
        this.studentNumber = studentNumber;
    }

    toString() {
        return `${this.name}: ${this.age}`;
    }
}

let o: { name: string, age: number, toString: () => void, [_: string]: any } = new F("David", 18);

o.a = 1;
o.b = "2";
o.c = false;

[_: string]: any語法看起來很怪異,它的意思是:這個型別的所有物件屬性的鍵值都必須是string,且值的型別可為任意型別。屬性的鍵值可以使用數值或是字串,大部份的情況下會用字串,就算是用數值也是可以用字串來存取屬性。在型別的屬性中有了[_: string]: any後,我們就可以替這個型別的物件添加任意屬性,存放任意型別的值了!

再舉一個例子,如果要設計一個屬性值只能是數值的物件,程式如下:

let o: { [_: string]: number } = {};

o.a = 1;
o.b = 2;

利用is關鍵字來縮減型別範圍

還記得上一章中,我們寫的以下這段程式嗎?

function save(path: string, data_force?: string | boolean, force?: boolean): boolean {
    let data;

    const t_data_force = typeof data_force;

    if (t_data_force === "undefined") {
        data = "Hello, world!";
        force = false;
    } else if (t_data_force === "boolean") {
        data = "Hello, world!";
        force = data_force as boolean;
    } else { // string
        data = data_force;
    }

    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

為了讓我們的程式更安全,我應該要儘量避免轉型的發生。TypeScript提供了is關鍵字,可以用在函數的回傳值上,來幫助TypeScript的編譯器限縮值的型別範圍。

以上程式,由於data_force參數的型別為string | boolean | undefined,所以我們其實可以加入以下利用了is關鍵字實作的兩個函數,來讓編譯器能夠正確判斷data_force的型別。

function save(path: string, data_force?: string | boolean, force?: boolean): boolean {
    let data;

    const t_data_force = typeof data_force;

    function isUndefined(data_force?: string | boolean): data_force is undefined {
        return t_data_force === "undefined";
    }

    function isBoolean(data_force?: string | boolean): data_force is boolean {
        return t_data_force === "boolean";
    }

    if (isUndefined(data_force)) {
        data = "Hello, world!";
        force = false;
    } else if (isBoolean(data_force)) {
        data = "Hello, world!";
        force = data_force;
    } else { // string
        data = data_force;
    }

    const fs = require("node:fs");

    if (fs.existsSync(path)) {
        if (force) {
            fs.unlinkSync(path);
        } else {
            return false;
        }
    }

    fs.writeFileSync(path, data);

    return true;
}

題外話,如果值的型別全都被編譯器限縮到沒有的話,會發生什麼事呢?

例如:

function foo(x: string | number): boolean | void {
    const t_x = typeof x;

    function isString(data_force: string | number): data_force is string {
        return t_x === "string";
    }

    function isNumber(data_force: string | number): data_force is number {
        return t_x === "number";
    }

    if (isString(x)) {
        return true;
    } else if (isNumber(x)) {
        return false;
    }
}

當程式執行完第16行之後,x變數的型別是什麼?它的型別其實是never

function foo(x: string | number): boolean | void {
    const t_x = typeof x;

    function isString(data_force: string | number): data_force is string {
        return t_x === "string";
    }

    function isNumber(data_force: string | number): data_force is number {
        return t_x === "number";
    }

    if (isString(x)) {
        return true;
    } else if (isNumber(x)) {
        return false;
    }
    
    let xx: never = x;
}

在TypeScript中,用never來表示「沒有型別」。

判斷物件是否有指定的屬性

in關鍵字,除了可以用來製作for-in迴圈之外,還可以判斷一個物件是否有指定的屬性。例如:

class User {
    name: string;

    protected type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }
}

class Human extends User {
    constructor(name: string) {
        super(name);
        this.type = "human";
    }
}

const o1 = new User("Unknown");
const o2 = new Human("David");

console.log("name" in o1);
console.log("whoAmI" in o1);
console.log("name" in o2);
console.log("whoAmI" in o2);

console.log(o1.hasOwnProperty("name"));
console.log(o1.hasOwnProperty("whoAmI"));
console.log(o2.hasOwnProperty("name"));
console.log(o2.hasOwnProperty("namwhoAmIe"));

以上程式會輸出:

true
true
true
true
true
false
true
false

物件的hasOwnProperty方法可以判斷物件是否自己擁有(而非透過prototype)指定的屬性。其實在使用for-in迴圈來走訪物件的屬性時,通常還需要使用hasOwnProperty方法來過濾掉非自身的屬性。

例如:

class User {
    name: string;

    protected type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }
}

class Human extends User {
    constructor(name: string) {
        super(name);
        this.type = "human";
    }
}

const o = new Human("David");

for (const key in o) {
    console.log(key);
}

以上程式會輸出:

type
name
constructor
whoAmI

而我們通常在使用for-in迴圈時,只是要得到物件本身擁有的屬性,所以要把程式寫成:

class User {
    name: string;

    protected type = "user";

    constructor(name: string) {
        this.name = name;
    }

    whoAmI() {
        return `My name is ${this.name}. I am a ${this.type}.`;
    }
}

class Human extends User {
    constructor(name: string) {
        super(name);
        this.type = "human";
    }
}

const o = new Human("David");

for (const key in o) {
    if (o.hasOwnProperty(key)) {
        console.log(key);
    }
}

以上程式會輸出:

type
name

您可能會覺得很奇怪,既然物件有hasOwnProperty方法,為什麼hasOwnProperty在使用for-in迴圈的時候不會被走訪呢?這是因為JavaScript的屬性其實是有一些「選項」可以設定的,能夠被for-in迴圈走訪到的屬性,它的enumerable選項必須要被設為true(預設值正是true)。然而hasOwnProperty屬性的enumerable選項是設為false,所以它不會被for-in迴圈走訪。

JavaScript內建的Object物件,提供了definePropertydefineProperties方法,可以用比較低階的方式來設定任意型別的值的屬性。由於這部份並不是本系列文章的範圍,在此就點到為止。

總結

在這個章節中,我們學到了大括號物件、函數物件和類別物件,這三種JavaScript物件的建立方式,稍微掌握了物件導向程式設計的方式,也了解了TypeScript可怕的轉型機制,然後還學會了instanceofisin關鍵字的用法。在下一個章節中,要來學習TypeScript提供的介面用法。

下一章:TypeScript程式語言的介面和抽象類別