JavaScript Codeception: A Journey into Meta Programming

NearformNearform
7 min read

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 to false, 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 with writable: true, it is possible to modify the value and change the writable attribute from true to false.

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!

0
Subscribe to my newsletter

Read articles from Nearform directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Nearform
Nearform