Learn a little: Make metaprogramming possible in typescript class

Learn a little: Make metaprogramming possible in typescript class

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 *