Table of contents
By Marco Ippolito
Learning about meta programming is essential for becoming a better and more creative coder
Meta programming is a fascinating and powerful concept that goes beyond the traditional boundaries of programming. It empowers developers to write code that not only manipulates data and performs computations but also introspects and modifies the structure and behavior of the code itself. In essence, it’s like programming the programmer’s tools, allowing you to write code that writes code or dynamically adapts to different situations at runtime.
Whether you’re an experienced JavaScript developer or a curious beginner, learning about meta programming is essential for becoming a better and more creative coder. This blog post is part 1 of a series of meta programming articles that will enhance your coding abilities.
Let’s dive in and see how meta programming can change the way we write and think about JavaScript code.
We encounter meta programming all the time
In our daily lives, we encounter meta programming without even realizing it. I stumbled upon it during a specific situation, it all started when I attempted to JSON.stringify
an instance of the Error
class from a try-catch block, only to be greeted by an unhelpful “{}” empty object.
try {
throw new Error('hello');
} catch (error) {
console.log(error.message); // prints hello
console.log(JSON.stringify(error)); // prints {}
}
To resolve the issue, I implemented a workaround by specifically invoking the stack
and message
properties:
try {
throw new Error('hello');
} catch (error) {
console.log({
stack: error.stack, // prints 'Error: hello\n',
message: error.message, // prints 'hello'
});
}
We are aware that the properties stack
and message
are defined and contain values, so why can’t we utilise JSON.stringify
?JavaScript provides a handy global static method known as Object.getOwnPropertyDescriptors,
this function retrieves an object containing all property descriptors, including valuable information like property values and additional metadata.
try {
throw new Error("hello");
} catch (error) {
console.log(Object.getOwnPropertyDescriptors(error));
}
// {
// stack: {
// value: "Error: hello\n",
// writable: true,
// enumerable: false,
// configurable: true,
// },
// message: {
// value: "hello",
// writable: true,
// enumerable: false,
// configurable: true,
// },
// };
We notice that the properties stack
and message
have the field enumerable
set to false.
Quoting MDN documentation on Object.getOwnPropertyDescriptors
:
“If true this property shows up during enumeration of the properties on the corresponding object.”
Given that the JSON.stringify
function employs an iteration mechanism to generate the resulting string, non-enumerable properties will be excluded from the output.
I assume this behavior is intentional to prevent inadvertent leakage of sensitive information, such as stack traces, when serializing the object, requiring developers to explicitly handle such scenarios.
If desired, we could modify the behavior of the message
property for the Error
class, by changing the object’s property enumerable
to true using JavaScript Reflect API.
With it, we can dynamically control property access, modification, and introspection. This means we can define properties, set their attributes, and even intercept property access and modification.
try {
throw new Error('hello');
} catch (error) {
Reflect.defineProperty(error, 'message', { enumerable: true })
console.log(JSON.stringify(error));
}
// prints {"message":"hello"}
Alternatively it is possible to use the Object.defineProperty
API, which functions similarly to the Reflect
API in terms of manipulating object properties. However, there’s a key distinction between them: when using Object.defineProperty
, if an error occurs during property manipulation, it throws an error, potentially disrupting the code flow. On the other hand, the Reflect
API offers a more graceful approach by returning a boolean value to indicate success or failure, allowing for smoother error handling and control. Developers can choose between these two APIs based on their preference and error-handling requirements.
Arrays are another fascinating example of object to explore with Object.getOwnPropertyDescriptors
.
const arr = [];
console.log(Object.getOwnPropertyDescriptors(arr));
// {
// length: { value: 0, writable: true, enumerable: false, configurable: false }
// }
The only property of an empty Array is length
and similarly to the message
property of the Error
class has the attribute enumerable
set to false.
Indeed, it’s important to note that the writable
attribute for the length
property of arrays is set to true which means we are allowed to change the value.
const arr = [];
arr.length = 10; // this is the same as: new Array(10)
console.log(Object.getOwnPropertyDescriptors(arr));
// {
// length: { value: 10, writable: true, enumerable: false, configurable: false }
// }
The array is still empty but we have changed the value of the property length
.
Now let’s add an element to the array:
const arr = [];
arr.length = 10;
arr.push(1);
console.log(Object.getOwnPropertyDescriptors(arr));
// {
// '10': { value: 1, writable: true, enumerable: true, configurable: true },
// length: { value: 11, writable: true, enumerable: false, configurable: false }
// }
When you inspect the property descriptors using Object.getOwnPropertyDescriptors(arr)
, you can see that the “10” property (representing the element at index 10) has been added to the array with a value of 1
, and the length
property has automatically increased to 11
to reflect the new total number of elements in the array.
What if we tried to change the behavior of the length
property and set the attribute writable
to false?
const arr = [];
arr.length = 10;
Reflect.defineProperty(arr, 'length', { writable: false });
arr.length = 100;
console.log(Object.getOwnPropertyDescriptors(arr));
// {
// length: {
// value: 10,
// writable: false,
// enumerable: false,
// configurable: false
// }
// }
The value of length
did not change after we reassigned it!
This will clearly disrupt the array’s functionality because if we attempt to push an element onto it, it will throw this error.
TypeError: Cannot assign to read only property 'length' of object '[object Array]'
at Array.push (<anonymous>)
Now, let’s attempt something even more audacious: trying to delete the length
property:
const arr = [];
delete arr.length;
console.log(Object.getOwnPropertyDescriptors(arr));
// {
// length: { value: 0, writable: true, enumerable: false, configurable: false }
// }
Wait?! It didn’t work?!
This is because configurable
is set to false.
The Mozilla documentation says:
If the descriptor had its
configurable
attribute set tofalse
, the property is said to be non-configurable. It is not possible to change any attribute of a non-configurable accessor property, and it is not possible to switch between data and accessor property types. For data properties withwritable: true
, it is possible to modify the value and change thewritable
attribute fromtrue
tofalse
.
In short, we are allowed to change writable
from true to false but we are not allowed to delete the property or change other attributes like enumerable
or configurable
itself.
If we tried to set enumerable
to true:
const arr = [];
delete arr.length;
Reflect.defineProperty(arr, 'length', { enumerable: true });
console.log(Object.getOwnPropertyDescriptors(arr));
// {
// length: { value: 0, writable: true, enumerable: false, configurable: false }
// }
Nothing has changed.
The last two property descriptors we’re going to cover today are get
and set
.In JavaScript, you have the flexibility to define object properties in two distinct styles, each serving different purposes:
1. Object literal style:
const user = {
username: 'Satanacchio'
}
console.log(user.username) // prints Satanacchio
console.log(Object.getOwnPropertyDescriptors(user));
// {
// username: {
// value: 'Satanacchio',
// writable: true,
// enumerable: true,
// configurable: true
// }
// }
In this style, you create an object literal with a property username
directly assigned to a value. This results in a simple data property with attributes like writable
, enumerable
, and configurable
set to true
by default.
2. Getter and setter style:
const user = {
get username(){
return 'Satanacchio'
}
}
console.log(user.username) // prints Satanacchio
console.log(Object.getOwnPropertyDescriptors(user));
// {
// username: {
// get: [Function: get username],
// set: undefined,
// enumerable: true,
// configurable: true
// }
// }
In this style, you define a getter method using the get
keyword. This creates a property username
with a getter function, allowing you to compute and return values dynamically when accessing the property. The descriptor includes a get
key to indicate the presence of a getter function.
Notice how the property descriptor value
in this case is replaced by get
and set
and the descriptor configurable
is not longer present.
Conclusion: you have the tools to understand some of the puzzling things you encounter in JavaScript
As we come to the end of our journey, we’ve taken a close look at how property descriptors work in JavaScript. We’ve seen how they control the way objects behave, from things like defining how properties are accessed to deciding whether they can be changed, property descriptors are the behind-the-scenes rules that make JavaScript work the way it does.
By understanding property descriptors, we’ve unraveled some of the mysteries behind how JavaScript objects behave. We’ve explained why objects and functions sometimes act in specific ways that might seem a bit strange at first.
So, the next time you encounter something in JavaScript that seems puzzling, remember that you now have the tools to understand why, all thanks to your grasp of property descriptors.
Happy coding!