โปรเจกต์ TypeScript เติบโตขึ้น ไฟล์เพิ่มจำนวนขึ้น ความสัมพันธ์ของ dependencies เริ่มพันกันยุ่งเหยิง และในที่สุด การ build ของคุณก็ชนกำแพง ซึ่งไม่ใช่เพราะความซับซ้อนของ logic แต่เป็นเพราะ compiler จำเป็นต้องอ่านข้อมูลทั้งจักรวาลก่อนที่จะสามารถเขียนไฟล์ declaration เพียงไฟล์เดียวได้
TypeScript 6.0 แก้ปัญหานี้ด้วย isolatedDeclarations ฟีเจอร์นี้เป็นการคิดใหม่ว่าไฟล์ .d.ts ควรจะถูกสร้างขึ้นมาอย่างไร แทนที่จะผูกการสร้าง declaration เข้ากับกระบวนการ type-checking แบบเต็มรูปแบบ มันช่วยให้ compiler สามารถสร้างไฟล์เหล่านั้นได้โดยการดูแต่ละ source file แยกกัน (in isolation) ผลลัพธ์ที่ได้คือกระบวนการ build ที่สามารถทำงานแบบขนาน (parallel) ไปกับไฟล์นับพันไฟล์ แทนที่จะต้องค่อยๆ ไล่ไปตาม dependency graph ทีละจุด
คอขวดที่แท้จริง
ในปัจจุบัน การสร้างไฟล์ declaration เป็นการทำงานแบบลำดับ (serial operation) เมื่อคุณเปิดใช้งาน --declaration และรัน compiler ตัว TypeScript จะไม่สามารถสร้างไฟล์ .d.ts สำหรับโมดูลใดๆ ได้ จนกว่ามันจะเข้าใจทุก type ที่โมดูลนั้นเกี่ยวข้องอย่างครบถ้วน หาก utils.ts มีการ import type จาก types.ts และ types.ts ดึงข้อมูลบางอย่างมาจาก api.ts ตัว compiler จะต้องไล่แก้ปัญหาความสัมพันธ์นั้นให้เสร็จสิ้นก่อน จึงจะสามารถอธิบายได้ว่า utils.ts ส่งออก (export) อะไรบ้าง
ใน monorepo ขนาดใหญ่ ผลกระทบแบบโดมิโนนี้รุนแรงมาก ไฟล์เพียงไฟล์เดียวที่อยู่ใกล้กับราก (root) ของ import graph สามารถขัดขวางการสร้าง declaration ของไฟล์อื่นๆ ที่ตามมา (downstream) ได้เป็นร้อยไฟล์ CPU ของคุณอาจมี 8 คอร์ แต่ 7 คอร์กลับนั่งว่างงาน ในขณะที่ TypeScript ต้องใช้ความพยายามอย่างหนักในการสร้างโครงสร้างของทุก interface ข้ามขอบเขตของ package แม้ว่า compiler จะกำลังทำงานที่จำเป็นอยู่ แต่การผูกติดกันระหว่างการตรวจสอบ type และการสร้าง declaration หมายความว่าคุณต้องจ่ายต้นทุนเต็มๆ ในการวิเคราะห์ข้ามไฟล์ แม้ว่าคุณจะต้องการเพียงแค่เขียน public surface types ลงในดิสก์เท่านั้นก็ตาม
isolatedDeclarations เปลี่ยนกฎเกณฑ์อย่างไร
isolatedDeclarations ทำลายการผูกติดนั้น เมื่อเปิดใช้งาน flag นี้ compiler จะตกลงที่จะสร้างไฟล์ .d.ts สำหรับ source file โดยไม่ต้องไปถามไฟล์อื่นว่าอะไรหมายถึงอะไร มันทำได้โดยการกำหนดข้อตกลง (contract) ง่ายๆ คือ: ทุก symbol ที่ถูก export ออกไปจะต้องมีการระบุ type annotation ที่ชัดเจนและมองเห็นได้ ณ จุดที่มันถูกประกาศ (declared)
หาก compiler สามารถเห็น type แบบเต็มที่เขียนไว้ตรงนั้นใน source เลย มันก็ไม่จำเป็นต้องทำ type inference อีกต่อไป ไม่ต้องไล่ตามการ import และไม่จำเป็นต้องรู้ว่า identifier User ในไฟล์อื่นเป็น interface, type alias หรือ class มันเพียงแค่สร้าง (emit) สิ่งที่คุณเขียนลงไปตรงๆ เท่านั้น
นั่นหมายความว่าไฟล์ A และไฟล์ B สามารถสร้าง declaration ของตัวเองได้พร้อมกัน build orchestrator สามารถส่งแต่ละไฟล์ไปให้ thread แยกกันได้ นอกจากนี้ fast transpilers ที่ก่อนหน้านี้ข้ามการสร้าง .d.ts ไปเพราะไม่มี full type checker ก็จะสามารถสร้างไฟล์ declaration ได้เช่นกัน เพราะงานนี้กลายเป็นการทำงานเชิงไวยากรณ์ (syntactic) ล้วนๆ
สิ่งที่ต้องแลก: ต้องเขียนระบุลงไป
ความเร็วไม่ได้มาฟรีๆ คุณต้องเลิกพึ่งพา type inference สำหรับสิ่งใดก็ตามที่คุณ export ออกไป ทุกๆ public function, class, variable และ constant จำเป็นต้องระบุ type ไว้อย่างชัดเจน หาก TypeScript ต้องคำนวณ type โดยการดูจาก return statement หรือการแก้ปัญหา generic argument ตัว isolatedDeclarations จะแจ้ง error
นี่คือตัวอย่างในการใช้งานจริง หากไม่มี flag นี้ คุณอาจเขียนแบบนี้:
export function fetchUser(id: number) {
return fetch(`/users/${id}`).then(r => r.json());
}
TypeScript จะทำ type inference สำหรับ return type โดยการตรวจสอบ fetch, จากนั้น Promise.prototype.then, และตามด้วย anonymous function ที่คืนค่า r.json() ซึ่งในการจะสร้างไฟล์ .d.ts นั้น compiler จำเป็นต้องทำการวิเคราะห์ทั้งหมดนี้
เมื่อเปิดใช้งาน isolatedDeclarations คุณต้องระบุ annotation ให้กับการ export:
interface User {
id: number;
email: string;
}
export function fetchUser(id: number): Promise<User> {
return fetch(`/users/${id}`).then(r => r.json());
}
คราวนี้ compiler จะเห็น Promise<User> ได้ทันที มันจะสร้าง declaration และทำงานต่อไป
กฎนี้ใช้ครอบคลุมในวงกว้าง Array ที่ถูก export จำเป็นต้องมี type ที่ชัดเจนแทนที่จะให้มันถูก infer จาก element ภายใน Object ที่ถูก export จำเป็นต้องมีการระบุ type annotation ที่ชัดเจนหากโครงสร้าง (shape) ของมันมีความสำคัญต่อผู้ใช้งาน (consumers) ส่วน Generic function จำเป็นต้องมี return type และ constraints ที่มองเห็นได้ ณ จุดที่ประกาศ คุณไม่สามารถ export ผลลัพธ์ของ complex mapped type ได้โดยไม่กำหนดให้มันเป็น named type alias ที่เขียนระบุไว้อย่างครบถ้วน
ข้อดีก็คือ public API ของคุณจะกลายเป็นสิ่งที่อธิบายตัวเองได้ (self-documenting) ผู้ใช้งาน—รวมถึง compiler—ไม่จำเป็นต้องทำ reverse-engineer เพื่อเดาเจตนาของคุณจากรายละเอียดการ implementation อีกต่อไป เพราะ type เหล่านี้คือข้อตกลง (contract) ที่ถูกกำหนดไว้อย่างตั้งใจ
เวลาที่ประหยัดได้
ใน codebase ขนาดใหญ่ ผลกระทบจะเกิดขึ้นทันที เวลาในการ build ที่เคยลากยาวเป็นนาทีสามารถลดลงเหลือเพียงไม่กี่วินาที เพราะการสร้าง declaration จะไม่เป็นตัวถ่วงหลักอีกต่อไป แต่ละไฟล์จะถูกสร้างขึ้นอย่างเป็นอิสระ ดังนั้นกระบวนการนี้จึงขยายตัว (scale) ตามจำนวนคอร์ที่คุณมี ไม่ใช่ตามความลึกของ import graph
สิ่งนี้ยังเปลี่ยนเครื่องมือที่คุณสามารถเลือกใช้ได้ด้วย Transpiler อย่าง esbuild และ swc นั้นมีความเร็วสูงมากในการแปลง TypeScript เป็น JavaScript อยู่แล้ว แต่หลายทีมยังคงต้องรัน tsc แยกต่างหากเพียงเพื่อสร้างไฟล์ .d.ts ด้วย isolatedDeclarations เครื่องมือที่รวดเร็วเหล่านั้นสามารถจัดการได้ทั้งสองงาน พวกมันไม่จำเป็นต้องจำลองระบบ Type ทั้งหมดของ TypeScript เพื่อสร้าง declaration; พวกมันเพียงแค่ต้อง parse syntax และคัดลอก explicit types ที่คุณระบุไว้เท่านั้น สิ่งนี้ทำให้การทำ end-to-end TypeScript builds ด้วย toolchain ทางเลือกอื่นมีความเป็นไปได้มากขึ้นอย่างมาก
การทำ distributed และ incremental builds ก็ง่ายขึ้นด้วยเช่นกัน ในระบบ continuous integration, remote cache หรือ sharded build สามารถ emit declarations สำหรับ package หนึ่งได้โดยไม่ต้องดาวน์โหลด transitive dependency graph ทั้งหมดก่อน หาก types ใน source มีความชัดเจน (explicit) ตัว build shard ก็จะมีทุกอย่างที่จำเป็นต้องใช้
สิ่งที่ยังคงเหมือนเดิม
ข้อจำกัดนี้ใช้กับเฉพาะส่วนที่เป็น exports เท่านั้น ภายใน module หนึ่งๆ ทุกอย่างยังคงดำเนินไปตามปกติ Local variables, private class members และ unexported helper functions ยังคงสามารถพึ่งพาการทำ full type inference ได้ TypeScript จะสามารถ infer type ของ loop variable หรือ closure parameter ได้อย่างราบรื่นโดยไม่มีปัญหา
export function calculateTotal(items: Item[]): number {
// Local variable: inference is fine
const taxRate = 0.08;
// Private class member inside a local class: inference is fine
class Helper {
private cache = new Map();
}
return items.reduce((sum, item) => sum + item.price * (1 + taxRate), 0);
}
เฉพาะ function signature ที่ถูก export ออกไปเท่านั้นที่ต้องมีการใส่ annotation ส่วนกลไกภายใน (internal machinery) ยังคงมีความยืดหยุ่นและเขียนได้ง่าย สิ่งนี้ช่วยให้ภาระในการเขียนโค้ด (authoring burden) อยู่ในระดับที่ยอมรับได้ คุณไม่ได้เปลี่ยนไปใช้สไตล์แบบ explicit ทั้งหมดในทุกที่ แต่คุณเพียงแค่ทำให้สัญญา (contract) ที่ขอบเขตของแต่ละ module มีความชัดเจนเป็นทางการมากขึ้น
มันเหมาะกับ Codebase ของคุณหรือไม่?
การนำ isolatedDeclarations มาใช้จะเปลี่ยนจุดที่คุณใช้เวลา คุณอาจต้องพิมพ์เพิ่มขึ้นอีกไม่กี่ครั้งเมื่อเขียน export แต่สิ่งที่ได้รับกลับมาคือคุณไม่ต้องเสียเวลา (paying interest) ไปกับทุกๆ build อีกต่อไป สำหรับผู้สร้าง library สิ่งนี้มักจะเป็นเรื่องที่ยอมรับได้ง่าย เพราะอย่างไรเสีย Public APIs ก็ควรจะมีการใส่ annotation อยู่แล้ว สำหรับนักพัฒนาแอปพลิเคชันที่ทำงานภายใน closed monorepo ต้นทุนที่ต้องจ่ายล่วงหน้า (upfront cost) อาจดูเหมือนเป็นขั้นตอนที่เกินความจำเป็น แต่ถ้าทีมของคุณวัดความเร็วในการ build ด้วยช่วงเวลาพักดื่มกาแฟ การแลกเปลี่ยนนี้จะกลายเป็นสิ่งที่น่าดึงดูดอย่างรวดเร็ว
คุณสามารถนำมาใช้แบบค่อยเป็นค่อยไปได้ โดยการเปิดใช้งาน flag, รัน compiler และแก้ไข error ที่เกิดขึ้นกับ exported symbols ข้อความ error จะบอกคุณอย่างชัดเจนว่า public-facing types ตัวไหนที่เป็นแบบ implicit ให้แก้ไขตัวเหล่านั้น ปล่อยส่วนภายในไว้เหมือนเดิม แล้วคุณจะเห็นขั้นตอนการสร้าง declaration เร็วขึ้นอย่างเห็นได้ชัด
สิ่งหนึ่งที่ต้องจำไว้คือ: flag นี้ไม่ได้ทำให้ตัว type checker ของ TypeScript เร็วขึ้น หากคุณต้องการ feedback ที่เร็วขึ้นใน editor หรือต้องการให้ tsc --noEmit ทำงานเร็วขึ้น คุณยังคงต้องใช้ project references, การกำหนด file inclusion ที่เข้มงวดขึ้น หรือการแก้ไขเชิงสถาปัตยกรรมอื่นๆ isolatedDeclarations มุ่งเป้าไปที่การทำ emission ของไฟล์ .d.ts โดยเฉพาะ มันคือการทำ build optimization ไม่ใช่การทำ type-checking optimization
บทสรุปที่สำคัญ
isolatedDeclarations ขอให้คุณปฏิบัติต่อ public types ของคุณในฐานะ first-class artifacts เลิกปล่อยให้ compiler ต้องมานั่งคาดเดา (deduce) พวกมัน แต่ให้เขียนมันลงไปเลย เมื่อคุณทำเช่นนั้น compiler ก็จะไม่ต้องไล่ตรวจสอบ (crawling) ผ่าน dependency graph ทั้งหมดทุกครั้งที่ต้องการสร้างไฟล์ declaration อีกต่อไป มันจะสามารถ emit แบบขนาน (in parallel) ได้, เครื่องมืออย่าง esbuild และ swc จะสามารถจัดการ workflow ของ TypeScript ได้อย่างเต็มรูปแบบ และการ build monorepo ของคุณก็จะไม่ล่าช้าอีกต่อไป
ต้นทุนจะเปลี่ยนจากเวลาในการ build ไปเป็นเวลาในการเขียนโค้ด (authoring time) แทน สำหรับทีมส่วนใหญ่ที่กำลังเติบโต นี่คือการแลกเปลี่ยนที่คุ้มค่า
