So what is Metaprogramming?
It's the ability of a program to read, analyze, and change its form dynamically. A program that can write a program. It's Magic in programming, and you are the wizard!
The Challenge
It won't be much of a hassle to do metaprogramming in a dynamically typed language like javascript, but it's a different story if you use typescript.
Typescript nature forces you to declare the types of an object/class/function. For instance, this Bird class:
class Bird {
name: string
color: string
constructor (name: string, color: string) {
this.name = name
this.color = color
}
}
It's pretty straightforward. Probably we don't need to do metaprogramming at all. So, let's make this a little more interesting.
In a real-world application, especially if you are building a complex web app. There would be a case when you need to create a Model for a large object like this.
[
{
name: 'Mr Pelican',
race: 'Pelicanus occidentalis californicus',
wing_feather_color: 'black',
body_feather_color: 'white',
beak_color: 'yellow',
breeds_location: 'California (Channel Islands)',
age: 1,
// and 20 more attributes....
}
]
With typescript, we'll probably end up with a class like this...
class Bird {
name: string
race: string
wing_feather_color: string
body_feather_color: string
beak_color: string
breeds_location: string
age: number
// and 20 more attributes....
constructor (bird: Bird) {
this.name = bird.name
this.race = bird.race
this.wing_feather_color = bird.wing_feather_color
this.body_feather_color = bird.body_feather_color
this.beak_color = bird.beak_color
this.breeds_location = bird.breeds_location
this.age = bird.age
// and 20 more attributes....
}
}
Let's imagine we are going to do this for the other 20 objects in the project. It would be a hassle!
Also, what if you want to expose those variables as read-only attributes? Would you do that manually one by one? Probably not, right?
This is when metaprogramming can be handy. It will help us to create this kind of model easily. Before digging into that, let's think of an alternative way to solve this problem.
The Alternative
When faced with a large object like this, you might think to move it into an object attribute instead.
interface Bird {
name: string
race: string
wing_feather_color: string
body_feather_color: string
beak_color: string
breeds_location: string
age: number
// and 20 more attributes....
}
class BirdModel {
bird: Bird
constructor (bird: Bird) {
this.bird = bird
}
}
It's indeed working, but it doesn't seem to be an elegant solution. Let's see how we would use the instance object after adding some another attribute and function.
class BirdModel {
bird: Bird
private flying: boolean
constructor (bird: Bird) {
this.bird = bird
this.flying = false
}
get isFlying () {
return this.flying
}
fly () {
this.flying = true
}
}
const littleBird = new BirdModel({ ... })
littleBird.fly()
if (littleBird.isFlying) {
console.log(`${littleBird.bird.name} is flying`)
}
It feels weird.
When creating an instance from Bird class, we're expecting it to be Bird
itself, not as a wrapper of a Bird
. Since we are going to add other attributes or methods that belong to the Bird
, not the wrapper. It feels more natural in Object-Oriented Programming to set those as the first-level attributes.
Another gotcha, updating the littleBird.bird.name
directly will also create a reference problem in javascript.
const data: Bird = {
name: 'Mr Pelican',
race: 'Pelicanus occidentalis californicus',
wing_feather_color: 'black',
body_feather_color: 'white',
beak_color: 'yellow',
breeds_location: 'California (Channel Islands)',
age: 1
}
const littleBird = new BirdModel(data)
const littleBird2 = new BirdModel(data)
littleBird2.bird.name = 'Ms Pelican'
console.log('First Bird: ', littleBird.bird.name) // First Bird: Ms Pelican
console.log('Second Bird: ', littleBird2.bird.name) // First Bird: Ms Pelican
That is something that we want to avoid whenever possible. So let's forget this and go back to the first solution.
The Problem
Let's start by replacing those attribute assignments with this code.
class Bird {
// ...
constructor (bird: Bird) {
// delegate all attributes to as getters
Object.keys(bird).forEach((attributeName) => {
Object.defineProperty(this, attributeName, {
value: bird[attributeName as keyof Bird],
writable: false
})
})
}
// ...
Now It will dynamically assign the given object as the Bird class read-only attributes.
Unfortunately, since we are using typescript this is what will happen,
class Bird {
name: string // Property 'name' has no initializer and is not definitely assigned in the constructor (ts)
race: string // Property 'race' has no initializer and is not definitely assigned in the constructor (ts)
wing_feather_color: string // Property 'wing_feather_color' has no initializer and is not definitely assigned in the constructor (ts)
body_feather_color: string // Property 'body_feather_color' has no initializer and is not definitely assigned in the constructor (ts)
beak_color: string // Property 'beak_color' has no initializer and is not definitely assigned in the constructor (ts)
breeds_location: string // Property 'breeds_location' has no initializer and is not definitely assigned in the constructor (ts)
age: number // Property 'age' has no initializer and is not definitely assigned in the constructor (ts)
constructor (bird: Bird) {
// delegate all attributes to as getters
Object.keys(bird).forEach((attributeName) => {
Object.defineProperty(this, attributeName, {
value: bird[attributeName as keyof Bird],
writable: false
})
})
}
}
By default, typescript will show this error because we haven't defined those attributes in the constructor.
Pulling out the types to an interface, and implementing it won't help either.
interface Bird {
name: string
race: string
wing_feather_color: string
body_feather_color: string
beak_color: string
breeds_location: string
age: number
}
// Class 'BirdModel' incorrectly implements interface 'Bird'.
// Type 'BirdModel' is missing the following properties from type 'Bird': name, race, wing_feather_color, body_feather_color, and 3 more.ts(2420)
class BirdModel implements Bird {
constructor (bird: Bird) {
// delegate all attributes to as getters
Object.keys(bird).forEach((attributeName) => {
Object.defineProperty(this, attributeName, {
value: bird[attributeName as keyof Bird],
writable: false
})
})
}
}
If we decide to remove those attribute's type declarations, we won't be able to access them from the instance object.
const mrPelican: InstanceType<typeof Bird> = {
name: 'Mr Pelican',
race: 'Pelicanus occidentalis californicus',
wing_feather_color: 'black',
body_feather_color: 'white',
beak_color: 'yellow',
breeds_location: 'California (Channel Islands)',
age: 1
}
const littleBird = new Bird(mrPelican)
console.log('name', littleBird.name) // Property 'name' does not exist on type 'Bird'. ts(2339)
The Solution
So, what we have to do instead, is to leverage the typescript's declaration merging.
interface Bird {
name: string
race: string
wing_feather_color: string
body_feather_color: string
beak_color: string
breeds_location: string
age: number
}
interface BirdModel extends Bird {}
class BirdModel {
constructor (bird: Bird) {
// delegate all attributes to as getters
Object.keys(bird).forEach((attributeName) => {
Object.defineProperty(this, attributeName, {
value: bird[attributeName as keyof Bird],
writable: false
})
})
}
}
const mrPelican: Bird = {
name: 'Mr Pelican',
race: 'Pelicanus occidentalis californicus',
wing_feather_color: 'black',
body_feather_color: 'white',
beak_color: 'yellow',
breeds_location: 'California (Channel Islands)',
age: 1
}
const littleBird = new BirdModel(mrPelican)
console.log('name: ', littleBird.name) // name: Mr Pelican
console.log('name: ', littleBird.wing_feather_color) // name: Mr Pelican
Notice the interface and class are using the same name. This way, typescript won't force you to declare the types in the BirdModel
. Instead, it will assume the class has the same attributes as the interface.
To make it reusable, we could pull that into a BaseModel
a class like this.
/**
* Delegate/expose all attributes of the given model parameter,
* so we don't have to assign the attributes manually
*/
interface BaseModel<T extends Object> {
[x: `has_${string}`]: boolean
}
class BaseModel<T>{
constructor (model: T) {
// delegate all attributes to as getters
Object.keys(model).forEach((attributeName) => {
const value = model[attributeName as keyof T]
Object.defineProperty(this, attributeName, {
value: value,
writable: false
})
Object.defineProperty(this, `has_${attributeName}`, {
value: typeof value !== 'undefined' || value !== null,
writable: false
})
})
}
}
// Bird
interface Bird {
name: string
race: string
wing_feather_color: string
body_feather_color: string
beak_color: string
breeds_location: string
age: number
}
interface BirdModel extends Bird {}
class BirdModel extends BaseModel<Bird> {
flying?: Boolean
constructor (bird: Bird) {
super(bird)
this.flying = false
}
get isOld () {
return this.age >= 30
}
get isFlying () {
return this.flying
}
fly () {
this.flying = true
}
}
const mrPelican: Bird = {
name: 'Mr Pelican',
race: 'Pelicanus occidentalis californicus',
wing_feather_color: 'black',
body_feather_color: 'white',
beak_color: 'yellow',
breeds_location: 'California (Channel Islands)',
age: 1
}
const littleBird = new BirdModel(mrPelican)
console.log('bird name: ', littleBird.name) // bird name: Mr Pelican
console.log('bird wings color: ', littleBird.wing_feather_color) // bird wings color: black
console.log('bird has name: ', littleBird.has_age) // bird has name: true
// Dog
interface Dog {
name: string
race: string
}
interface DogModel extends Dog {}
class DogModel extends BaseModel<Dog> {
bark () {}
}
const mrDog = {
name: 'Mr Dog',
race: 'Dalmation',
gender: 'male' // add unregistered attribute
}
const littleDog = new DogModel(mrDog)
console.log('dog name: ', littleDog.name) // dog name: Mr Dog
console.log('dog has name: ', littleDog.has_name) // dog has name: true
// console.log('dog has name: ', littleDog.gender) // uncomment to fail. Property 'gender' does not exist on type 'DogModel'
Any thoughts? Please let me know by leaving a comment below!
Thanks!
*Credits: Photo by Kadin Hatch on Unsplash *