Learn a little: Make metaprogramming possible in typescript class

I'm currently building software for healthcare agencies.
Search for a command to run...

I'm currently building software for healthcare agencies.
The code called "The Solution" doesn't even pass typechecking for me.
Am I missing something?
sample.ts(11,11): error TS2310: Type 'BirdModel' recursively references itself as a base type. sample.ts(35,24): error TS2693: 'Bird' only refers to a type, but is being used as a value here. /private/tmp/sample.js:23 var littleBird = new Bird(mrPelican); ^
ReferenceError: Bird is not defined at Object.<anonymous> (/private/tmp/sample.js:23:18) at Module._compile (node:internal/modules/cjs/loader:1241:14) at Module._extensions..js (node:internal/modules/cjs/loader:1295:10) at Module.load (node:internal/modules/cjs/loader:1091:32) at Module._load (node:internal/modules/cjs/loader:938:12) at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:83:12) at node:internal/main/run_main_module:23:47
Hi Dave, thanks for your comment. I think I've mistakenly replaced some of the code blocks with the wrong ones.
Kindly try copying the code block again, or you can get it from the repo here: https://github.com/chi-putera/practicality/blob/master/javascripts/metaprogramming/pelican.ts
Hi Manh Do, I gave a little example in BaseModel constructor, to create readonly attributes using Object.defineProperty for BirdModel. You could also use Reflect, or even Proxy depends on your case.
You could also extends or modify the code to add more function helpers like what ActiveStorage have in Rails.
The idea here is to leverage typescript declaration merging to make it possible with typescript.
Maybe the title isn’t specific enough, I’ve changed it already. Thanks for your feedback.
CHI PUTERA
Ah, I see. Thanks for clarifying.
I'm also interested in this topic. Do you think we can do something like macro in typescript?
Say I need to gather all file names in a given folder and make it be a union, something like:
type AllFileName = FilesAsUnion<`./path/to/folder`>
// emit: AllFileName = 'foo' | 'bar' | 'baz' | ...
of course, I can write a script to watch the folder and emit generated type. But it looks not that good
Manh Do Unfortunately, we can't define or inject types dynamically on the fly.
I don't think there is another way but to create a types-generator for that.
Learning to program can be a bit frustating sometimes. Most likely, it happened because we're trying to put so many information at once. Why don't we slow down? Learn a little at a time.
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!
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.
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.
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)
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 *