Forbidden Typescript: Using Object.create to clone
In “Javascript: The Definitive Guide” there is an example that uses Object.inherit
to inherit the prototype change. JavaScript defines a method Object.create
that creates a new object using the given argument as the prototype of that object. Translating the examples from The Definitive Guide to Typescript, it looks like:
function inherit<T extends object>(obj: T): T {
return Object.create(obj);
}
class MyObject {
public a: number;
constructor(a: number) {
this.a = a;
}
}
const myObject = new MyObject(123);
const myClone = inherit(myObject);
However, we lied. Notice that in the above code, the myClone
object was created without a
being initialized.
console.log(myObject); // { a: 123 }
console.log(myClone); // {}
console.log(myClone instanceof MyObject); // true
This creates what is known as a type-hole: the Typescript compiler will not report any bugs when we try to use myClone.a
. That’s because we used Object.create
which returns the any
type. In the above example, if we tried to use myClone.a
in a case where we expected a number
, but got undefined
, we can end up with runtime bugs that should have been caught by the compiler.
We can make the typings a little more clear by doing the following:
function inherit<T extends object>(obj: T): Partial<T> {
return Object.create(obj);
}
Now Typescript will report that the value of myClone.a
might be undefined.
Let’s improve this a bit more and create an object that inherits and freezes the data in the given object:
function inheritAndFreeze<T extends object>(obj: T, values: Partial<T>): Readonly<Partial<T>> {
const properties: PropertyDescriptorMap = {};
Object.keys(obj).forEach((unsafeKey) => {
const key = unsafeKey as keyof T;
properties[key] = {
value: values[key] ?? undefined,
writable: false,
configurable: false,
};
})
return Object.create(obj, properties);
}
class MyObject {
public a: number;
constructor(a: number) {
this.a = a;
}
}
const myObject = new MyObject(123);
const myClone = inheritAndFreeze(myObject, { a: 123 });
console.log(myClone); // { a: 123 }
myClone.a = 999; // Would throw "Cannot assign to read only property 'a' of object '#<MyObject>' "
If we didn’t mark the return value as Readonly<Partial<T>>
, and instead just had Partial<T>
we would once again have a type-hole and myClone.a = 999;
would be allowed by Typescript, but would throw an exception at runtime because property a is read only as a runtime constraint.
The Takeaway
Let’s step back and really look at what inherit
was doing. All it gave us was a template object, and if you read on MDN about Inheritance and prototype chaining:
You may also see some legacy code using
[Object.create()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create)
to build the inheritance chain. However, because this reassigns theprototype
property and removes the[constructor](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/constructor)
property, it can be more error-prone, while performance gains may not be apparent if the constructors haven't created any instances yet.
The key here is to be wary of using utility functions built into the language that return the any
type. Be careful what the Typescript constraints are and make sure you don’t have any type-holes when you are using these generic functions.