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 startedMaking 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
}
let changedFlags = Object.defineProperty(user,”name”);
descriptor = Object.getOwnPropertyDescriptor(user, ‘name’);
{
value: “John”,
writable: false,
enumerable: false,
configurable: false
}
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”;
let user = { };
Object.defineProperty(user, “name”, {
value: “John”,
enumerable: true,
configurable: true
});
alert(user.name);
user.name = “Pete”;
💡
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;
}
};
console.log(Object.keys(users));
for (let key in user) console.log(key);
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);
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 ) );
We also can’t change Math.PI to be writable again:
Object.defineProperty(Math, “PI”, { writable: true })
let user = {
name: “John”
};
Object.defineProperty(user, “name”, {
configurable: false
});
user.name = “Pete”;
delete user.name;
💡
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 },
});
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.
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 whole object
- 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>
type ReadOnlyOuter = {
readonly inner : {
x:number;
}
}
const o : ReadOnlyOuter = {
inner : {
x:50;
}
}
o.inner = { x:5}
o.inner.x = 1; OK
Read-only for any key in an object?
const obj : { readonly [k:string] : any } = {
a:”a”
};
obj.a;
obj.b=”b”;
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];
(‘x’ | 1)[];
[‘x’,1];
[string,number];
readonly [string,number];
(string | number)[];
readonly (string | number) [];
[any,any];
any[];
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
Truely constant with TS
With the help of TS, we can make it non-editable,addable, and removeable.
const x = { x:10,y:20,z:”30″ } as const;
const a = [1,2,3];
const a = [1,2,3] as const;
type X = {readonly x:10,readonly y:20 ,readonly z:”30″ };
const x : X = { x:10,y:20,z:”30″ }
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);
const v = obj[k];
}
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”;
Solution
let k: keyof typeof obj;
for(let k in obj) {
const v = obj[k];
}
Use Mapped Types to Keep Values in Sync
interface ScatterProps {
xs: number[];
ys: number[];
xRange:[number,number];
yRange:[number,number];
onClick: (x:number,y:number,index:number)=>void;
};
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;
}
💡
Object can be used to keep related value and types synchronized
Bonus: Controlling 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 literal
const ninjaCollection = {
ninjas: [“Yoshi”, “Kuma”, “Hattori”],
get firstNinja(){
console.log(“Getting firstNinja”);
return this.ninjas[0];
},
set firstNinja(value){
console.log(“Setting firstNinja”);
this.ninjas[0] = value;
}
};
With class
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 but 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 a 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;
ninja.name = “MA”;
Use proxy to measure performance
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
Using proxies to autopopulate properties
Proxies open many gate ways for us to think about how we code
const rootFolder = new Folder();
rootFolder.ninjasDir = new Folder();
rootFolder.ninjasDir.firstNinjaDir = new Folder();
rootFolder.ninjasDir.firstNinjaDir.ninjaFile = “yoshi.txt”;
With proxy, we can simplify the process
function Folder() {
return new Proxy({}, {
get: (target, property) => {
report(“Reading ” + property);
if(!(property in target)) {
target[property] = new Folder();
}
return target[property];
}
}); }
try {
rootFolder.ninjasDir.firstNinjaDir.ninjaFile = “yoshi.txt”;
} catch {
throw new Error(“error”)
}
Using proxies to implement negative array indexes
If your programming background is from languages such as Python, Ruby, or Perl, you might be used to negative array indexes, which enable you to use negative indexes to access array items from the back
const ninjas = [“Yoshi”, “Kuma”, “Hattori”];
ninjas[0];
ninjas[1];
ninjas[2];
ninjas[-1];
ninjas[-2];
ninjas[-3];
We can build our own negative array access through proxy
function createNegativeArrayProxy(array) {
if(!Array.isArray(array)) {
throw new TypeError(‘Expected an array’);
}
return new Proxy(array, {
get (target,property,value) {
property = +property;
return target[property<0 property + target.length ? property]
}
set (target,property) {
property = +property;
return target[property<0 property + target.length ? property]
}
})
}
let numbers = [“1″,”2″,”3”];
numbers = createNegativeArrayProxy(numbers);
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
Source: hashnode.com