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
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
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
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
-
Returns
true
if adding, removing, or changing properties is forbidden, and all current properties areconfigurable: false, writable: false
.
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;
}
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
Defining getters and setters
With object literals (I prefer this over TS)
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;
}
}
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);
}
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)💡
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;
}
}
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
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;
}
}