JavaScript Object Koruma ve Immutability: Property Descriptor, seal ve freeze
Property descriptor'ları, Object.defineProperty'nin sinsi varsayılanlarını, preventExtensions, seal ve freeze farkını, sığ ve derin dondurmayı ve object'leri değiştirmeden güncelleme desenlerini örneklerle görüyoruz.
Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.
Önce Tahmin Et
Bir uygulamanın ayarlarını tutan bir object'imiz var ve kimse değiştirmesin diye onu donduruyoruz. Bu kodu konsola yapıştırdığında ne görürsün?
const config = Object.freeze({
api: "https://example.com",
retry: { count: 3 }
});
config.api = "http://evil.test";
config.retry.count = 10;
console.log(config.api, config.retry.count);
Okumaya devam etmeden önce bir tahmin yap. İki atamanın ikisi de engellenecek mi, ikisi de geçecek mi, yoksa biri geçip biri takılacak mı? Bir de şunu düşün: hata alacak mısın, yoksa her şey sessizce mi olup bitecek?
Her Property'nin Görünmeyen Ayarları
Bir önceki yazıda property'lerin zincirde nasıl arandığını gördük. Şimdi tek bir property'ye yakından bakalım. Bir object'e user.name = "Ada" diye bir değer yazdığında motor aslında yalnızca değeri değil, onunla birlikte üç ayarı da saklar. Bu ayarların tamamına property descriptor denir:
const user = { name: "Ada" };
console.log(Object.getOwnPropertyDescriptor(user, "name"));
// { value: "Ada", writable: true, enumerable: true, configurable: true }
Bir property'yi etiketli bir çekmece gibi düşün. value çekmecenin içindekidir. writable, içindekini değiştirip değiştiremeyeceğini söyler. enumerable, çekmecenin envanter listesinde görünüp görünmediğini belirler; Object.keys, for...in, spread ve JSON.stringify bu listeye bakar. configurable ise çekmecenin sökülüp sökülemeyeceğini ve etiketinin yeniden yazılıp yazılamayacağını söyler; yani property silinebilir mi, ayarları değiştirilebilir mi.
Normal atamayla ya da object literal'iyle oluşturduğun her property'de bu üçü de true'dur. Dilin kendi object'lerinde ise durum farklı. Array.prototype.map'in descriptor'ına bakarsan enumerable: false görürsün; array'leri for...in ile dolaşınca method'ların listede çıkmamasının sebebi bu. Daha önce gördüğümüz class method'larının sayılamaz olması da aynı ayardan geliyor. Bir object'in bütün descriptor'larını tek seferde görmek istersen Object.getOwnPropertyDescriptors var.
Object.defineProperty ile Kontrolü Ele Almak
Ayarları kendin belirlemek istediğinde Object.defineProperty'yi kullanırsın. Konsolda dene:
const product = { name: "Keyboard" };
Object.defineProperty(product, "id", { value: 42 });
console.log(product.id); // 42
console.log(Object.keys(product)); // ["name"]
console.log(JSON.stringify(product)); // {"name":"Keyboard"}
product.id = 99;
console.log(product.id); // 42
delete product.id;
console.log(product.id); // 42
Burada yazmadığımız ayarların hepsi false oldu. Bu, defineProperty'nin en çok gözden kaçan kuralıdır: normal atamada varsayılanlar true, defineProperty'de ise false'tur. Sonuçta id okunabilen ama listelerde görünmeyen, değiştirilemeyen ve silinemeyen bir property oldu.
configurable: false tek yönlü bir kapıdır. Bir kez kapattığında o property'yi silemez, enumerable ayarını değiştiremez, onu getter/setter'a çeviremezsin. Ayarlarda izin verilen tek değişiklik writable'ı true'dan false'a çekmektir; geri açmak yoktur. writable açık kaldığı sürece değeri değiştirmek elbette serbest. Birden fazla property'yi aynı anda tanımlamak için Object.defineProperties kullanılır, bir önceki yazıda Object.create'in ikinci argümanına verdiğimiz object de tam olarak bu formattaydı.
Getter ve Setter'ı Descriptor ile Tanımlamak
Descriptor'ların iki türü vardır. Şimdiye kadar gördüğümüz, value ve writable taşıyan data descriptor'dı. Diğeri accessor descriptor: değer yerine get ve set fonksiyonları taşır. Okuma getter'ı, yazma setter'ı çalıştırır.
const temperature = { celsius: 20 };
Object.defineProperty(temperature, "fahrenheit", {
get() {
return this.celsius * 9 / 5 + 32;
},
set(value) {
if (typeof value !== "number") throw new TypeError("Expected a number");
this.celsius = (value - 32) * 5 / 9;
},
enumerable: true
});
console.log(temperature.fahrenheit); // 68
temperature.fahrenheit = 212;
console.log(temperature.celsius); // 100
İki türü karıştıramazsın: aynı descriptor'da hem value hem get yazarsan TypeError alırsın. Daha önce class'larda ve object literal'inde kullandığımız get fullName() {} yazımı da arka planda tam olarak böyle bir accessor descriptor üretir. Fark şu: class'ta getter prototype'a sayılamaz olarak konur, literal'de ise object'in kendisine sayılabilir olarak.
Setter'ın içinde doğrulama yapmak, bir değeri dışarıya açarken kontrolü elinde tutmanın en basit yoludur. Bir önceki yazıda shadowing'in istisnalarından söz etmiştik; işte biri: zincirde bir setter varsa atama yeni bir property oluşturmaz, o setter çalışır.
Üç Kilit: preventExtensions, seal ve freeze
Property'leri tek tek ayarlamak yerine bütün object'i kilitlemek istediğinde üç seviye var. Bunu bir ev üzerinden düşün. Object.preventExtensions yeni oda eklemeyi yasaklar ama var olan odaları yıkabilir, eşyalarını değiştirebilirsin. Object.seal duvarları sabitler: oda eklenemez, yıkılamaz, ama mobilyalar yer değiştirebilir. Object.freeze evi müzeye çevirir: hiçbir şeye dokunulmaz.
const open = Object.preventExtensions({ x: 1 });
open.y = 2;
delete open.x;
console.log(open); // {}
const sealed = Object.seal({ x: 1 });
sealed.x = 5;
sealed.y = 2;
delete sealed.x;
console.log(sealed); // { x: 5 }
const frozen = Object.freeze({ x: 1 });
frozen.x = 5;
frozen.y = 2;
delete frozen.x;
console.log(frozen); // { x: 1 }
Teknik olarak seal, object'i genişletilemez yapıp bütün property'lerini configurable: false'a çeker. freeze buna bir de writable: false ekler; getter/setter'lara dokunmaz, çünkü onların writable ayarı yoktur. Durumu sorgulamak için Object.isExtensible, Object.isSealed ve Object.isFrozen var. Üçü de geri alınamaz: dondurulmuş bir object'i çözmenin yolu yoktur, ancak kopyasını çıkarabilirsin.
Sessiz Başarısızlık mı, TypeError mı?
Yukarıdaki örneği konsolda çalıştırdığında hiçbir hata görmedin, değil mi? Konsol varsayılan olarak strict mode dışında (sloppy mode) çalışır ve orada yazılamaz bir property'ye atama yapmak sessizce yok sayılır. Kod çalışmaya devam eder, sen de değişikliğin neden tutmadığını saatlerce ararsın.
Strict mode'da aynı atama TypeError fırlatır. Daha önce gördüğümüz gibi module'ler ve class gövdeleri her zaman strict mode'dadır, yani gerçek projelerde genellikle hatayı görürsün. Farkı konsolda görmek için:
(function () {
"use strict";
const locked = Object.freeze({ x: 1 });
try {
locked.x = 2;
} catch (error) {
console.log(error.name); // "TypeError"
console.log(error.message);
}
})();
Hata mesajının metni tarayıcıdan tarayıcıya değişir ama tür her yerde TypeError'dır. Bir istisna daha var: dondurulmuş bir array'e push yapmak sloppy mode'da bile TypeError fırlatır, çünkü push kendi içinde başarısız atamayı hata olarak ele alır. Object.freeze([1, 2]).push(3) yazıp kendin gör.
Sığ Dondurma, Derin Dondurma
Şimdi baştaki soruya dönelim. Konsolda çıktı https://example.com 10 olur. config.api ataması sessizce yok sayıldı, çünkü config donmuş. Ama config.retry.count = 10 geçti. Neden? Object.freeze sığdır (shallow): yalnızca object'in kendi property'lerini kilitler. config.retry property'si başka bir object'i gösteriyor; o referansı değiştiremezsin ama gösterdiği object donmuş değil. Aynı kodu strict mode'da çalıştırsaydın ilk atamada TypeError alır, ikinci satıra hiç gelmezdin.
Bütün ağacı dondurmak istiyorsan kendin dolaşman gerekir:
function deepFreeze(object) {
Object.freeze(object);
for (const key of Reflect.ownKeys(object)) {
const value = object[key];
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
deepFreeze(value);
}
}
return object;
}
const settings = deepFreeze({
theme: { colors: { primary: "#0af" } },
langs: ["tr", "en"]
});
settings.theme.colors.primary = "red";
console.log(settings.theme.colors.primary); // "#0af"
console.log(Object.isFrozen(settings.langs)); // true
Reflect.ownKeys, object'in string ve symbol olan bütün kendi anahtarlarını verir; Reflect API'sini ilerleyen yazılarda detaylandıracağız. Fonksiyonun önce object'i dondurup sonra çocuklara inmesi bilinçli bir tercih: object'ler birbirini gösteren bir döngü oluşturuyorsa, isFrozen kontrolü sayesinde aynı object'e ikinci kez girmez ve sonsuz özyinelemeye düşmez.
Immutable Güncelleme Desenleri
Donmuş bir object'i değiştiremiyorsan nasıl güncelleyeceksin? Cevap: değiştirmezsin, yeni bir kopya üretirsin. Değişen dalı kopyalar, değişmeyen dalları olduğu gibi paylaşırsın:
const state = deepFreeze({
user: { name: "Ada", city: "Izmir" },
tags: ["js", "css"],
settings: { dark: true }
});
const next = {
...state,
user: { ...state.user, city: "Ankara" },
tags: [...state.tags, "html"]
};
console.log(state.user.city, next.user.city); // "Izmir" "Ankara"
console.log(state.user === next.user); // false
console.log(state.settings === next.settings); // true
Bu kodu çalıştırmadan önce yukarıdaki deepFreeze'i konsola yapıştırmış olman gerekiyor. Array'lerde daha önce gördüğümüz ES2023 method'ları işini kolaylaştırır: toSorted, toReversed, toSpliced ve with orijinale dokunmadan yeni bir array döndürür. Ağacın bir kopyası üzerinde serbestçe çalışmak istiyorsan structuredClone var; ama her güncellemede bütün ağacı kopyalamak gereksiz yere pahalıdır.
Bu desenin asıl kazancı şu: bir şeyin değişip değişmediğini anlamak için içine bakmana gerek kalmaz, referansları karşılaştırman yeter. state.user !== next.user ise kullanıcı değişmiştir; state.settings === next.settings ise ayarlar aynıdır. Geri al (undo) özelliği için eski durumları saklamak da bedavaya gelir, çünkü hiçbiri sonradan değişmez.
Sık Düşülen Tuzaklar
const'u dondurmakla karıştırmak. Daha önce gördüğümüz gibi const yalnızca değişkenin başka bir değere bağlanmasını engeller; gösterdiği object istediği kadar değişebilir.
Her şeyin dondurulabildiğini sanmak. freeze property'leri kilitler, iç durumu değil. Map, Set ve Date verilerini property'lerde değil iç alanlarda tutar:
const frozenMap = Object.freeze(new Map());
frozenMap.set("a", 1);
console.log(frozenMap.size); // 1
const frozenDate = Object.freeze(new Date(2026, 0, 1));
frozenDate.setFullYear(2000);
console.log(frozenDate.getFullYear()); // 2000
Donmuş bir prototype'ın çocukları da etkilediğini unutmak. Zincirde yazılamaz bir property varsa, alt object'e aynı isimle atama yapmak da başarısız olur. Bir önceki yazıdaki shadowing'in ikinci istisnası bu:
const basePrinter = Object.freeze({ print() { return "base"; } });
const myPrinter = Object.create(basePrinter);
myPrinter.print = () => "custom";
console.log(myPrinter.print()); // "base"
Çocukta gerçekten kendi method'unu istiyorsan atama yerine Object.defineProperty(myPrinter, "print", { value: ... }) kullanman gerekir. Son olarak defineProperty'nin varsayılanlarını unutmak: bir property'yi sadece salt okunur yapmak isterken enumerable ve configurable'ı da yazmazsan property'yi yanlışlıkla listelerden de gizlemiş olursun.
Küçük Bir Deneme
★☆☆ Çalıştırmadan önce tahmin et: const p = Object.seal({ a: 1 }); p.a = 2; p.b = 3; delete p.a; console.log(p, Object.isFrozen(p)); Sonra aynı denemeyi Object.freeze ile tekrarla.
★★☆ Bir object'e, değeri her okunduğunda bir artan ama dışarıdan atamayla değiştirilemeyen bir visits property'si ekle. İpucu: getter kullan, setter yazma ve sayacı object'in dışında bir değişkende tut. Object.keys çıktısında görünmesini de sağla.
★★★ deepFreeze'in tersini yaz: donmuş bir object'in değiştirilebilir derin kopyasını döndüren thaw(object). Bir sürümü structuredClone ile, diğerini Object.getOwnPropertyDescriptors kullanan kendi özyinelemeli fonksiyonunla yaz. Getter içeren bir object'te ikisini dene: structuredClone getter'a ne yapıyor, seninki ne yapıyor?
Property'leri kilitlemeyi ve kopyalarla çalışmayı öğrendik. Bir sonraki yazıda bir adım geri çekilip object'leri nasıl tasarladığımıza bakacağız: inheritance mı composition mı, factory function'lar, mixin'ler ve veriyi gerçekten gizlemenin yolları.