ข้ามไปยังเนื้อหา

Decorators

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

TypeScript 5.5 จำกัดสิ่งที่ตามหลัง @ ได้: มีแค่ identifier, dotted name หรือการเรียก function อย่างอื่นต้องใส่วงเล็บถึงจะ parse ผ่าน:

// @expect-error TS1497 TS2660
class 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 TS2660
class 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 ต้นฉบับกับ 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));
Open in TS Playground

กด 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 ที่ไม่เคยถูกออกแบบให้มันลบทิ้งได้เลย

ชนิด decoratorรับอะไรใช้ทั่วไป
Classตัว class, contextregister class, wrap constructor
Methodmethod, contextwrap/แทน behavior (logging, timing)
Accessorgetter/setter, contextดักการ read/write
Fieldundefined, contextreturn initializer เพื่อแปลง initial value

field decorator ต่างออกไปนิด — ไม่ได้รับ value มา wrap แต่ return initializer function ที่แปลง initial value ของ field เมื่อสร้าง instance ได้

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 นี้เลย”

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.ts
metadata.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 ตั้งค่าได้ ให้เขียน 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() { /* ... */ }
}

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.ts
var __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 เลย

แยกสองคำถามที่เป็นอิสระต่อกันไว้เสมอเมื่อ 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 decoratorECMAScript มาตรฐาน ไม่ต้องใช้ 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/Angularexperimental, 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 ไม่ได้ถูกออกแบบมาแทนที่เลย
  • 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) จะ return undefined เงียบ ๆ เพราะไม่มี design:paramtypes metadata ถูก 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 เทียบเท่าเลย

code TypeScript ใหม่ควรใช้ decorator design แบบไหน?
argument ตัวที่สองของ stage-3 decorator (context) ให้อะไร?
อันนี้ compile ผ่านสะอาดภายใต้ --erasableSyntaxOnly และ --strict ทำไมมันยัง throw SyntaxError เมื่อรันด้วย node --experimental-strip-types?
function logged(value: any, context: ClassMethodDecoratorContext) {
  return value;
}
class Service {
  @logged
  fetch() { return "ok"; }
}
SyntaxError ที่ decorator throw ภายใต้ Node type-stripping ต่างจาก ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX ที่ parameter property throw ตรงนั้นยังไง?
ทำไมปุ่ม "Run" ของ TSPlayground ในเบราว์เซอร์รัน code ที่ใช้ stage-3 decorator ได้สำเร็จ ทั้งที่ node --experimental-strip-types ทำไม่ได้?
compile อันนี้ด้วย stage-3 settings ธรรมดา เทียบกับด้วย --experimentalDecorators --emitDecoratorMetadata ได้ output ต่างกันมาก ทำไม NestJS และ Angular ยังต้องใช้คู่ legacy แทนที่จะใช้ stage-3 อย่างเดียว?
function Injectable(): ClassDecorator {
  return (target) => {};
}
class Logger {}
@Injectable()
class UserService {
  logger: Logger;
  constructor(logger: Logger) { this.logger = logger; }
}