Jitrak Blog

ts-pattern

ts-pattern เครื่องมือที่จะมาช่วยให้ทำ Pattern matching ใน TypeScript

14 Jul 2023 18:00

Written by: Yosapol Jitrak

Tags:

Condition

Pattern matching

บทความที่แล้ว ผมได้กล่าวถึง Switch true และได้เกริ่นไว้หน่อยแล้วว่า จะมี Tool ที่จะมาช่วยทำให้ Code มันสะอาดขึ้น สิ่งนั้นคือ ts-pattern

ts-pattern

มันทำอะไรได้บ้าง ก่อนอื่นเลย เขาเขียนจั่วหัวไว้ดังนี้ “The exhaustive Pattern Matching library for TypeScript with smart type inference.” ดูเท่ และน่าสนใจมากเลย

เรื่องติดตั้งก็ไม่มีอะไรมากครับ ลงทีเดียวจบ ไม่ต้อง Set อะไรเพิ่มแล้ว

via npm

npm install ts-pattern

via yarn

yarn add ts-pattern

via pnpm

pnpm add ts-pattern

ผมลองยกตัวอย่างโจทย์เรื่องชนะทางแพ้ทางของเกม จากบทความที่แล้วนะครับ

graph LR
    water --> |2| fire
    fire --> |2| grass
    grass --> |2| earth
    earth --> |2| electric
    electric --> |2| water
    dark --> |1| light
    dark --> |1.2| water
    dark --> |1.2| fire
    dark --> |1.2| grass
    dark --> |1.2| earth
    dark --> |1.2| electric
    light --> |3| dark
    light --> |0.9| water
    light --> |0.9| fire
    light --> |0.9| grass
    light --> |0.9| earth
    light --> |0.9| electric

ยกตัวอย่างข้อมูลแถวแรก คือ น้ำชนะไฟ Damage จะคูณ 2

ถ้าเขียนใน Version ที่ใช้ ts-pattern จะเป็นดังนี้ครับ

import { match, P } from 'ts-pattern';

export const allElements = ['normal', 'water', 'fire', 'grass', 'earth', 'electric', 'light', 'dark'] as const;

export type ElementType = (typeof allElements)[number];

export type CalculateMultiplierInput = [ElementType, ElementType];

// water -> fire -> grass -> earth -> electric -> water | 2
// dark -> every | 1.2
// light -> dark | 3
// light -> not dark | 0.9
export const calculateDamageMultiplier = (input: CalculateMultiplierInput) =>
  match(input)
    .with(['water', 'fire'], () => 2)
    .with(['fire', 'grass'], () => 2)
    .with(['grass', 'earth'], () => 2)
    .with(['earth', 'electric'], () => 2)
    .with(['electric', 'water'], () => 2)
    .with(['dark', P.string], () => 1.2)
    .with(['light', 'dark'], () => 3)
    .with(['light', P.string], () => 0.9)
    .otherwise(() => 1);

Code ดูเป็นมิตรกับเรามากขึ้นกว่า Version switch true มากขึ้นเยอะเลยครับ
ท่านี้คือการใช้ Tuple pattern ครับ โดย P.string จะหมายถึง Wildcards อะไรก็ได้ที่เป็น string
P ย่อมาจาก Pattern นะครับ สามารถใช้แทนกันได้

with จะมีโครงสร้างดังนี้ครับ .with(pattern, handler)

ลองมาดูตัวอย่างอื่นกันครับ

import { match, P } from 'ts-pattern';

type Input = [number, '+', number] | [number, '-', number] | [number, '*', number] | ['-', number];

const input: Input = [3, '*', 4];

const output = match(input)
  .with([P.number, '+', P.number], ([x, , y]) => x + y)
  .with([P.number, '-', P.number], ([x, , y]) => x - y)
  .with([P.number, '*', P.number], ([x, , y]) => x * y)
  .with(['-', P.number], ([, x]) => -x)
  .otherwise(() => NaN);

Credit: https://github.com/gvergnaud/ts-pattern
เคสนี้คือใช้ P.numbers หมายถึง Wildcards ที่เป็น number อะไรก็ได้ครับ

คราวนี้ลองยกตัวอย่างเป็นโปรแกรมตัดเกรดนักศึกษา Version ts-pattern กันครับ

import { match } from 'ts-pattern';
enum Grade {
  A = 'A',
  B = 'B',
  C = 'C',
  D = 'D',
  F = 'F',
}

interface Student {
  id: number;
  name: string;
  score: number;
  grade?: string;
}

const students: Student[] = [
  { id: 1, name: 'Alice', score: 85 },
  { id: 2, name: 'Bob', score: 72 },
  { id: 3, name: 'Charlie', score: 56 },
  { id: 4, name: 'David', score: 65 },
  { id: 5, name: 'Eve', score: 49 },
];

students.forEach((student) => {
  student.grade = match(student.score)
    .when(
      (score) => score >= 80,
      () => Grade.A,
    )
    .when(
      (score) => score >= 70,
      () => Grade.B,
    )
    .when(
      (score) => score >= 60,
      () => Grade.C,
    )
    .when(
      (score) => score >= 50,
      () => Grade.D,
    )
    .otherwise(() => Grade.F);
  console.table(students);
});

โจทย์นี้เราจะใช้ .when แทน if-else หรือ switch case นะครับ

โดย when จะมีโครงสร้างดังนี้ครับ .when(predicate, handler)
บางคนอาจจะไม่รู้จัก Predicate นะครับ Predicate คือ Function ที่คืนค่าเป็น Boolean ครับ

ต่อไปจะกล่าวถึง Feature ที่ผมชอบที่สุดของ ts-pattern นะครับ
แต่ก่อนจะไปตรงนั้น เรามาดูปัญหาก่อน สมมติว่าเราเขียน switch case ปกติ โดยใช้ Enum type นะครับ

enum ProxyType {
  AccountNumber = 'ACCNO',
  MSISDN = 'MSISDN',
  NationId = 'NATID',
}

Object.values(ProxyType).forEach((proxyType) => {
  let message: string;
  switch (proxyType) {
    case ProxyType.AccountNumber:
      message = 'AccountNumber';
      break;
    case ProxyType.MSISDN:
      message = 'MSISDN';
      break;
    case ProxyType.NationId:
      message = 'NationId';
      break;
  }

  console.log(message);
});

ถ้าวันดีคืนดี เกิดมี Type ประเภทใหม่ขึ้นมา แล้วมี Switch case เหล่านี้เต็มโปรเจคไปหมด เรามีโอกาสผิดพลาดได้ครับ เพราะ Code switch case เหล่านี้ ก็ Complie ผ่าน อาจจะไปเกิด Bug บน Production ได้ โดยมีคนเขียนบทความถึงเรื่องนี้อยู่ครับ คิดซักนิดก่อนใช้ switch

Feature ของ ts-pattern ที่ว่าคือ exhaustive จะช่วยเตือนเรา มีแจ้งเตือนเราตั้งแต่ใน IDE ครับ และ Compile ไม่ผ่านตั้งแต่แรก

exhaustive

อันนี้ผมลืมบอกไป ตอนสุดท้ายหลังจากใส่ Pattern แล้ว เราจะต้องสั่ง Run มัน โดยจะมี Run อยู่ 3 ประเภท คือ

  1. exhaustive คือเช็คว่าเราเขียนครบทุก Pattern ที่มีโอกาสเกิดขึ้นได้ ถ้าเราเขียนไม่ครบ จะ Compile ไม่ผ่าน
  2. otherwise คือใส่ Default ไว้ ถ้าไม่ตรงกับ Pattern ไหนเลย
  3. run คือ ไม่เช็คว่าเราเขียนครบทุก Pattern ที่มีโอกาสเกิดขึ้นได้ ซึ่งทำให้มีโอกาสไปตายตอน Run time

โดยส่วนตัวก็แนะนำให้ใช้แค่ exhaustive กับ otherwise ส่วนของ run เลี่ยงได้เลี่ยงครับ

จริง ๆ มี Feature และ Use case ให้ใช้ได้เยอะกว่านี้มากครับ สำหรับ ts-pattern ลองเข้าไปดูเองได้ที่ Document เขากันครับ ts-pattern
จบไปแล้วนะครับสำหรับบทความนี้ เจอกันใหม่บทความหน้าครับ