Decorators
decorator มีสองยุค — ใช้ยุคใหม่
หัวข้อที่มีชื่อว่า “decorator มีสองยุค — ใช้ยุคใหม่”decorator คือ function ที่ wrap หรือสังเกต class หรือ member ของ class ให้คุณเพิ่ม behavior แบบ declarative ด้วย syntax @name มีสอง design ที่เข้ากันไม่ได้ และสำคัญว่าคุณหมายถึงอันไหน:
- legacy decorator — design เก่าแบบ experimental ที่เปิดด้วย
experimentalDecoratorsในtsconfigยังใช้เยอะใน NestJS และ Angular จึง flag นี้ยังเป็น opt-in ที่รองรับอยู่ใน TypeScript 7 signature ต่างกัน พึ่งreflect-metadataและพึ่ง emit ของtscเอง — build ที่แค่ strip type ทิ้ง (--erasableSyntaxOnly, Node type stripping, fast path ของ bundler ส่วนใหญ่) รันมันไม่ได้ - stage-3 decorator — design ECMAScript ที่เป็นมาตรฐาน รองรับ native ตั้งแต่ TypeScript 5.0 โดยไม่ต้องใช้ flag พิเศษ และไม่เปลี่ยนเลยจนถึง TypeScript 7 เพราะ syntax เป็น ECMAScript ล้วน ๆ ไม่ใช่ส่วนขยายของ TypeScript อันนี้จึงเป็นตัวที่ควรเรียนสำหรับ code ใหม่
บทเรียนนี้ครอบคลุม decorator แบบ stage-3
parse เข้มขึ้นตั้งแต่ 5.5
หัวข้อที่มีชื่อว่า “parse เข้มขึ้นตั้งแต่ 5.5”TypeScript 5.5 จำกัดสิ่งที่ตามหลัง @ ได้: มีแค่ identifier, dotted name หรือการเรียก function อย่างอื่นต้องใส่วงเล็บถึงจะ parse ผ่าน:
// @expect-error TS1497 TS2660class C { @super.decorate method() {}}วงเล็บแก้ได้แค่ error ตอน parse (TS1497) มันไม่ได้ทำให้ super ใช้ตรงนั้นได้ — decorator expression ถูก evaluate ครั้งเดียวตอน class-definition time ใน lexical scope ที่อยู่ นอก class body — ก่อนที่ member ใด ๆ (และ super binding ของมัน) จะมีอยู่ด้วยซ้ำ ดังนั้นบน subclass จริง แบบใส่วงเล็บก็ยังพัง เหลือแค่ error เชิง semantic:
// @expect-error TS2660class Base { static decorate(originalMethod: any, context: ClassMethodDecoratorContext) {}}
class C extends Base { @(super.decorate) method() {}}วงเล็บมีไว้สำหรับ decorator expression ที่ไม่ใช่ identifier จริง ๆ — ternary เลือกระหว่าง decorator สองตัว, การเรียก function, หรือ index เข้า table ของ decorator:
declare function a(originalMethod: any, context: ClassMethodDecoratorContext): any;declare function b(originalMethod: any, context: ClassMethodDecoratorContext): any;declare const cond: boolean;
class C { @(cond ? a : b) // ✅ parentheses make a non-identifier expression a valid decorator method() {}}method decorator
หัวข้อที่มีชื่อว่า “method decorator”method decorator รับ method ต้นฉบับกับ context object แล้ว return ตัวแทน (หรือไม่ return อะไร) นี่คือ decorator @logged ที่ wrap method เพื่อ log การเรียกของตัวเอง:
function logged(originalMethod: any, context: ClassMethodDecoratorContext) { const name = String(context.name); function replacement(this: any, ...args: any[]) { console.log(`calling ${name}`); return originalMethod.call(this, ...args); } return replacement;}
class Service { @logged fetch(id: number) { return `item ${id}`; }}
new Service().fetch(1); // logs "calling fetch", then returns "item 1"context object คือหัวใจของ design ใหม่ โดยบอก decorator ว่ากำลัง decorate อะไร — context.kind ("method", "field", "getter", …), context.name, context.static, context.private และ hook addInitializer สำหรับงาน setup
A method decorator, actually running in the browser
function logged(originalMethod: any, context: ClassMethodDecoratorContext) { const name = String(context.name); function replacement(this: any, ...args: any[]) { console.log(`calling ${name}`); return originalMethod.call(this, ...args); } return replacement;}
class Service { @logged fetch(id: number) { return `item ${id}`; }}
console.log(new Service().fetch(1)); กด Check types: ไม่มี diagnostic เลย เหมือน fence อื่นในบทนี้ แต่กด Run — ต่างจาก node --experimental-strip-types — มันรันได้จริง print calling fetch แล้ว item 1 ปุ่ม Run ของ playground ในเบราว์เซอร์ใช้ transpileModule ของ TypeScript compiler เอง (ตอนนี้เป็น TypeScript 5.9.3 ตรงนั้น เทียบกับ 7.0.2 สำหรับ type-check ที่อื่นในคอร์สนี้) ซึ่ง down-level stage-3 decorator ให้กลายเป็น helper function ES2022 ธรรมดา (__esDecorate, __runInitializers) แทนที่จะ emit raw @ syntax ตรง ๆ output ที่ compile แล้วเป็น JavaScript ธรรมดาที่ไม่มี @ syntax เหลืออยู่ ดังนั้นรันได้ทุกที่ — รวมถึงข้างใน sandboxed iframe ของ playground นี้ และรวมถึง Node เองถ้าคุณ compile ล่วงหน้าด้วย tsc แทนที่จะให้ Node strip type สด ๆ node --experimental-strip-types พังเพราะมันทำสิ่งที่สองซึ่งตื้นกว่ามาก: มันลบ type syntax ทิ้งแบบ textual โดยไม่รัน transform จริง และ decorator เป็น syntax ที่ไม่เคยถูกออกแบบให้มันลบทิ้งได้เลย
สี่สิ่งที่ decorate ได้
หัวข้อที่มีชื่อว่า “สี่สิ่งที่ decorate ได้”| ชนิด decorator | รับอะไร | ใช้ทั่วไป |
|---|---|---|
| Class | ตัว class, context | register class, wrap constructor |
| Method | method, context | wrap/แทน behavior (logging, timing) |
| Accessor | getter/setter, context | ดักการ read/write |
| Field | undefined, context | return initializer เพื่อแปลง initial value |
field decorator ต่างออกไปนิด — ไม่ได้รับ value มา wrap แต่ return initializer function ที่แปลง initial value ของ field เมื่อสร้าง instance ได้
accessor decorator
หัวข้อที่มีชื่อว่า “accessor decorator”accessor decorator เล็ง member ที่เป็น auto-accessor แบบ accessor keyword (จากบท Classes, Deep) และ wrap ได้ทั้ง get กับ set ที่ generate มา target ของมันไม่ใช่ function เดียว แต่เป็นคู่ { get, set }:
function logged( value: ClassAccessorDecoratorTarget<unknown, number>, context: ClassAccessorDecoratorContext,) { return { get(this: unknown) { const v = value.get.call(this); console.log(`get ${String(context.name)} -> ${v}`); return v; }, set(this: unknown, newValue: number) { console.log(`set ${String(context.name)} <- ${newValue}`); value.set.call(this, newValue); }, };}
class Counter { @logged accessor count = 0;}
new Counter().count = 5;อันนี้ compile ผ่านสะอาด และเหมือน class decorator ก่อนหน้าในบทนี้ throw SyntaxError: Invalid or unexpected token แบบเดียวกันภายใต้ node --experimental-strip-types — decorator syntax ไม่ใช่ชนิดของสิ่งที่ถูก decorate เป็นสิ่งที่ stripper ของ Node parse ไม่ได้ มีรายละเอียดหนึ่งที่ควรเอ่ยให้แม่นยำ: error นั้น ไม่ใช่ code ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX ที่ Node ใช้สำหรับ syntax ของ TypeScript ที่มันรู้จักแต่ตั้งใจปฏิเสธ (parameter property, enum, namespace — การละเมิด erasable-syntax จากบท Classes, Deep) decorator ไม่ใช่ syntax ของ TypeScript เลย — มันเป็น proposal ของ JavaScript ที่ stripper ของ Node ไม่เคยถูกสร้างมาให้ลบทิ้ง parser จึงพังที่ raw token @ เอง แบบทั่วไปเหมือนที่มันจะพังกับตัวอักษรที่ไม่รู้จักตัวอื่น สอง failure mode นี้หน้าตาคล้ายกันแต่มาจากคนละที่: อันหนึ่งคือ stripper ของ Node บอกว่า “ฉันเห็นสิ่งนี้ แต่ไม่อนุญาตตรงนี้”; อีกอันคือ JavaScript parser พื้นฐานของ Node บอกว่า “ฉันไม่รู้จัก token นี้เลย”
decorator metadata
หัวข้อที่มีชื่อว่า “decorator metadata”context ของ stage-3 decorator ทุกตัวมี object metadata (context.metadata, TypeScript 5.2+) — plain object ที่แชร์กันระหว่าง decorator ทุกตัวที่ apply กับ class เดียวกัน มีไว้ให้ decorator เก็บข้อมูลไว้ อ่านได้ทีหลังจากตัว class เองผ่าน well-known symbol Symbol.metadata:
function track(value: undefined, context: ClassFieldDecoratorContext) { const metadata = context.metadata as Record<string, unknown>; metadata[context.name as string] = true;}
class Config { @track host = "localhost";}
console.log(Object.keys(Config[Symbol.metadata] ?? {}));อันนี้ compile ผ่านสะอาดภายใต้ setting ของคอร์สนี้ (--lib esnext,dom,dom.iterable) — แต่ Symbol.metadata ใหม่พอที่ควรเช็คว่า lib ไหนที่ ship มันจริง compile source เดียวกันเป๊ะด้วย --lib es2025,dom แทน esnext พัง:
$ node_modules/typescript/bin/tsc --ignoreConfig --noEmit --strict --target es2025 --lib es2025,dom --skipLibCheck metadata.tsmetadata.ts(10,27): error TS2550: Property 'metadata' does not exist on type 'SymbolConstructor'. Do you need to change your target library link. Do you need to change your target library? Try changing the 'lib' compiler option to 'esnext' or later.ในตอน TypeScript 7.0.2 typing ของ Symbol.metadata ยังอยู่แค่ใน esnext proposal lib ไม่ได้อยู่ใน versioned es2025 lib แม้ตัว language feature เองจะ stable มาตั้งแต่ TypeScript 5.2 แล้วก็ตาม project ไหนที่ต้องการ decorator metadata โดยเฉพาะต้องมี "lib": [..., "esnext"] (หรือ versioned lib ใหม่กว่านั้นถ้ามีวันหนึ่ง ship ออกมา) ไม่ใช่แค่ target ล่าสุด
decorator factory
หัวข้อที่มีชื่อว่า “decorator factory”จะทำให้ decorator ตั้งค่าได้ ให้เขียน function ที่ return decorator — เรียกว่า decorator factory การเรียก @name(args) จะรัน factory แล้วค่าที่ return ออกมาคือ decorator จริง
function logged(label: string) { // factory: takes config return function (originalMethod: any, context: ClassMethodDecoratorContext) { return function (this: any, ...args: any[]) { console.log(`[${label}] ${String(context.name)}`); return originalMethod.call(this, ...args); }; };}
class Api { @logged("http") get() { /* ... */ }}ทำไม NestJS และ Angular ยังเอื้อมไปหา design แบบเก่า
หัวข้อที่มีชื่อว่า “ทำไม NestJS และ Angular ยังเอื้อมไปหา design แบบเก่า”context.metadata ของ stage-3 (ข้างบน) ตั้งใจให้เรียบง่ายมาก: มันเป็น empty object ที่ decorator เติมเอง ไม่มีการเชื่อมอัตโนมัติกับอะไรที่ compiler รู้อยู่แล้วเกี่ยวกับ class เลย framework แบบ dependency-injection ต้องการสิ่งที่ stage-3 ไม่มีให้เลย: type ที่ประกาศไว้ของ constructor parameter ทุกตัว เพื่อให้ container สามารถ lookup “parameter ตัวนี้ type เป็น Logger ไปสร้างหรือ fetch Logger มา” โดยไม่ต้องให้ developer เขียน mapping นั้นด้วยมือ ข้อมูลชิ้นนี้เจาะจง — emit อัตโนมัติ ไม่ต้องมี decorator คำนวณเอง — คือสิ่งที่ emitDecoratorMetadata (คู่กับ experimentalDecorators) ให้มา และ stage-3 decorator ไม่มีอะไรเทียบเท่าเลย
compile class เดียวกันสองแบบทำให้ช่องว่างนี้เป็นรูปธรรม ภายใต้ stage-3 (setting ค่าเริ่มต้นของคอร์สนี้ — ไม่มี flag เพิ่ม):
function Injectable(): ClassDecorator { return (target) => {};}
class Logger {}
@Injectable()class UserService { logger: Logger; constructor(logger: Logger) { this.logger = logger; }}emit ออกมา (tsc --target es2025 --module esnext ไม่มี flag เพิ่ม):
function Injectable() { return (target) => { };}class Logger {}let UserService = (() => { let _classDecorators = [Injectable()]; let _classDescriptor; let _classExtraInitializers = []; let _classThis; var UserService = class { static { _classThis = this; } static { const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(null) : void 0; __esDecorate(null, _classDescriptor = { value: _classThis }, _classDecorators, { kind: "class", name: _classThis.name, metadata: _metadata }, null, _classExtraInitializers); UserService = _classThis = _classDescriptor.value; if (_metadata) Object.defineProperty(_classThis, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata }); __runInitializers(_classThis, _classExtraInitializers); } logger; constructor(logger) { this.logger = logger; } }; return UserService = _classThis;})();ไม่มีตรงไหนใน output นั้นเลยที่บอกว่า type ที่ประกาศของ logger คือ Logger — constructor(logger) ที่ emit ออกมาเป็นแค่ชื่อ parameter ธรรมดา ตรงตามที่ erasure ทิ้ง type เสมอ compile source เดียวกันเป๊ะด้วย --experimentalDecorators --emitDecoratorMetadata แทน:
$ node_modules/typescript/bin/tsc --ignoreConfig --target es2025 --module esnext --experimentalDecorators --emitDecoratorMetadata --outDir out legacy.tsvar __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r;};var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);};function Injectable() { return (target) => { };}class Logger {}let UserService = class UserService { logger; constructor(logger) { this.logger = logger; }};UserService = __decorate([ Injectable(), __metadata("design:paramtypes", [Logger])], UserService);__metadata("design:paramtypes", [Logger]) คือชิ้นที่ stage-3 ไม่มีทางแสดงออกได้เลย: compiler เองรู้จาก source ว่า logger: Logger แล้ว emit ค่า class Logger จริง ๆ ลงใน output ดึงกลับมาได้ตอน runtime ด้วย Reflect.getMetadata("design:paramtypes", UserService) (ผ่าน package แยกต่างหาก reflect-metadata ที่ให้ Reflect.metadata/Reflect.getMetadata) นั่นคือ mechanism ตรง ๆ ที่ @Injectable() ของ NestJS และ dependency injector ของ Angular สร้างขึ้นมาบน: decorate class แล้วถาม runtime ว่า “constructor นี้ parameter type อะไรบ้าง” แล้วได้คำตอบจริง — ไม่มี context.metadata ของ stage-3 จำนวนเท่าไรก็ตามให้ได้แบบนี้ เพราะ stage-3 metadata เป็นแค่สิ่งที่ decorator เลือกเขียนเข้าไปเองด้วยมือ ไม่เคยเป็นสิ่งที่ compiler เติมให้อัตโนมัติจาก type system เลย
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”แยกสองคำถามที่เป็นอิสระต่อกันไว้เสมอเมื่อ decorator code ไม่ทำงานตามที่คุณคาดหวัง: “compiler ต้อง transform syntax นี้เป็นอย่างอื่นไหม?” (setting erasable-syntax-only สนใจเรื่องนี้ และ decorator ผ่าน — มันเป็น ECMAScript มาตรฐาน ไม่มีอะไรต้อง transform สำหรับจุดประสงค์การ strip type) กับ “JavaScript engine ที่รัน code นี้จริง ๆ เข้าใจ syntax นี้เลยไหม?” (นี่คือสิ่งที่พังภายใต้ node --experimental-strip-types และเป็น failure ที่ต่างกันโดยสิ้นเชิง — parse error ดิบ ๆ ไม่ใช่ error แบบ TypeScript-construct-ที่-รู้จักแต่ถูกปฏิเสธ) ปุ่ม Run ของ playground ในเบราว์เซอร์เลี่ยงคำถามที่สองไปเลยด้วยการ transpile จริงก่อน ซึ่งก็เป็นวิธีเดียวกันเป๊ะที่ production build ที่ใช้ tsc (หรือ bundler ใดก็ตามที่มี TypeScript transform จริง ไม่ใช่แค่ stripper) ship code ที่ใช้ decorator ในปัจจุบัน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| Design | ประโยชน์ | ต้นทุน |
|---|---|---|
| stage-3 decorator | ECMAScript มาตรฐาน ไม่ต้องใช้ flag, context object rich และ type ดี, ใช้ได้กับ --erasableSyntaxOnly | รันภายใต้ pipeline ที่ strip อย่างเดียว (Node type stripping) ไม่ได้ถ้าไม่มี transpile จริงก่อน; context.metadata ไม่มี type information อัตโนมัติ |
legacy decorator (experimentalDecorators) | emitDecoratorMetadata ฝัง constructor parameter type จริง (design:paramtypes) เข้า output — รากฐานของ DI แบบ NestJS/Angular | experimental, signature ต่างจาก stage-3, ต้องใช้ reflect-metadata, ผูกกับ emit ของ tsc เอง (ไม่เคยทำงานภายใต้ pure type-stripper) |
accessor + accessor decorator | ดัก get กับ set ทั้งคู่ได้สะอาดผ่าน declaration เดียว | ใช้ได้แค่กับ member แบบ accessor keyword ไม่ใช่คู่ get/set ที่เขียนมือ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- ถ้า code type-check ผ่านภายใต้
--erasableSyntaxOnlyมันจะรันได้ภายใต้ Node type stripping decorator เป็นตัวอย่างที่ขัดแย้งชัดที่สุดในคอร์สนี้: มันผ่าน--erasableSyntaxOnlyสะอาด (ไม่มีอะไรต้อง transform ฝั่ง compiler) แต่ยังพังด้วยSyntaxErrorธรรมดาภายใต้node --experimental-strip-typesเพราะ flag นั้นแค่ strip type annotation เท่านั้น — มันไม่ implement หรือ transpile JavaScript-level proposal ใด ๆ ที่ V8 ยังไม่มี SyntaxErrorที่ decorator throw ภายใต้ Node เป็น error ชนิดเดียวกับ TypeScript construct ที่ erase ไม่ได้ (parameter property,enum) ไม่ใช่ — พวกนั้นได้ code เฉพาะของ Node คือERR_UNSUPPORTED_TYPESCRIPT_SYNTAXซึ่งเป็นการปฏิเสธตั้งใจของ syntax TypeScript ที่รู้จักแต่ไม่อนุญาต decorator พังด้วย parse error ทั่วไปเพราะ parser ของ stripper ไม่รู้จัก token@ว่าเป็นอะไรเลยcontext.metadataใน stage-3 decorator ให้สิ่งเดียวกับที่design:paramtypesให้ตัว legacy ไม่ใช่ —context.metadataเป็น empty object ที่ decorator ต้องเติมเอง;design:paramtypesคือ compiler emit parameter type จริงที่ประกาศไว้ของ constructor อัตโนมัติ ไม่ต้องมี decorator code เลย ซึ่งเป็น capability ที่ stage-3 decorator ไม่ได้ถูกออกแบบมาแทนที่เลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- decorator แบบ
@Injectable()stage-3 ที่คาดว่าจะอ่าน constructor parameter type จากcontextหรือcontext.metadataทำแบบนี้ไม่ได้ — context object ของ stage-3 ไม่มี type information อยู่เลย เพราะ type ถูก erase ก่อนที่ decorator ใด ๆ จะรันด้วยซ้ำ การอ่าน parameter type จริงตอน runtime ต้องใช้experimentalDecorators+emitDecoratorMetadataและรูปแบบ decorator แบบ legacy generated code ที่ผสม “stage-3 syntax” กับ “อ่าน metadata แบบdesign:paramtypes” กำลังรวมสอง design ที่ไม่ interoperate กัน - class decorator แบบ
@Componentที่ copy จากตัวอย่าง NestJS หรือ Angular เข้า project ที่ไม่ได้เปิดexperimentalDecoratorsตัว syntax decorator เองมักจะยัง compile ผ่าน (stage-3 รับ decorator function shape ได้หลากหลาย) แต่ reflection ใดก็ตามที่ decorator พึ่ง (Reflect.getMetadata) จะ returnundefinedเงียบ ๆ เพราะไม่มีdesign:paramtypesmetadata ถูก emit เลยถ้าไม่มี flag
🐛 หา bug ในโค้ดนี้
function Injectable(): ClassDecorator {return (target) => {};}
@Injectable()class Logger {}
@Injectable()class UserService {logger: Logger;constructor(logger: Logger) { this.logger = logger;}}
// Somewhere in a hand-rolled DI container:function resolve(target: any) {const paramTypes = Reflect.getMetadata("design:paramtypes", target) ?? [];console.log(paramTypes);}resolve(UserService);เฉลย
project นี้ไม่มี experimentalDecorators/emitDecoratorMetadata ใน tsconfig.json — decorator compile ผ่านได้ดีในฐานะ stage-3 decorator ธรรมดา แต่ resolve(UserService) log array ว่างเปล่า (หรือ throw ถ้า polyfill ของ reflect-metadata สำหรับ Reflect.getMetadata ไม่เคยถูก import เลย) ไม่ใช่ [Logger] metadata แบบ design:paramtypes ถูก emit จาก legacy decorator pipeline เท่านั้น ที่ถูก gate ไว้หลัง experimentalDecorators และ emitDecoratorMetadata รวมกัน — stage-3 decorator ไม่ว่าจะเขียนยังไงก็ไม่เคยเติมมันเลย เพราะ context.metadata ของ stage-3 เป็น bag แบบ opt-in ที่ decorator ต้องเติมเองด้วยมือ ไม่ใช่สิ่งที่ compiler เติมให้อัตโนมัติจาก type system วิธีแก้คือเปิด flag legacy ทั้งสองตัวจริง ๆ ทั้ง project (แล้วยอมรับ decorator model แบบ legacy ทุกที่) หรือเลิกใช้ reflection-based DI แล้วส่ง dependency ตรง ๆ แทน — trick เรื่อง constructor-parameter-type ที่ code นี้พยายามใช้ไม่มี stage-3 เทียบเท่าเลย