iT邦幫忙

2024 iThome 鐵人賽

DAY 18
0
Software Development

一個好的系統之好維護基本篇 ( 馬克版 )系列 第 18

Day-18: Typescript 編譯器守護者

  • 分享至 

  • xImage
  •  

同步至 medium

https://ithelp.ithome.com.tw/upload/images/20241002/20089358MeMn2mVtwb.png

會開這篇的主要原因是因為在工作久了我真的發現,Javascript 真的很容易寫出難維護的程式碼,在而且討探維護性時這一塊時,發現型別的安全性這一塊我真的發現非常重要,很多的錯誤、誤用、維護都可以用這個提早發現,並且也可以很清楚的知道這個情境支援那些型別,所以才開了這一篇文章。

Typescript 型別處理的三種的基本概念

在開始說型別安全時,有幾個 ts 處理型別的基本概念要先簡單理解一下。

1. 型別推論 ( Type Inference ) : 就 ts 自動推論型別

例如下面的範例,ts 就會自動推論出它是 number

const age = 30; // TypeScript 推論出 age 是 number 型別
console.log(typeof age) 
// output: "number" 

2. 型別註記 ( Type Annotation ) : 就是開發者自已主動標註

如下,我們自動給他 number 的型別。

let age: number = 30;

3. 型別斷言 ( Type Assertion ) : 就 ts 直接斷言目標為什麼型別

Object as <類型>

or

<類型> Object  ---> 但這個好像在 JSX 會有問題

然後

interface Person {
    name: string;
    age: number;
}

const mark = {
    name: 'mark',
    age: 25,
} as Person;

型別安全的好習慣

1. 不要濫用型別斷言 ( as XXX )

型別推論與型別註記在型別安全性上,只要不要亂來,例如每個東西都下 any 的話,是沒有什麼太大的問題,但是 type assertion 就會有可能產生問題。

型別斷言它允許開發者覆蓋編譯器的類型檢查功能,它本身功能很強,但是也導致濫用變成 anit-patterns,例如下面的範例 mark 與 william 事實上都沒有 occupation,但是都還是沒有錯誤。

interface Person {
    name: string;
    age: number;
    occupation: string;
}

const mark = {
    name: 'mark',
    age: 25,
} as Person;

const william = <Person>{
    name: 'William',
    age: 40,
};

這裡的解決方案是避免不必要的斷言,並利用 TypeScript 的型別系統來自動推斷型別。此外,可以在 tsconfig.json 中設置以下規則來限制型別斷言的濫用 :

"rules": {
    "no-unnecessary-type-assertion": true,
    "no-object-literal-type-assertion": true
}

然後下面為這些 rule 的一些解釋。

// no-unnecessary-type-assertion

// Bad: 不必要的型別斷言,因為 TypeScript 已經知道 `message` 是 string 型別
const message: string = 'Hello';
const assertedMessage = message as string;

// Good: 型別斷言被移除,TypeScript 自動推斷出 `message` 是 string
const message: string = 'Hello';
const inferredMessage = message;
// no-object-literal-type-assertion

interface Person {
    name: string;
    age: number;
}

// Bad: 物件用了型別斷言,忽略了缺少的 age 屬性
const person = {
    name: 'John'
} as Person;  // TypeScript 不會檢查 age

// Good: 直接定義物件時,TypeScript 會報錯,因為沒有 age 屬性
const person: Person = {
    name: 'John',
    age: 30
};

2. 不要使用 any,真的不知道就用 unknown

any 會完全繞過 ts 的型別檢查,白話文就是變成 js,如果某個型別真的不確定,那就先用 unknown,因為它會強迫進行型別檢查。

// Bad
let value: any = getValue()


// Good
let value: unknown = getValue();
if (typeof value === 'string') {
    console.log(value.toUpperCase());
}

3. 忽略 null 和 undefined 檢查,並且謹慎使用 !

記憶中如果沒有設置 strict 的情況下,strictNullChecks 是關起來的,也就是說以下的情況 ts 是不會丟錯的這也代表程式碼在執行到 toUpperCase() 就有可能會出錯。

function greet(name: string | null) {
    console.log(`Hello, ${name.toUpperCase()}`);
}

'name' is possibly 'null'.(18047)
(parameter) name: string | null

這裡是建議strict就開啟來吧,如果不能就至少開個strictNullChecks,這樣至少它會提醒叫我們檢查。

但是在開啟來後,不要因為懶的寫檢查,然後就直接加個!,它就是所謂的型別斷言,強制的和 ts 說這東東絕對不會是 null 或 undefined,但事實上如果不會是這兩個,那好像也不需要寫 string | null。

function greet(name: string | null) {
    console.log(`Hello, ${name!.toUpperCase()}`);
}

greet(null)
// [ERR]: Cannot read properties of null (reading 'toUpperCase') 

4. 使用聯合類型,要注意不要使用 Type Assertion,而是用 Type Guard

聯合型別 (Union Types) 就是允許變數是多種型別中的其中一種,如下,然後很多情況時,可能就是兩者提供不同方法,然後 ts 會和你說它無法確定是 Cat 有沒有這個方法,這個是好事。

type Dog = { bark: () => void };
type Cat = { meow: () => void };
type Pet = Dog | Cat;

function makeNoise(pet: Pet) {
  // 錯誤:pet 可能是 Dog 也可能是 Cat,無法確定有 bark 方法
   pet.bark(); // Error: Property 'bark' does not exist on type 'Cat'.
}

const myPet: Pet = { meow: () => console.log("Meow!") };
makeNoise(myPet); 

然後比較大的問題是,人們碰到這個問題會如何解決,其中會出問題的就是用 type assertion 如下 :

type Dog = { bark: () => void };
type Cat = { meow: () => void };
type Pet = Dog | Cat;

function makeNoise(pet: Pet) {
    // 使用 Type Guard 檢查是否是 Dog
    if ('bark' in pet) {
        pet.bark();
    } else {
        pet.meow();
    }
}


const myPet: Pet = { meow: () => console.log("Meow!") };
makeNoise(myPet);  // 正常執行

~備註~
type guard是 ts 中很多用來保護類型的方式,其中上面用in就是其中一種方式,像是還有 :

  • typeof
  • instanceof
  • is

詳細更多的內容 ts 官網有更多的說明。

typescriptlang-narrowing

5. 小心因 Structural Typing 的誤判

例如下面範例,我明明 employee 變數是使用 type annotation 說明一定要 Employee 類型,但是我送一個型別為 Persion 的結果 ts 沒有出錯。

主要的原因在於 ts 為結構化型別語言,也就是說它檢查是基於結構,而不是類型名稱,所以也就是說長的一樣就會給你過,這也是為什麼下面的範例 ts 沒有出錯的原因。

type Person = {
    name: string;
    age: number;
}

type Employee = {
    name: string;
    age: number;
}

const mark: Person = {
    name: 'mark',
    age: 19
}

const employee: Employee = mark; <------------ 這裡不會出錯
class Person{
    name: string;
    age: number;
    constructor(name: string, age: number){
        this.age = age;
        this.name = name;
    }
}

class Employee{
    name: string;
    age: number;
    constructor(name: string, age: number){
        this.name = name;
        this.age = age;
    }
}

const mark:Person = new Person('mark', 18)
const empoyee:Employee = mark; <------------------ 這裡還是不會錯

那這有什麼解法嗎 ?

有一個最 workaround 的解法適用於 class,那就是將一個變數改成 private 如下:

class Person{
    name: string;
    age: number;
    constructor(name: string, age: number){
        this.age = age;
        this.name = name;
    }
}

class Employee{
    name: string;
    private age: number;
    constructor(name: string, age: number){
        this.name = name;
        this.age = age;
    }
}

const mark:Person = new Person('mark', 18)
const empoyee:Employee = mark; // <------------------ 這裡就出錯囉 ~
// Type 'Person' is not assignable to type 'Employee'.
//  Property 'age' is private in type 'Employee' but not in type 'Person'.(2322)
// const empoyee: Employee

然後還有一個解法就是使用 brand 來取分具有相同結構的不同類型,如下範例,簡單的說就是多了一個隱藏的 brand 欄位 ~

type Brand<K, T> = K & { __brand?: T };

class Person{
    name: string;
    age: number;
    constructor(name: string, age: number){
        this.age = age;
        this.name = name;
    }
}

class Employee{
    name: string;
    age: number;
    constructor(name: string, age: number){
        this.name = name;
        this.age = age;
    }
}
type BrandedPerson = Brand<Person, 'Person'>;
type BrandEmployee = Brand<Employee, 'Employee'>

const mark:BrandedPerson = new Person('mark', 18) 
const empoyee:BrandEmployee = mark; // <------------- 這裡就會發現錯誤了

6. 熟悉 Utility Types

我們在開發時後,很多時後是所謂的一個 type 打天下,但比較準確的說,我們會因為這個 type 而導致我們常常使用一些偷吃步之類的,例如asany,但事實上我們可以用 Utility Types 來將原本的類型,再打包一層使用,可以讓我們比較不會走偷吃步。

https://www.typescriptlang.org/docs/handbook/utility-types.html

然後這裡列一下我自已覺得比較常看到的:

  • Partial: 它會產生一個新 type,並且是根據 base type 欄位,然後全部都改成 optional,這個使用要注意,因為很多情況下實際上使用還是需要那 1、2 個欄位為 required,這時用它就有可能讓裡面炸掉。
  • Required: 上面那個相反。
  • Record<Keys, Type>: 就是 map 很常用。
  • Pick<Type, Keys>: 就是產生一個新的 type,然後根據 base type 你選的欄位,順到說一下它不會改變你選的欄位的 required 或 optional。
  • Omit<Type, Keys>: pick 的相反,全部欄位,然後再移除不要的。

以上就是我自已比較常看與用的,然後順到說一下它們都還可以混這用,例如下面這樣,所以只要這個學的好,你事實上走偷吃步的情況就會減少了。

type OptionalUser = Partial<Pick<User, 'id' | 'email'>>;

小結

這篇文章中我們大概理解了 typescript 編譯器在我們的維護性上,發揮多大的功用,基本上只要專案有用 ts 並且在撰寫時不要以 javascript 的思維在開發東西,那基本上整個維護性就會拉高非常多,最直覺的感受就是我總於知道吐出來的東西是什麼了,尤其是當你面對到一個方法,後面深入十個方法,然後東西是從最裡的方法吐出來,然後每一層都會加個欄位,減個欄位,這個真的很靠北 ~ 前端問我說這個回傳結果是什麼,我都說我要來通靈了……

不過說來也要反省一下,一直以來我都不太專注在語言上,但是在尋找好維護這一塊的東西我發現熟悉這個語言事實上真的蠻重要的,不然像很多時後我也就直接來個 as 來解決一切,但事實上的確會留給後人的坑 ~ 真的我該反省一下,要花點時間在語言上 ~


上一篇
Day-17: DI 的設計模式與臭臭的味道
下一篇
Day-19: Domain-Driven Design 提升團隊合作與軟體維護性的關鍵 ( 概略 )
系列文
一個好的系統之好維護基本篇 ( 馬克版 )30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言