TypeScript'te Decorator'lar: Standart TC39 Decorator'ları ve context Object'i
TS 5.0 ile gelen standart decorator'ları; method, class, field ve accessor decorator'larını, context object'ini, neden erasable olmadıklarını ve eski experimentalDecorators ayarından farklarını görüyoruz.
Sürüm notu: Bu yazıdaki kodlar TypeScript 7.0 ve Node 26 içindir.
Önce Tahmin Et
Bir method'un üstüne iki decorator koyduk. Dikkat et, method'u hiç çağırmıyoruz:
function tag(label: string) {
console.log(`evaluate ${label}`);
return function (target: Function, context: ClassMethodDecoratorContext) {
console.log(`apply ${label} to ${String(context.name)}`);
};
}
class Report {
@tag("first")
@tag("second")
print() {
console.log("printing");
}
}
console.log("class ready");
Konsola ne yazar, hangi sırayla? printing satırı görünür mü? Cevabı, decorator'ın ne zaman çalıştığını konuştuktan sonra vereceğiz.
Decorator Nedir?
Decorator, bir class'ın ya da class üyesinin önüne @ ile yazılan ve o tanımı değiştirebilen bir fonksiyondur. Method'u saran bir log, method'u instance'a bağlayan bir yardımcı, bir class'ı bir kayıt listesine ekleyen bir işaret: hepsi tekrar tekrar elle yazdığımız kalıplardır ve decorator bunları tek satırlık bir etikete indirir.
TypeScript decorator'ları uzun zamandır destekliyor, ama iki farklı dönem var. Eski dönemde decorator'lar TypeScript'e özgü, deneysel bir özellikti ve experimentalDecorators ayarıyla açılıyordu. TC39'daki decorator önerisi olgunlaştıktan sonra TS 5.0 ile standart decorator'lar geldi; bugün hiçbir ayar açmadan yazdığın @ bu standart sürümdür. Bu yazıdaki örneklerin hepsi standart decorator'larla yazıldı, eski sürümü en sonda ayrıca konuşacağız.
Standart bir decorator iki argüman alır: süslediği değer (method için fonksiyonun kendisi, class için class'ın kendisi) ve bir context object'i. Bir değer döndürürse, o değer orijinalin yerine geçer.
İlk Method Decorator'ı
Klasik örnekle başlayalım: çağrıları ve dönüş değerlerini loglayan bir decorator.
function logged<This, Args extends unknown[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>
) {
const name = String(context.name);
return function (this: This, ...args: Args): Return {
console.log(`-> ${name}(${args.join(", ")})`);
const result = target.call(this, ...args);
console.log(`<- ${name} = ${result}`);
return result;
};
}
class Calculator {
@logged
add(a: number, b: number) {
return a + b;
}
}
new Calculator().add(2, 3);
// -> add(2, 3)
// <- add = 5
Generic'ler ilk bakışta kalabalık görünebilir ama her biri bir iş yapıyor. This method'un çağrıldığı instance'ın type'ı, Args parametrelerin tuple'ı, Return dönüş tipi. ClassMethodDecoratorContext da TypeScript'in standart decorator'lar için verdiği hazır type'lardan biri. Bu sayede döndürdüğümüz sarmalayıcı fonksiyon orijinal method'la birebir aynı imzayı taşıyor; add'i yanlış argümanla çağırırsan derleyici yine yakalar.
Şimdi baştaki sorunun cevabı: konsola sırasıyla evaluate first, evaluate second, apply second to print, apply first to print ve class ready yazılır. printing hiç görünmez, çünkü decorator'lar method çağrıldığında değil, class tanımlanırken çalışır. Sıra da önemli: @tag("first") gibi ifadeler yukarıdan aşağı değerlendirilir, ama dönen decorator'lar method'a en yakından başlayarak, yani aşağıdan yukarı uygulanır. Bir hediyeyi kat kat paketlemek gibi düşün: en içteki kâğıt önce sarılır.
context Object'i ve addInitializer
context decorator'a süslediği şey hakkında bilgi verir. kind neyi süslediğini söyler ("class", "method", "getter", "setter", "field", "accessor"), name üyenin adını, static ve private üyenin nasıl tanımlandığını verir. access ile değere sonradan erişebilirsin. En kullanışlısı ise addInitializer: verdiğin fonksiyon, her instance oluşturulurken constructor'ın başında çalışır.
JavaScript serilerinden tanıdığın bir sorunu düşün: bir method'u object'ten koparıp çağırdığında this kaybolur. addInitializer ile bunu kökten çözebiliriz:
function bound(target: Function, context: ClassMethodDecoratorContext) {
const name = context.name;
context.addInitializer(function (this: any) {
// bind sonucunun tipini burada takip etmiyoruz, bu yüzden any
this[name] = this[name].bind(this);
});
}
class Greeter {
name: string;
constructor(name: string) {
this.name = name;
}
@bound
greet() {
console.log(`Hello, I am ${this.name}`);
}
}
const { greet } = new Greeter("Ada");
greet(); // Hello, I am Ada
Decorator olmadan son satır this undefined olduğu için hata fırlatırdı. Burada her instance kendi bağlanmış greet kopyasını alıyor ve method'u object'ten koparsan bile çalışıyor.
Class, Field ve Accessor Decorator'ları
Class decorator'ı class'ın kendisini alır. Döndürdüğü bir şey yoksa class olduğu gibi kalır; bu, class'ları bir yere kaydetmek için ideal:
const registry = new Map<string, Function>();
function register(name: string) {
return function (target: Function, context: ClassDecoratorContext) {
console.log(`kind: ${context.kind}, name: ${String(context.name)}`);
registry.set(name, target);
};
}
@register("csv")
class CsvExporter {
export(rows: string[][]) {
return rows.map((row) => row.join(",")).join("\n");
}
}
console.log([...registry.keys()]); // [ 'csv' ]
Konsola önce kind: class, name: CsvExporter, sonra [ 'csv' ] yazılır. register("csv") bir decorator factory: argüman alıp asıl decorator'ı döndüren bir fonksiyon. Baştaki tag da aynı kalıptaydı.
Field decorator'ları biraz farklıdır. Field'ın henüz bir değeri olmadığı için ilk argüman her zaman undefined gelir. Değeri değiştirmek istersen, başlangıç değerini alıp yenisini döndüren bir fonksiyon döndürürsün. Sonraki atamaları yakalamak istiyorsan field yerine TS 4.9 ile gelen accessor anahtar kelimesini kullanırsın; bu, arkasında private bir alan olan otomatik bir getter/setter çifti üretir:
function trimmed(target: undefined, context: ClassFieldDecoratorContext<unknown, string>) {
return function (initial: string) {
return initial.trim();
};
}
function clamp(min: number, max: number) {
return function (
target: ClassAccessorDecoratorTarget<unknown, number>,
context: ClassAccessorDecoratorContext<unknown, number>
): ClassAccessorDecoratorResult<unknown, number> {
return {
set(value) {
target.set.call(this, Math.min(max, Math.max(min, value)));
},
init(value) {
return Math.min(max, Math.max(min, value));
},
};
};
}
class Player {
@trimmed name = " Ada ";
@clamp(0, 100) accessor volume = 150;
}
const player = new Player();
console.log(player.name); // Ada
console.log(player.volume); // 100
player.volume = -20;
console.log(player.volume); // 0
@trimmed sadece başlangıç değerine dokunur; player.name = " Alan " yazarsan boşluklar kalır. @clamp ise hem init ile başlangıç değerini hem de set ile her yeni atamayı sınırlıyor. Type'lar burada da çalışıyor: @clamp'i bir string accessor'a koymaya kalkarsan derleyici itiraz eder.
Decorator Erasable Değildir: tsc ile Derle
Serinin başından beri örnekleri erasable yazıyoruz; tipler silinince geriye geçerli JavaScript kalıyor ve Node 26 dosyayı node file.ts ile doğrudan çalıştırabiliyor. Decorator'lar bu kuralın dışında. Henüz hiçbir JavaScript motoru decorator syntax'ını tanımıyor ve Node'un type stripping'i decorator'ları dönüştürmüyor; Node belgeleri bunun bir parser hatasıyla sonuçlandığını açıkça söylüyor. Yukarıdaki dosyalardan birini node ile çalıştırırsan @ satırında bir SyntaxError alırsın.
İlginç bir ayrıntı: TS 5.8 ile gelen erasableSyntaxOnly ayarı decorator'lar için uyarı vermiyor. TypeScript 7.0.2 ile denediğimizde bu ayar açıkken decorator'lı dosya hatasız derlendi. Yani "erasableSyntaxOnly sessiz kaldı, demek ki Node çalıştırır" çıkarımı burada yanlış olur.
Çözüm, kodu önce tsc ile JavaScript'e derlemek. tsconfig'inde outDir olarak dist verdiğini varsayarsak:
npx tsc
node dist/report.js
Derleyici, decorator'ları __esDecorate ve __runInitializers adlı küçük yardımcı fonksiyonlarla sıradan JavaScript'e çevirir. Bunun için target'ın decorator'ları içermeyen bir sürüm olması gerekir. TypeScript 7'nin varsayılanı olan es2025 bunu sağlar. target'ı esnext yaparsan tsc decorator'ları olduğu gibi bırakır ve çıktı yine çalışmaz; bunu da denedik, @tag("first") satırı JavaScript çıktısında aynen duruyordu.
Eski Dönem: experimentalDecorators
Eski kod tabanlarında ve bazı köklü library'lerde experimentalDecorators ile yazılmış decorator'lar göreceksin. İmzaları farklıdır: method decorator'ı context yerine prototype'ı, üyenin adını ve property descriptor'ını alır.
function oldLog(target: object, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (this: unknown, ...args: unknown[]) {
console.log(`call ${key}`);
return original.apply(this, args);
};
}
class Service {
@oldLog
run() {
return "done";
}
}
console.log(new Service().run());
Bu kodu ayar açmadan derlersen tsc standart decorator kurallarını uygular ve şunu söyler:
error TS1241: Unable to resolve signature of method decorator when called as an expression.
The runtime will invoke the decorator with 2 arguments, but the decorator expects 3.
--experimentalDecorators ile derlediğinde hata kaybolur ve çıktı call run, done olur; tersine, bu ayar açıkken standart logged decorator'ımız TS1241 ile reddedilir. İki sistem bir arada çalışmaz, ayar bütün projeyi bir tarafa koyar. Eski sistemde olup standartta olmayan şeyler de var: parameter decorator'ları ve emitDecoratorMetadata ile tip bilgisini çalışma zamanına taşıma. Bu yüzden onlara dayanan library'ler hâlâ eski ayarla çalışıyor. Yeni yazdığın kodda standart decorator'ları seç; eski bir projedeysen, mevcut ayara uy.
Sık Düşülen Tuzaklar
Birincisi, decorator'ı her yere koyabileceğini sanmak. Standart decorator'lar sadece class'lara ve class üyelerine uygulanır; tek başına duran bir fonksiyona ya da bir parametreye koyamazsın.
İkincisi, field decorator'ının atamaları izlediğini sanmak. Field decorator'ı yalnızca başlangıç değerini görür. Her atamada bir şey yapmak istiyorsan accessor kullan.
Üçüncüsü, sarmalayıcıyı arrow function olarak döndürmek. Arrow function kendi this'ini almaz, decorator'ın tanımlandığı yerdeki this'i yakalar; method'un instance'ına ulaşamazsın. Sarmalayıcıyı function ile yaz ve this parametresini tiple.
Dördüncüsü, node file.ts ile çalıştırmayı denemek ve hatayı TypeScript'e bağlamak. Hata TypeScript'ten değil, JavaScript motorundan gelir; decorator'lı dosyaları tsc ile derleyip çalıştır.
Küçük Bir Deneme
★☆☆ Konsola ne yazar, tahmin et: Report class'ının sonuna new Report().print(); ekle. Bu satır evaluate ve apply satırlarını tekrar yazdırır mı?
★★☆ Bir @timed method decorator'ı yaz: method'un ne kadar sürdüğünü performance.now() ile ölçsün ve add took 0.01 ms gibi bir satır yazsın. logged'daki generic imzayı kullan ve yanlış argümanla çağrının hâlâ yakalandığını doğrula.
★★★ @clamp(0, 100)'ü accessor label = "x" gibi bir string accessor'a koy. Derlenir mi? Hata verirse mesajı oku ve hangi generic parametrenin uyuşmadığını bul.
Decorator'lar, TypeScript'in JavaScript'e henüz gelmemiş bir özelliği derleyerek bugünden kullanmamızı sağladığı bir örnekti. Bir sonraki yazıda benzer bir hikâyeye bakacağız: dosya, bağlantı ya da zamanlayıcı gibi kaynakları kendiliğinden kapatan using ve TypeScript'in bu syntax'a verdiği destek.