Metaprogramming in JavaScript and TypeScript

  • Tutorial

Prologue


I want to present to your court a number of mini statues that will describe the techniques and fundamentals of metaprogramming. Basically, I will write about the use of certain techniques in JavaScript or in TypeScript.
This is the first (and I hope not the last) article in the series.


So what is metaprogramming


Metaprogramming is a programming technique in which computer programs have the ability to treat other programs as their data. It means that a program can be designed to read, generate, analyze or transform other programs, and even modify itself while running. In some cases, this allows programmers to minimize the number of lines of code to express a solution, in turn reducing development time .

A rather confusing description, but the main benefit of metaprogramming is understandable:


... this allows programmers to minimize the number of lines of code to implement the solution, which in turn reduces development time


In fact, metaprogramming has a lot of face and guise. And you can discuss for a long time about “where the metaprogramming ends and the programming itself begins”


For myself, I accepted the following rules:


  1. Metaprogramming does not deal with business logic, does not change it, and does not affect it in any way.
  2. If you remove all metaprogramming code, this should not (radically) affect the program.

In JavaScript, metaprogramming is a relatively new trend, the base brick of which is descriptor.


JavaScript Descriptor


Descriptor is a kind of description (meta information) of a certain property or method in an object.


Understanding and properly manipulating this object ( descriptor ) allows much more than just creating and changing methods or properties in objects.
Also descriptor will help in understanding the work with decorators (but more on that in the next article).


For clarity, imagine that our object is a description of the apartment.
We describe the object of our apartment:


let apt = {
    floor: 12,
    number: '12B',
    size: 3400,
    bedRooms: 3.4,
    bathRooms: 2,
    price: 400000,
    amenities: {...}
};

Let's determine which of the properties are modifiable and which are not.


For example, the floor or the total size of the apartment cannot be changed, but the number of rooms or bathrooms is quite possible.
And so we have the following requirement: in apt objects, make it impossible to change the properties: floor and size .


To solve this problem, we just need descriptors of each of these properties. To get descriptor , we use the static method getOwnPropertyDescriptor , which belongs to the class Object .


let descriptor = Object.getOwnPropertyDescriptor(todoObject, 'floor');
console.log(descriptor);
// Output
{
  value: 12,
  writable: true,
  enumerable: true,
  configurable: true
}

Let's analyze in order:
value: any - actually the same value that was assigned to the floor
writable property at a certain point : boolean - determines whether it is possible or not to change the value value
enumerable: boolean - determines if the floor property can or not be listed - (more on this later )
configurable: boolean - Defines the ability to make changes to the descriptor object .


In order to prevent the possibility of changing the floor property , after initialization, it is necessary to change the value of writable to false .
To change the properties of a descriptor, there is a static method defineProperty , which takes the object itself, the name of the property, and descriptor .


Object.defineProperty(apt, 'floor', {writable: false});

In this example, we do not pass the entire descriptor object , but only one writable property with the value false .
Now let's try to change the value in the floor property:


apt.floor = 44;
console.log(apt.floor);
// output
12

The value has not changed, and when using 'use strict' we get an error message:


Cannot assign to read only property 'floor' of object ...

And now we can no longer change the value. However, we can still return writable -> true and then change the floor property . To avoid this, it is necessary to change the value of the configurable property to false in descriptor .


Object.defineProperty(apt, 'floor', {writable: false, configurable: false});

If we now try to change the value of any of the properties of our descriptor ...


Object.defineProperty(apt, 'floor', {writable: true, configurable: true});

In response, we get:


TypeError: Cannot redefine property: floor
In other words, we can neither change the value of floor nor its descriptor anymore .

Summarize


To make the property value in the object unchanged, it is necessary to register the configuration of this property: {writable: false, configurable: false} .


This can be done as during property initialization:


Object.defineProperty(apt, 'floor', {value: 12, writable: false, configurable: false});

Or after.


Object.defineProperty(apt, 'floor', {writable: false, configurable: false});

In the end, consider an example with a class:


class Apartment {
    constructor(apt) {
        this.apt = apt;
    }
    getFloor() {
        return this.apt.floor
    }
}
let apt = {
    floor: 12,
    number: '12B',
    size: 3400,
    bedRooms: 3.4,
    bathRooms: 2,
    price: 400000,
    amenities: {...}
};

Change the getFloor method:


Apartment.prototype.getFloor = () => {
    return 44
};
let myApt = new Apartment(apt);
console.log(myApt);
// output will be changed.
44

Now change the descriptor of the getFloor () method :


Object.defineProperty(Apartment.prototype, 'getFloor', {writable: false, configurable: false});
Apartment.prototype.getFloor = () => {
    return 44
};
let myApt = new Apartment(apt);
console.log(myApt);
// output will be original.
12

I hope this article sheds a little more light on what descriptor is and how it can be used.


Everything written above does not claim to be absolutely true or the only correct one.

Also popular now: