Readonly and proxy in JS

Photo by Dan Gold on Unsplash

Readonly and proxy in JS

A constant is not a constant in JS

It's true that we never have the real constant in JS like in any other language out there (Java, C#, etc.), you name it.In this blog, I'll walk you through how we can make it possible in JS. Of course, we need more than a constant keyword in JS. Without further ado, let's get started

Making an object truly immutable

As we know, objects can store properties.

Until now, a property was a simple “key-value” pair to us. But an object property is actually a more flexible and powerful thing.

Property flags

Object properties, besides a value, have three special attributes (so-called "flags"):

  • Writable: if true, the value can be changed; otherwise, it's read-only

  • Enumerable: if true, then listed in loops; otherwise, not listed

  • Configurable: if true, the property can be deleted and these properties can be modified; otherwise, not

💡
When we create an object, everything is set to true

💡
If we take it to another level of thinking, JS doesn't have private fields like in other languages like Java and C#. So it's important for JS to have custom attributes for every properties
let user = {
  name: "John"
};

let descriptor = Object.getOwnPropertyDescriptor(user, 'name');

{
  value: "John",
  writable: true,
  enumerable: true,
  configurable: true
}

// To change the flag we can use
// Object.define(object,property,
// {writable:boolean,enumerable:boolean,configurable:boolean })
let changedFlags = Object.defineProperty(user,"name");
descriptor = Object.getOwnPropertyDescriptor(user, 'name');
export class AuthenticationError extends ApolloError {
  constructor(message: string, extensions?: Record<string, any>) {
    super(message, 'UNAUTHENTICATED', extensions);

    Object.defineProperty(this, 'name', { value: 'AuthenticationError' });
  }
}

Non-writable

Let’s make user.name non-writable (can’t be reassigned) by changing writable flag:

let user = {
  name: "John"
};

Object.defineProperty(user, "name", {
  writable: false
});

user.name = "Pete"; // Error: Cannot assign to read only property 'name'

// initial a value with Object.defineProperty
let user = { };

Object.defineProperty(user, "name", {
  value: "John",
  // for new properties we need to explicitly list what's true
  enumerable: true,
  configurable: true
});

alert(user.name); // John
user.name = "Pete"; // Error
💡
We can not override the value of an object but we can add another property to it

Non-enumerable

Now let’s add a custom toString to user.

Normally, a built-intoString for objects is non-enumerable; it does not show up infor..in. But if we add a toString of our own, then by default it shows upfor..in, like this:

let user = {
  name: "John",
  toString() {
    return this.name;
  }
};

// By default, both our properties are listed:
console.log(Object.keys(users)); // name, toString
for (let key in user) console.log(key); // name, toString

If we want to be more restricted and don't poison the user's keys loop we can do this

Object.defineProperty(user,toString, {
    enumerable:false
})

for(let key in users) console.log(key); // name

Non-configurable

The non-configurable flag (configurable:false) is sometimes preset for built-in objects and properties.

Please note:configurable: false prevents changes to property flags and their deletion while allowing them to change their value.

For instance, Math.PI is non-writable, non-enumerable and non-configurable

let descriptor = Object.getOwnPropertyDescriptor(Math, 'PI');

alert( JSON.stringify(descriptor, null, 2 ) );
/*
{
  "value": 3.141592653589793,
  "writable": false,
  "enumerable": false,
  "configurable": false
}
*/

We also can’t change Math.PI to be writable again:
// Error, because of configurable: false
Object.defineProperty(Math, "PI", { writable: true })
let user = {
  name: "John"
};

Object.defineProperty(user, "name", {
  configurable: false
});

user.name = "Pete"; // works fine
delete user.name; // Error
💡
Keep in mind, that it's only at the property level it can't prevent adding a new property to an object

Object.defineProperties ( with plural )

const newObj = Object.defineProperties({}, {
  name: { value: "John", writable: false },
  surname: { value: "Smith", writable: false },
  // ...
}); // we can define many properties all at one with object syntax

Cloning an object

Normally, when we clone an object, we use an assignment to copy properties, like this:

for (let key in user) {
  clone[key] = user[key]
}

But that does not copy flags. So if we want a “better” clone, then Object.defineProperties is preferred.

Object.getOwnPropertyDescriptors

let clone2 = Object.defineProperties({},
             Object.getOwnPropertyDescriptors(user));


Sealing an object

Property descriptors work at the level of individual properties.

There are also methods that limit access to the wholeobject

  • Object.preventExtensions(obj) // prevents the addition of new property

  • Object.seal(obj) // Sets configurable:false for all existing properties

  • Object.freeze(obj) // Set configuration:false,writable:false for all existing properties and also prevents adding new property to an object (truly immutable)

  • Object.isExtensible returns false if adding properties is forbidden

  • Object.isSeal returns true if adding or removing properties is forbidden

  • Object.isFrozen(obj)

    Returnstrue if adding, removing, or changing properties is forbidden, and all current properties areconfigurable: false, writable: false.

💡
Things to keep in mind are that it only protect us on a shallow level

Sealing an object with TS

interface Outer {
    inner : {
        x:number;
    }
}

type ReadOnlyOuter = Readonly<Outer>

// Same way
type ReadOnlyOuter = {
   readonly inner : {
       x:number;
    }
}

const o : ReadOnlyOuter = {
    inner : {
        x:50;
    }
}


//Can not assign "inner" because it is a read-only property
o.inner = { x:5}

//It only protect us on shadow level just like Object.freeze
o.inner.x = 1; OK

Read-only for any key in an object?

const obj : { readonly [k:string] : any } = {
    a:"a"
};
obj.a; // OK 

obj.b="b"; // Index signature in type 
// '{ readonly [k: string]: any; }' only permits reading.(2542)

Object.freeze in JS is Readonly type in TS


Type widening and const in TS

At run-time, every variable has a single value. But at static analysis time, when TS is checking our code, a variable has a set of possible values, namely, its type. When you initialize a variable and don't provide a type, the type checker needs to decide on one. This is called widening

const mixed = ['x',1];

//What is the possibilities here ?
('x' | 1)[];
['x',1];
[string,number];
readonly [string,number];
(string | number)[];
readonly (string | number) [];
[any,any];
any[];

//If you were a TS, what would you choose ?
The best guess is (string|number];
When you declare const mixed = ["x",1];
It assumes that you can put,replace,update any value in array
with the respect to string | number
const x = { x:10,y:20,z:"30" };
TS will inferthis to
{[key:string] : number | string } 
which is fine this is the most accurate it can get for you

//If it's not what you want then you can override TS's default behaviors

Truely constant with TS

With the help of TS, we can make it non-editable.

const x = { x:10,y:20,z:"30" } as const; 
//Type is { readonly x:10,y:20,z:"30" }
const a = [1,2,3]; // number[];
const a = [1,2,3] as const; //Readonly [1,2,3];
type X =  {readonly x:10,readonly y:20 ,readonly z:"30" };
const x : X = { x:10,y:20,z:"30" } 

// Or we can do this
type X = Readonly<{x:10,y:20,z:"30"}>;
const x : X = { x:10,y:20,z:"30" }

How to iterate over an object?

const obj = {
   one:"one",
   two:"two",
   three:"three"
};

for(let k in obj) {
    console.log(k); //k is a string we can not loop over 3 specific keys
    const v = obj[k];
} // Element implicitly has an 'any-type'

But why does TS make the assumption that k will be a string?

Because JS object is very flexible, we can define it and modify it
any time we want
obj.four = "four";
// So TS has no idea that we has just modified this object somewhere
// so to protect itself it has to make it key as a string

Solution

let k: keyof typeof obj;
    for(k in obj) {
    const v = obj[k]; // OK
}

Use Mapped Types to Keep Values in Sync

interface ScatterProps {
   // The data
   xs: number[];
   ys: number[];

   // Display
   xRange:[number,number];
   yRange:[number,number];

  // Events
  onClick: (x:number,y:number,index:number)=>void;
};

//Depends on ScatterProps interface
const REQUIRES_UPDATES = { [K in keyof ScatterProps]:boolean } = {
    xs:true,
    ys:true,
    xRange:true,
    yRange:true,
    onClick:false
}

function shouldUpdate(oldProps:ScatterProps, newProps:ScatterProps){
  let k: keyof ScatterProps;
  for(key in oldProps) {
    if(oldProps[key] !== newProps[key] && REQUIRES_UPDATES[key]) {
         return true;
    }
  }
  return false;
}

Inheritance for mapping

type Person = {
    id:number,
    name:string;
}

type Student = {
    [K in keyof Person]:Person[K]
} & {
    grade: number;
}
💡
Object can be used to keep related value and types synchronized

Control and manage access to objects

A JS object is a dynamic collection of properties. We can easily add a new property and remove an old one without much effort. In many situations, that could be a bad thing since we often deal with validated data first before showing it to our users. And luckily, JS has also provided us with a way to control access and manage all the changes that occur in our objects through getters and setters

💡
With Typescript, this is not the case since everything is so strict but luckily we don't need TS to achieve that

Defining getters and setters

💡
get and set are special keywords in JavaScript similar to functions.

With object literals (I prefer this over TS)

💡
Mistake to avoid RangeError: Maximum call stack size exceeded at set name (:5:23)
const person = {
    _name = "",
    set name(name) {
         if (typeof name === "string") {
            this._name = name;
         } else {
            throw new TypeError("wrong type");
         }
     }
    get name() {
       return this._name;
    }
}

With class

Encapsulation

class Ninja {
    constructor(){
       this.ninja = ["Yoshi","Kuma","Hattori"];
    }
    get firstNinja() {
       return this.ninja[0];
    }
    set firstNinja(name) {
        this.ninja[0]=name;
    }
}
💡
Another good thing about a proxy is that it can hide the implementation of a property. For example, when you access an object, a property inside the proxy is already swapped to another value and users would not know

Validation

set skillLevel(value) {
     if(!Number.isInteger(value)){
       throw new TypeError("Skill level should be a number");
     }
     _skillLevel = value;
}

const ninja = new Ninja();

try {
 ninja.skillLevel = "great"
} catch (err) {
  console.log(error);
}
💡
This is how you avoid all those silly little bugs that happen when the value of the wrong type ends up in a certain property. Sure, it adds overhead, but that’s a price that we sometimes have to pay to safely use a highly dynamic language such as JavaScript. Maybe we could use Typescript to solve it but TS is static compile time, and if we write poor TS, the bugs can lead into production

Using getters and setters to define computed properties

const shogun = {
 name: "Yoshiaki",
 clan: "Ashikaga",
 get fullTitle(){
     return this.name + " " + this.clan;
 },
 set fullTitle(value) {
     const segments = value.split(" ");
     this.name = segments[0];
     this.clan = segments[1];
 }
};

Using proxy to control access (more flexible key)💡

💡
With set and get, we only control access to a single property. But proxies are even more powerful; it enable us to handle all interactions with an object, including method calls
const emperor = { name"Koma" };
const representative = new Proxy(emperor, {
    get:(target,key,value)=> {
       return key in target ? target[key]
                : "Don't bother the emperor!"
    }
    set:(target,key,value)=> {
        target[key] = value;
    }
}

💡
Proxy is just an object with two functions: get and set and we can re-use them with any other object we want

Use proxies for logging

One of the most powerful tools when trying to figure out how code works or when trying to get to the root of nasty bug is logging

💡
Proxies allows us to log into the code without intercept the current implementation
function makeLoggable(target) {
    return new Proxy(target, {
        get(target,property) {
            console.log("Reading",property);
            return target[property];
        },
        set(target,property,value) {
            console.log("Writing value" + value + "to " + property);
            target[property] = value;
        }
   })
}

let ninja = { name:"Vince" };
ninja = makeLoggable(ninja);
ninja.name; // Reading name 
ninja.name = "MA"; // Writing value MA to name;

Use proxy to measure performance (on a function)

Besides being used for logging property accesses, proxies can also be used for measuring the performance of function invocations

function isPrime(number){
  if(number < 2) { return false; }
  for(let i = 2; i < number; i++) {
     if(number % i === 0) { return false; }
}
  return true;
}

isPrime = new Proxy(isPrime, {
   apply:(target,thisArg,args) {
        console.time("isPrime");
        const result = target.apply(thisArg,args);
        console.timeEnd("isPrime");
        return result;
   }
}
💡
Again , without modifying the code, we can easily measure the performance of the function

Proxy for reactive programming

Trade off with proxy

💡
Although proxy is a cool feature and a nice supplement to JS features, it adds another layer of complexity in our code and the fact that all of the operations to objects have to go through proxy, which can cause performance issues with long-running process