Dealing with plain objects (or the result of JSON.parse) is a fundamental part of web development. In order to make the development experience bearable, we often shape plain objects into something predictable, including transforming their values into something more powerful than primitive types.
There are several approaches we can take. One is factory functions, which require you to define the transform function, plus an interface with its type definition. Another, classes, which are self-contained in the sense of functionality and type. I’d like to make a case for the latter since classes don’t get much love these days.
We will explore what it would take to stick plain objects into classes while allowing us the flexibility we need when working with our custom data types and providing additional functionality (getters/setters/methods).
The Simple Solution
To start, we’ll define a plain class:
class Person {
name: string
lastName: string
constructor(payload: Partial<Person>){
this.name = payload.name || ''
this.lastName = payload.lastName || ''
}
}
Which we can then use like this:
new Person({ name: 'Alice', lastName: 'Liddell' });
// and since the payload can be a "Partial" Person
new Person({ name: 'Alice' });
But we want more. We want to be able to construct these dynamically at runtime, from incoming values, without having to go the constructor of each class to set up each property.
An alternative would be to programmatically assign the values to each property from the payload. A common pattern for this is the following:
class Person {
name: string = '';
lastName: string = '';
constructor(payload: Partial<Person>){
for(const key in payload){
if(this.hasOwnProperty(key)){
this[key] = payload[key];
}
}
}
}
It’s pretty straightforward: We call this.hasOwnProperty
to make sure we set values only for properties belonging to this class.
This is good and all, but it will only be useful when we want our class to only contain primitive types. Aside from that is the fact that we’d need to repeat the same constructor in every class.
Let’s see a more practical class definition:
import { DateTime } from 'luxon'
import { Decimal } from 'decimal.js'
class Address {
no: string = ''
street: string = ''
city: string = ''
}
class Person {
name: string = ''
lastName: string = ''
dob: DateTime = DateTime.local()
address: Address = new Address()
netWorth: Decimal = new Decimal(0)
}
This is closer to what we’d have in a typical application. Custom data types like our own Address
class, Luxon’s DateTime, or decimal.js’ Decimal.
The JavaScript Type Issue
In JavaScript, there is currently no native way to find out what type properties are and instantiate them at runtime — the reason being that types don’t really exist in JavaScript. TypeScript types are syntactic sugar for development enjoyment.
The tools for runtime type inspection in JavaScript are:
-
typeof
, which only works for primitive types -
instanceof
, which is only useful if you already know the class or constructor function you want to check against
Give Way to reflect-metadata
Reflection is a common tool at the disposal of traditional OOP languages like Java and C#, and also languages like Go, Python, and Lua. In a nutshell, it’s a provided API that allows you to inspect a class or property at runtime, and get its type. This allows you to, among other things, create new instances from it.
The reflect-metadata proposal in JavaScript is not yet part of TC39, but it was authored by the person responsible for implementing Reflection in C#, so it’s safe to say that it will get there eventually.
Even though reflect-metadata
is experimental, it has been heavily used by Angular 2 for many years now. Angular depends on it for its dependency injection mechanism, that is, creating and passing along resources when needed, at runtime.
It is like a key-value store that can only reside in classes or class properties. We can grab it and use it to auto-populate type metadata or manage our own custom metadata so that we can achieve all of our goals and dreams.
Let’s Code a More Dynamic Approach
After installing:
npm install reflect-metadata
'
And importing it at the beginning of our file:
import 'reflect-metadata'
We need to make sure our tsconfig.json contains the following:
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
This will allow us to use decorators to trigger reflect-metadata to auto-populate the design:type
metadata key in our decorated properties.
We will also need a few types:
type Constructor<T = any> = { new(...args: any[]): T }
type Indexable = { [key: string]: any }
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[P]>
}
Constructor: Is used to represent constructor functions or classes, on which we can call new
Indexable: Is used to define indexable classes or objects on which you can do object[key]
DeepPartial: Is used to allow us to specify partial representations of objects or classes, as the provided Partial TypeScript utility only works for one level of depth
Now on to the decorators:
function Primed(target: any, propertyKey: string) {}
function Model<T extends Constructor>(constructor: T){
return class extends constructor {
constructor(...args: any[]){
super()
this.init(args[0])
}
}
}
Primed: It doesn’t have a body but will be used to trigger Reflect.metadata
to be added on the decorated property, as reflect-metadata only auto-populates the design:type
metadata for properties that are decorated
Model: It will be used to override the decorated class’s constructor so that we can call our custom initialization method implicitly
We will create a Base
class that will take care of initializing our properties, creating new instances when required:
class Base<T> {
constructor(payload: DeepPartial<T>){}
private init(payload: any){
for(const key in payload){
if(this.hasOwnProperty(key)){
const factory: Constructor = Reflect.getMetadata('design:type', this, key)
(this as Indexable)[key] = factory ? new factory(payload[key]) : payload[key]
}
}
}
}
You’ll notice we added a different method for initializing our instances, and our constructor is empty.
This is because we want to initialize A from within B so that we don’t have to copy the constructor to every class. If A extends B and A contains properties which have a default value, you can’t set A’s properties from within B’s constructor, as they will be overridden by A’s default values:
class A{
constructor(){
this.foo = "bar"
}
}
class A extends B {
foo = null
}
console.log(new A())
// Output: A { foo: null }
And that’s why we have an init
method. We’re making sure A gets fully initialized before setting properties in it.
So inside the init
method, we call:
Reflect.getMetadata('design:type', this, key)
to get the metadata that was auto-populated for that key in the instance, which will contain the value associated with the type assigned to the decorated property. If it exists, we create a new instance with new, passing the value into it.
Using our earlier example, the classes will now look like this:
import { DateTime } from 'luxon'
import { Decimal } from 'decimal.js'
@Model
class Address extends Base<Address> {
no: string = ''
street: string = ''
city: string = ''
}
@Model
class Person extends Base<Person> {
name: string = ''
lastName: string = ''
@Primed
dob: DateTime = DateTime.local()
@Primed
address: Address = new Address()
@Primed
netWorth: Decimal = new Decimal(0)
}
There’s a little problem with this. We’d get a type error if tried to do the following:
const person = new Person({
name: 'Alice',
lastName: 'Liddell',
dob: '1852-05-04T12:00:00.000Z',
address: {
street: 'East 74th Street',
city: 'Manhattan'
},
netWorth: 99
})
That’s because we want to pass a string
into our dob
field and a number
into our netWorth
field, and our Person class is expecting a DateTime
and a Decimal
respectively. What we can do is modify our Base
class to accept an optional second type, which we can use to create a new union type between it and the target class’s type.
This is what that would look like:
type BaseConstructorPayload<T, U = undefined> = DeepPartial<U extends undefined ? T : T | U>
class Base<T, U = undefined> {
constructor(payload: BaseConstructorPayload<T, U>){}
...
}
Which we can then use like so:
interface PersonInput {
dob: string
netWorth: number
}
@Model
class Person extends Base<Person, PersonInput> {
...
}
Dealing With Arrays and Other Custom Types
We’re almost there, but we still have a couple of problems:
reflect-metadata
doesn’t populatedesign:type
on arrays properly. It sets them to Array instead of the expected type.Not all of our custom data types will be created/initialized the same. With Luxon’s
DateTime
, we’d want to initialize it withDateTime.fromISO
. We’d want the flexibility with other types as well.
To address these, we need to be able to customize the way we specify what type something is when needed, and for that, we will introduce a new metadata key.
We’ll allow the Primed
decorator to accept an optional parameter, which will be a class or function. We will then save that into the CONSTRUCTOR_META
key with Reflect.defineMetadata
:
const CONSTRUCTOR_META = Symbol('CONSTRUCTOR_META')
export function Primed(constructor?: Constructor) {
return (instance: any, propertyKey: string) => {
if(constructor)
Reflect.defineMetadata(CONSTRUCTOR_META, constructor, instance, propertyKey)
}
}
To deal with Luxon’s DateTime
and other custom types that might be created different ways, we’ll check for them and initialize them manually through a new private function parseValue
.
To deal with arrays, we’ll check the design:type
metadata to know if we need to iterate.
We will be getting our new metadata under CONSTRUCTOR_META
, which will take precedence over design:type
:
export class Base<T, U = undefined> {
constructor(payload: BaseConstructorPayload<T, U>){}
private init(payload: any){
for(const key in payload){
if(this.hasOwnProperty(key)){
const designType: Constructor = Reflect.getMetadata("design:type", this, key)
const constructorMeta: Constructor = Reflect.getMetadata(CONSTRUCTOR_META, this, key)
const factory = constructorMeta || designType
const isArray = designType === Array
const value = isArray ? payload[key].map(v => this.parseValue(v, factory)) : this.parseValue(payload[key], factory)
;(this as Indexable)[key] = value
}
}
}
private parseValue(value: any, factory: Constructor){
if(factory){
if(factory === DateTime)
return DateTime.fromISO(value)
else if(factory === Decimal)
return new Decimal(value)
else if(factory.prototype instanceof Base.constructor)
return new factory(value)
}
return value
}
}
Finally, after making addresses
an array, this will be our class definition and usage:
interface PersonInput {
dob: string
netWorth: number
}
@Model
class Person extends Base<Person, PersonInput> {
name: string = ''
lastName: string = ''
@Primed()
dob: DateTime = DateTime.local()
@Primed(Address)
addresses: Address[] = []
@Primed()
netWorth: Decimal = new Decimal(0)
}
const person = new Person({
name: 'Alice',
lastName: 'Liddell',
dob: '1852-05-04T12:00:00.000Z',
address: [{
street: 'East 74th Street',
city: 'Manhattan'
}],
netWorth: 99
})
Wrap Up
It’s safe to say that with factory functions you save yourself the initial setup at the cost of having to repeat yourself. You’d need to create both a factory function and an interface with its type definition.
You could do both, or (after overcoming a handful of obstacles) you could just do a class.
You can play around the code in this codesandbox.
There are a couple of caveats when using reflect-metadata
to auto-populate type metadata: It doesn’t handle self-references or circular references.
I actually made a simple package, primed-model, that solves these problems, providing the outlined decorators and base class. It also provides a clone
method, for preventing side effects when passing the class instance around. Check it out if you’re interested!
You can also check out class-transformer if you want to see a different, on-steroids take on this task.
Let’s show classes some love!
That’s all, and thanks for reading.
Top comments (2)
Nice article, it shows why node/ts should not be use with OOP.
Dude, this article is a gem, although I didn't understand most of it
Thank you!