Meta programming with TypeScript

Meta programming with TypeScript
meta programming is like magic in real world

First, developers write programs. Then, they write code (programs) which produce programs (code) based on other code (programs) - this is meta programming.

The simplest example of meta programming in JavaScript I can think of is Object.keys - iterate through any object's keys and do something with them. This allows to change behaviour of a program dynamically in runtime.

Therefore, with magical power of JavaScript we can analyze any object - check for object keys, their types, modify and assign new properties and methods to any object / class.

Thankfully, TypeScript brings clarity (again) to JavaScript magic so we can create type-safe magic-enabled apps.

Case - API silent methods

My recent case for metaprogramming with TypeScript happened with the help of key remapping in mapped types .

For REST API endpoints I use classes with static methods like next:

class ProfileAPI {
  getMyProfile() { ... }
  
  updateMyProfile() { ... }
}

Using axios for networking, I created interceptors to catch error responses and show relevant error toasters (aka snackbar notifications) using nice and simple react-toastify npm package.

But I don't want to show for every method of API, because:

  1. I want to handle error differently, e.g. show response error right below button user clicked instead of toaster in top right corner
  2. sometimes it is not needed, e.g. optional side-effect which runs on interval and not every request is critical.

Solution - extend API with silent methods

With little help of JS magic, I turned my APIs into new classes with more methods:

class ProfileAPI {
  getMyProfile() { ... }
  
  updateMyProfile() { ... }

  getMyProfileSilent() { ... }
  
  updateMyProfileSilent() { ... }
}

Methods with Silent suffixes are added automatically and have the same behaviour as original methods which you can then extend as you need via monkey patching.

type RemapKeysWithSuffix<O extends object, Suffix extends string> = {
  [K in keyof O as `${string & K}${Suffix}`]: O[K]
};

// add special behaviour to new "virtual" methods during class build
const build = <A extends object, Configs extends Record<string, {}>>(api: new (config?: {}) => A, configs: Configs): A & RemapKeysWithSuffix<A, string & keyof Configs> => {
  const result: Record<string, any> = new api();
  Object.keys(configs).forEach(suffix => {
    const config = configs[suffix];
    const configuredApi: Record<string, any> = new api(config);
    Object.keys(result).forEach(key => {
      // monkey patch here
      result[key + suffix] = configuredApi[key];
    });
  });
  return result as any;
}

const DemoApi = build(class DemoApi {
  method() { }
  anotherMethod() { }
}, { Silent: {} });

DemoApi.anotherMethodSilent();

Case - type safe query for database

Another prominent case I used metaprogramming for is related to Proxy.

On my backend project, which is written with a great framework NestJs I use TypeORM query builder technique to get complex data joins from PostgreSQL database while preserving type safety like next:

const subSelect = this.repo
    .createQueryBuilder(safe.Alias.sub2Submission)
    .select(safe.sub2Submission.createdDate)
    .where(`${safe.sub2Submission.participantId} = ${safe.subSubmission.participantId}`)
    .groupBy(safe.sub2Submission.score)
    .addGroupBy(safe.sub2Submission.createdDate)
    .orderBy(safe.sub2Submission.score, 'DESC')
    .addOrderBy(safe.sub2Submission.createdDate, 'ASC')
    .limit(1)
    .getSql();

Now, all "raw queries" are used only through special safe object with properties generated in runtime and only for allowed value.

Solution - Proxy query generator

With the next little util I can create string generators backed by type safety of TypeScript:

export const createSafePropertyGetter = <T>(prefix?: string): Record<keyof T, string> => {
  return new Proxy({} as any, {
    get(_, prop: string) {
      if (typeof prop === 'symbol') {
        throw new Error('try to use safe.Alias.desiredEntity');
      }
      return prefix ? prefix + '.' + prop : prop;
    },
  }) as any;
};

So, let's say I have entity (database table) called profile, we do next:

// define list of entities
type allEntityNames =
  | 'profile'
  | 'file'
  | 'member'
  | 'friend';
export const Alias = createSafePropertyGetter<Record<allEntityNames, string>>();

// create namespace to conveniently group all entities in one object
namespace nsSafe {
  export const profile = createSafePropertyGetter<ProfileEntity>(Alias.profile);
}

export const safe = {
  Alias,
  ...nsSafe,
} as const;

// now use it everywhere needed
safe.Alias.profile; // => 'profile'
safe.friend.invitedById; // => 'friend.invitedById'

Benefits of this approach is all entities names and their properties are type safe, inferred from entity interfaces and type aliases and type checked during compilation, which in turn means:

  • No typos
  • IDE Suggestions for available properties
  • Code is linked through project files, I can find all references to any property in query builder
  • Easy to refactor and maintain
  • Strict and clear pattern to follow across the team

Read more

As part of metaprogramming, I encourage you to experiment with TypeScript decorators which is still in experimental mode (TC39 stage 2 proposal) after so many years, though. But I like this feature and hope they will make decorators available not only for class methods, but for plain arrow functions.

For me personally, metaprogramming is like magic because ability to program change itself in runtime based on inspection of its own code and data is a great origin story for Artificial Intelligence to appear - but that's another big topic for future πŸ˜‰