JavaScriptHard

Property flags and descriptors in JavaScript

01

The Short Answer

Every object property in JavaScript has hidden metadata called property flags (or descriptors) that control how the property behaves. There are three flags: writable (can the value be changed?), enumerable (does it show up in for...in loops?), and configurable (can the property be deleted or its flags changed?). You read them with Object.getOwnPropertyDescriptor() and set them with Object.defineProperty(). Understanding descriptors explains why some properties are read-only, why some don't appear in Object.keys(), and how freeze/seal work under the hood.

02

The Three Data Descriptor Flags

When you create a property normally (like obj.name = 'Alice'), all three flags default to true — the property is fully open. But you can lock down individual behaviors using Object.defineProperty(). This is how the language implements read-only properties, hidden properties, and permanent properties.

FlagControlsDefault (normal assignment)Default (defineProperty)
writableCan the value be reassigned?truefalse
enumerableShows in for...in, Object.keys()?truefalse
configurableCan delete or change flags?truefalse

Important default difference

Properties created with obj.x = 1 have all flags set to true. Properties created with Object.defineProperty() have all flags defaulting to false. This catches people off guard — a property defined with defineProperty is non-writable, non-enumerable, and non-configurable unless you explicitly say otherwise.

03

Reading Property Descriptors

Object.getOwnPropertyDescriptor() returns the full descriptor for a single property, including its value and all three flags. Object.getOwnPropertyDescriptors() (plural) returns descriptors for all own properties at once. These are essential for understanding why a property behaves a certain way and for copying properties with their full metadata intact.

reading-descriptors.tstypescript
const user = { name: 'Alice', age: 30 };

// Get descriptor for a single property
const nameDesc = Object.getOwnPropertyDescriptor(user, 'name');
console.log(nameDesc);
// {
//   value: 'Alice',
//   writable: true,
//   enumerable: true,
//   configurable: true
// }

// Get all descriptors at once
const allDescs = Object.getOwnPropertyDescriptors(user);
console.log(allDescs);
// {
//   name: { value: 'Alice', writable: true, enumerable: true, configurable: true },
//   age: { value: 30, writable: true, enumerable: true, configurable: true }
// }

// Built-in properties often have different flags
const arrLengthDesc = Object.getOwnPropertyDescriptor([1, 2, 3], 'length');
console.log(arrLengthDesc);
// { value: 3, writable: true, enumerable: false, configurable: false }
// Array.length is non-enumerable (doesn't show in for...in)
// and non-configurable (can't delete it)
04

Defining Properties with Flags

Object.defineProperty() lets you create or modify a property with explicit control over each flag. This is how you create read-only constants, hidden internal properties, and properties that can't be deleted. Remember that any flag you don't specify defaults to false when creating a new property this way.

define-property.tstypescript
'use strict';

const obj: Record<string, unknown> = {};

// Non-writable — read-only property
Object.defineProperty(obj, 'id', {
  value: 'abc-123',
  writable: false,
  enumerable: true,
  configurable: false,
});
obj.id = 'new-id'; // ❌ TypeError: Cannot assign to read-only property 'id'

// Non-enumerable — hidden from iteration
Object.defineProperty(obj, '_internal', {
  value: 'secret',
  writable: true,
  enumerable: false, // Won't show in Object.keys() or for...in
  configurable: true,
});
console.log(Object.keys(obj)); // ['id'] — _internal is hidden
console.log(obj._internal);    // 'secret' — still accessible directly

// Non-configurable — can't delete or change flags
Object.defineProperty(obj, 'type', {
  value: 'user',
  writable: false,
  enumerable: true,
  configurable: false, // Permanent and locked
});
delete obj.type; // ❌ TypeError: Cannot delete property 'type'
Object.defineProperty(obj, 'type', { writable: true }); // ❌ TypeError: Cannot redefine

// Define multiple properties at once
Object.defineProperties(obj, {
  firstName: { value: 'Alice', writable: true, enumerable: true, configurable: true },
  lastName: { value: 'Smith', writable: true, enumerable: true, configurable: true },
});
05

Accessor Descriptors (Getters/Setters)

Properties can also be defined as accessors — instead of storing a value directly, they use get and set functions. Accessor descriptors have get, set, enumerable, and configurable — but NOT value or writable (you can't mix data and accessor descriptors). This is how computed properties, validation on assignment, and lazy initialization work at the language level.

accessor-descriptors.tstypescript
const person: Record<string, unknown> = {
  firstName: 'Alice',
  lastName: 'Smith',
};

// Define a computed property with get/set
Object.defineProperty(person, 'fullName', {
  get() {
    return `${this.firstName} ${this.lastName}`;
  },
  set(value: string) {
    const [first, last] = value.split(' ');
    this.firstName = first;
    this.lastName = last;
  },
  enumerable: true,
  configurable: true,
});

console.log(person.fullName); // 'Alice Smith' (getter called)
person.fullName = 'Bob Jones'; // setter called
console.log(person.firstName); // 'Bob'
console.log(person.lastName);  // 'Jones'

// Validation with setter
const account: Record<string, unknown> = { _balance: 0 };
Object.defineProperty(account, 'balance', {
  get() { return this._balance; },
  set(value: number) {
    if (value < 0) throw new Error('Balance cannot be negative');
    this._balance = value;
  },
  enumerable: true,
  configurable: true,
});

account.balance = 100;  // ✅ Works
account.balance = -50;  // ❌ Error: Balance cannot be negative

// Descriptor type check
const desc = Object.getOwnPropertyDescriptor(person, 'fullName');
console.log('get' in desc); // true — it's an accessor descriptor
console.log('value' in desc); // false — no value property
06

How freeze/seal Use Descriptors

Now you can understand what Object.freeze() and Object.seal() actually do under the hood — they just modify property descriptors in bulk. seal makes every property non-configurable and prevents extensions. freeze does everything seal does plus makes every property non-writable. There's no magic — it's all property flags.

freeze-seal-internals.tstypescript
// What Object.seal() does internally:
function mySeal(obj: object) {
  Object.preventExtensions(obj); // No new properties
  Object.getOwnPropertyNames(obj).forEach((prop) => {
    Object.defineProperty(obj, prop, { configurable: false });
  });
  return obj;
}

// What Object.freeze() does internally:
function myFreeze(obj: object) {
  Object.preventExtensions(obj); // No new properties
  Object.getOwnPropertyNames(obj).forEach((prop) => {
    const desc = Object.getOwnPropertyDescriptor(obj, prop)!;
    if ('value' in desc) {
      // Data descriptor — make non-writable and non-configurable
      Object.defineProperty(obj, prop, { writable: false, configurable: false });
    } else {
      // Accessor descriptor — just make non-configurable
      Object.defineProperty(obj, prop, { configurable: false });
    }
  });
  return obj;
}

// Verify:
const obj = { x: 1 };
Object.freeze(obj);
const desc = Object.getOwnPropertyDescriptor(obj, 'x');
console.log(desc);
// { value: 1, writable: false, enumerable: true, configurable: false }
07

Practical Use Cases

Property descriptors aren't just academic — they power real patterns in libraries and frameworks. Non-enumerable properties keep internal state hidden from serialization. Non-writable properties create true constants. Getters enable lazy computation and reactive systems. Here are patterns you'll encounter in production code.

real-world-uses.tstypescript
// 1. Non-enumerable metadata (won't appear in JSON.stringify)
function addMetadata(obj: object, key: string, value: unknown) {
  Object.defineProperty(obj, key, {
    value,
    enumerable: false, // Hidden from serialization
    writable: true,
    configurable: true,
  });
}
const record = { name: 'Alice', age: 30 };
addMetadata(record, '__version', 2);
addMetadata(record, '__lastModified', Date.now());
console.log(JSON.stringify(record)); // {"name":"Alice","age":30} — metadata hidden

// 2. Lazy initialization with getter
Object.defineProperty(globalThis, 'heavyModule', {
  get() {
    const module = expensiveInit(); // Only runs on first access
    // Replace getter with the value (one-time computation)
    Object.defineProperty(this, 'heavyModule', { value: module });
    return module;
  },
  configurable: true,
  enumerable: true,
});

// 3. Copying objects with full descriptor fidelity
// Object.assign() loses non-enumerable props and accessor definitions
// Use this instead:
function cloneWithDescriptors<T extends object>(source: T): T {
  return Object.create(
    Object.getPrototypeOf(source),
    Object.getOwnPropertyDescriptors(source)
  );
}
08

Why Interviewers Ask This

Property descriptors are an advanced topic that separates developers who understand JavaScript's object model deeply from those who only use the surface API. Interviewers ask this to test whether you know the three flags and their defaults, can explain the difference between data and accessor descriptors, understand how freeze/seal work internally (they're just descriptor manipulation), know practical applications (hidden metadata, lazy init, validation), and can use defineProperty correctly. It's a hard question because most developers never interact with descriptors directly — but they underpin everything from framework reactivity to built-in object behavior.

Quick Revision Cheat Sheet

writable: Can value be changed? (false = read-only)

enumerable: Shows in for...in / Object.keys()? (false = hidden)

configurable: Can delete or change flags? (false = permanent)

Normal assignment: All flags default to true

defineProperty: All flags default to false — explicit is required

Accessor: get/set instead of value/writable — can't mix both