Modular, Type-safe Metadata with TypeScript

One of the humps I’ve encountered while learning TypeScript is coming to terms with a core tenet of the language—the type definitions you write only exist at compile time, not at runtime.

I’ve bumped into this a few times. Can I generate a predicate function to test for type compatibility? No—use type guards instead. Can I reflect on the properties of a type at runtime? No—with caveats. It turns out that those caveats matter a lot, and you can do some pretty powerful things with the tools TypeScript does provide.

On a current project, we really wanted our type definitions to drive runtime behavior, specifically our GraphQL query resolution and data lookup functionality. After some initial flailing, we found these TypeScript features were key to a safe, flexible, metadata-driven system.

Types Exist Only at Compile Time

This is an important point to understand, and it forced me to think a bit differently about TypeScript. Many aspects of the language’s design are chosen to use a type system to enforce the rules you encode in your types, but without introducing unseen runtime code.

This has some ramifications that can be a bit surprising. Consider this class:

class Point {
	x: number;
	y: number;
}

Given any instance of Point, you can ask for its keys and learn about x and y. But what if you want to do something at runtime based on the Point class in general?

It turns out there’s no way to ask about this information as defined above, but there are other ways to accomplish this goal.

“Reflection” in TypeScript

TypeScript has support for ES7 decorators. Disabled by default, this powerful feature can be turned on with the experimentalDecorators flag in your tsconfig.json.

Decorators allow you use @decorator to invoke function when your app first launches to capture runtime information. For example, TypeORM uses them to annotate a class to map it to a database table:

import {Entity, Column} from "typeorm";

@Entity()
export class Photo {
    @Column()
    id: number;

    @Column()
    name: string;

    @Column()
    fileName: string;
}

Each use of a decorator turns into an invocation of a runtime function with metadata about the decorated item. Both @Entity() and @Column() in this example return a function which is called once for each use above, and passed, among other things, the name of the thing being decorated.

Decorators are the key to learning about your types at runtime.

Now, when you decorate a class with decorators, you often do so to store off metadata for future use. In the example above, the decorators themselves probably aren’t doing much of anything but building up a data structure to drive the ORM. This brings us to the close cousin of decorators, Reflect.metadata.

In addition to giving us decorators, ES7 also includes a (separate) draft proposal for a standard mechanism that stores and retrieves metadata for an object or class. Reflect.metadata isn’t widely supported right now, so to use it, you’ll need a polyfill. With it, there’s a natural way to store information captured in your decorators.

For example, consider the Point example from above. As written, there’s nothing at runtime that allows us to discover that points have x and y properties. But by decorating the class, we can capture that information:

class Point {
	@property x: number;
	@property y: number;
}

All that’s necessary is to give meaning to the word property. In this case, we might define it to use the metadata API as:

function property(target: object, propertyKey: string) {
  let columns: string[] = Reflect.getMetadata(PROPERTIES_KEY, target.constructor) || [];
  columns.push(propertyKey);
  Reflect.defineMetadata(PROPERTIES_KEY, columns, target.constructor);
}

This defines property as a function that takes an object, which will be the prototype of Point in our example, and the name of a property. Given such, property looks for the value associated with the symbol PROPERTIES_KEY on the decorated objects constructor and appends the name of the property to it as an array. It then stores the updated array back on the constructor. Defined as such, our Point example invokes property twice, once with ’x’ and once with ’y'.

With decorators and metadata, you can create powerful APIs to drive your application via declarative annotations of your classes. But there’s one restriction. With just these tools, there’s no way to know what types our values will have.

emitDecoratorMetadata

Alas, there is no fine-grained solution to knowing about your types in TypeScript. The official answer for granular type information is to use the server API to parse your code. This is, in large part, because TypeScript has a strict rule to never, ever generate code that you didn’t write and a sophisticated type representation is out of scope.

Except in one case, which gets us pretty far, if not all the way, to full reflection.

TypeScript will generate code to allow you to learn limited information about the types of your properties if you ask it nicely, by setting emitDecoratorMetadata to true in your tsconfig.json. If you do so, and you’ve imported the reflect-metadata polyfill, it will use that API to tell you which constructor represents the type of a property.

You can so do by asking for the design:type metadata key. The design:type of a number property is Number. The design:type of a string is String. The design:type of a property typed by some class is the constructor of that class. See this excellent overview for details.

Using decorators, you can give yourself a way to discover your properties. With emitDecoratorMetadata, you can find limited type information–enough for basic use cases. This is the basis for our approach to GraphQL schema generation in our app’s API.

Scalable Annotations

You can build on this starting point pretty effectively by pulling in other tools provided by TypeScript. In our case, we’ve combined these with mapped types, introduced in TypeScript 2.1, to define a more scalable, modular approach to decorating our classes.

Consider what happens if you want to describe information about a type, but that information is not relevant to all contexts, or fundamental to the operation of that type, as it is with TypeORM.

We ran into this recently when wanting to annotate a type with information for how it should be presented in our React client user interface. This information wasn’t relevant to our server, and it was fairly verbose.

For example, our initial approach was to add a decoration:

class Customer {
  @presentation({
      searchable: true,
      sortable: true,
      required: true,
      displayName: 'Customer Name',
      formDisplayType: displayType(DISPLAY_TYPES.INPUT),
      tableDisplay: true,
      columnWidth: 20,
	})
  @property name: string;
	// ...
}

While this worked, it made it difficult to scan our class to see the properties it supported, as there was so much metadata relevant to one specific purpose. We didn’t want to make the core type declaration difficult to read, but on the other hand, we wanted to leverage TypeScript’s power to ensure we didn’t make silly mistakes while doing so.

TypeScript has an amazing feature which helped us solve this problem.

In most similar languages–C#, Java, and JavaScript, for example–the only way to use this sort of metadata decoration capability is with decorators which must occur in certain places in your code.

But JavaScript has decoupled the decoration and the metadata mechanism, and TypeScript has given us other ways of tightly tying something to the properties of a class: mapped types.

With mapped types, we can derive one type from another. For example, the built-in Partial<T> takes a type T and gives a derivative of it, where every property is optional:

type Partial<T> = {
    [P in keyof T]?: T[P];
};

Partial<T> includes an entry for every property P in T, whose type is the same (T[P]), but has been made optional with the ? notation. For example, Partial<Point> would be {x?: number, y?: number}. By using the P in keyof T and T[P] syntax, TypeScript deeply understands the relationships on T and Partial<T> and will give fully typesafe access.

We can do the same thing to scale out our metadata annotations without losing certainty of their accuracy. In our case, we created a function which lets us annotate properties of a class after the fact:

definePresentation(Customer, {
	name: {
	  searchable: true,
	  sortable: true,
	  required: true,
	  displayName: 'Customer Name',
	  tableDisplay: true,
	  columnWidth: 20,
	},
  // ...
});

Where definePresentation is defined as:

const PROPERTY_PRESENTATION_KEY = Symbol("propertyPresentation");
export function definePresentation<T>(klass: new (...args: any[]) => T, props: PropertyPresentationMap<T>): void {
  for (let propertyName in props) {
    if (props.hasOwnProperty(propertyName)) {
      presentableProperties.push(propertyName);
      Reflect.defineMetadata(PROPERTY_PRESENTATION_KEY, props[propertyName], klass.prototype, propertyName);
    }
  }
}

definePresentation takes a constructor of a class T and a PropertyPresentationMap<T>, which includes a definition for each property. We simply loop through the properties in the map, and associate the provided metadata to that property.

PropertyPresentationMap is a mapped type, deriving its structure from another type:

type PropertyPresentationMap<T> = {
  [P in keyof T]?: PresentationOptions<T[P]>;
};

For any given property in the base type, PropertyPresentationMap<T> can have a property of the same name, whose value is a PresentationOptions configured for whatever type P has. In our case, PresentationOptions looks like:

interface PresentationOptions<PropertyType> {
  required?: boolean;
  searchable?: boolean;
  sortable?: boolean;
  displayName?: string;
  tableDisplay?: boolean;
  columnWidth?: number;
  defaultValue?: PropertyType;
}

Defined as such, TypeScript does magical things:

  • It guarantees that each key passed to definePresentation is a property of our base type. This allows us to annotate our base type in a different context, even a different file or module, without worrying about the two diverging or annotating a nonexistent property with metadata.
  • It knows the type of that property, allowing us to provide a default value and verifying the provided default matches the declared value. If we change our name from a string to some other type, our use of definePresentation will have a type error until the defaultValue is updated.

Wrapping Up

In the end, TypeScript provides a compelling, elegant solution to reflection and metadata that lives up to its design goals nicely.

  • Decorators let you drive runtime behavior from type definitions in an explicit way.
  • The Reflect API provides a great way to accumulate metadata to use at runtime.
  • Mapped types let you decouple metadata from the original type definition, should you need a modular way to decorate types in specific contexts.

To learn more about decorators, Reflect metadata, and TypeScript types, I suggest this blog series.

To learn more about mapped types, see the index types and mapped types sections from the TypeScript book.

Conversation
  • Chui Tey says:

    That’s pretty cool. I’ve been looking around how other developers set up presentation properties for basic forms over data applications. What do you do for relationships?

  • John Walker says:

    Literally the best and coolest frontend blob post i have ever read in the last decade! I had not used the new TS mapped types and seeing how they could be used in this way with the already awesome reflection stuff is amazing. Thank you!!

  • Bachar says:

    Really amazing .I was searching for posts like this. Thank you very much

  • Tinus says:

    Do you have source code of this?

  • Comments are closed.