Published on

Union Type Narrowing

Authors
  • Name
    Twitter

inis 實作 TypeScript 聯合型別縮窄

在 TypeScript 裡,"role" in person 這段語法其實是利用 JavaScript 的 in 運算子來做「屬性存在性檢查」,同時被 TypeScript 當作一種 type guard(型別守衛),用來把聯合型別(union)縮窄(narrow)到其中包含 role 屬性的那個分支。

// 範例
type Person = {
  name: string;
  role?: string;
}

function checkRole(person: Person) {
  if ("role" in person) {
    // TypeScript 知道 person 一定有 role 屬性
    console.log(person.role);
  }
}

回傳值可以用 person is Admin 或 person is User 來表示,這樣 TypeScript 就知道回傳值是 Admin 或 User 型別。

// 範例
export function isAdmin(person: Person): person is Admin {
    return person.type === 'admin';
}

export function isUser(person: Person): person is User {
    return person.type === 'user';
}

filterUsers 函式解析:如何用 Partial<Omit<User, 'type'>> 動態篩選 User 陣列

  • Omit 是移除屬性
  • Partial 是把所有屬性變成可選
export function filterUsers(
  persons: Person[],
  criteria: Partial<Omit<User, 'type'>>
): User[] {
  return persons
    .filter(isUser)                  // ① 先篩選出所有 User
    .filter((user) => {              // ② 再對每個 user 進一步比對 criteria
      // Object.keys 回傳 string[],這裡做型別斷言
      const criteriaKeys = Object.keys(criteria)
        as (keyof Omit<User, 'type'>)[];
      
      // ③ 只要 criteria 裡每個欄位,都在 user 上「完全相符」
      return criteriaKeys.every((fieldName) => {
        return user[fieldName] === criteria[fieldName];
      });
    });
}

步驟說明

① persons.filter(isUser):利用事先寫好的 type-guard isUser(檢查 type==='user'),只保留 User。

② criteria: Partial<Omit<User,'type'>>:傳入想篩選的欄位(name、age、occupation 任一或多個),且全部可選。

③ Object.keys(criteria):拿到使用者實際傳入的欄位陣列,透過 every 逐一檢查 user[fieldName] === criteria[fieldName],都相符才算符合篩選。

練習題

interface User {
    type: 'user';
    name: string;
    age: number;
    occupation: string;
}

interface Admin {
    type: 'admin';
    name: string;
    age: number;
    role: string;
}

export type Person = User | Admin;

export const persons: Person[] = [
    { type: 'user', name: 'Max Mustermann', age: 25, occupation: 'Chimney sweep' },
    { type: 'admin', name: 'Jane Doe', age: 32, role: 'Administrator' },
    { type: 'user', name: 'Kate Müller', age: 23, occupation: 'Astronaut' },
    { type: 'admin', name: 'Bruce Willis', age: 64, role: 'World saver' },
    { type: 'user', name: 'Wilson', age: 23, occupation: 'Ball' },
    { type: 'admin', name: 'Agent Smith', age: 23, role: 'Anti-virus engineer' }
];

export function logPerson(person: Person) {
    console.log(
        ` - ${person.name}, ${person.age}, ${person.type === 'admin' ? person.role : person.occupation}`
    );
}

export function filterPersons<
  T extends Person['type']
>(
  persons: Person[],
  personType: T,
  criteria: Partial< Omit< Extract<Person, { type: T }>, 'type' >>
): Array< Extract<Person, { type: T }> > {
  return persons
    // 先用 type guard 確保輸入陣列符合 T
    .filter((p): p is Extract<Person, { type: T }> => p.type === personType)
    // 再根據 criteria 裡的 key/value 做過濾
    .filter((person) => {
      const keys = Object.keys(criteria) as Array< keyof Omit<Extract<Person, { type: T }>, 'type'> >
      return keys.every((key) =>
        person[key] === criteria[key]
      )
    })
}


export const usersOfAge23 = filterPersons(persons, 'user', { age: 23 });
export const adminsOfAge23 = filterPersons(persons, 'admin', { age: 23 });

console.log('Users of age 23:');
usersOfAge23.forEach(logPerson);

console.log();

console.log('Admins of age 23:');
adminsOfAge23.forEach(logPerson);

這樣寫也可把紅字修掉,但練習網站上的測試沒過

export function filterPersons(
  persons: Person[],
  personType: Person['type'],
  criteria: Partial<Omit<Person, 'type'>>
): Person[] {
  return persons
    .filter((p): p is Extract<Person, { type: typeof personType }> => p.type === personType)
    .filter((person) => {
      const keys = Object.keys(criteria) as Array<keyof typeof person>;
      return keys.every((key) => person[key] === (criteria as any)[key]);
    });
}