Property type coercion in Angular using decorators

tsvetanganev

Tsvetan Ganev

Posted on April 10, 2022

Property type coercion in Angular using decorators

In this article I will show you how to use decorator functions to make your Angular components accept a broad range of input types but convert them transparently to a strict internal type. The technique is useful when you want to make your component API more flexible while still guaranteeing internal data type strictness.

You can view the entire example source code shown in the article on GitHub.

What are decorators?

JavaScript decorators are functions that alter the default behavior of classes, methods and properties. Like in other programming languages such as Java, C# and Python, we can use them to transparently enhance different aspects of our code. Web UI frameworks like Angular, Lit and Aurelia use them as the building blocks of their component models. Node.js frameworks and libraries such as NestJS, sequelize-typescript and TypeORM also provide decorators to make their APIs more expressive. A great example of spot-on decorator usage is a database entity declaration in TypeORM:

// example taken from the official documentation of TypeORM
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm"

@Entity()
export class Photo {
  @PrimaryGeneratedColumn()
  id: number

  @Column({
    length: 100,
  })
  name: string

  @Column("text")
  description: string

  @Column()
  filename: string

  @Column("double")
  views: number

  @Column()
  isPublished: boolean
}
Enter fullscreen mode Exit fullscreen mode

The Entity, PrimaryGeneratedColumn and Column decorators transform the plain JavaScript class into an entity mapped to a database table with specific column characteristics. What's most impressive is we achieve all this with no procedural code at all. The table definition is declarative which makes it pleasant to read and easy to understand. All the complicated procedural instructions are inside the decorator functions themselves, hidden from our eyes. Decorators designed with care and thought can create as elegant APIs as the one shown above.

While no JavaScript runtime supports decorators natively yet, there are implementations which use transpilation to achieve the same results. The most used ones are @babel/plugin-proposal-decorators and TypeScript's experimental decorators. At the end of March 2022 the decorators proposal reached stage 3 so we can expect them to become an official part of the ECMAScript specification pretty soon. I believe decorators are worth exploring in their current state, even if they end up being slightly different than the Babel/TypeScript implementations. In the worst case scenario we can keep using the polyfills while waiting for the JavaScript community to define migration strategies.

In this article I will show you how to use decorators in TypeScript since the experimentalDecorators compiler flag is active by default for all Angular projects.

Why do we need type coercion in Angular components?

You've probably heard "type coercion" mentioned in the context of the JavaScript engine making implicit data type conversions:

  • 1 + "2" === "12"
  • true + 1 === 2
  • [] / 1 === 0
  • (!null === !undefined) === true

This automatic transformation from one type into another causes headaches to many inexperienced developers. Those who consider themselves experienced will tell you to avoid implicit type conversions at any cost. I'd say you should learn how it works and use that knowledge to your advantage. Let's see how can we apply type coercion for Angular component input properties.

Imagine we have the following Angular component which renders a number with two buttons that can either decrement or increment it.

@Component({
  selector: "my-counter",
  template: `
    <button (click)="decrement()" [disabled]="disabled">-</button>
    <span>{{ count }}</span>
    <button (click)="increment()" [disabled]="disabled">+</button>
  `,
})
export class CounterComponent {
  @Input()
  disabled = false

  @Input()
  count = 0

  increment() {
    this.count++
  }

  decrement() {
    this.count--
  }
}
Enter fullscreen mode Exit fullscreen mode

Here we have two @Inputs:

  • disabled which controls whether the user can change the number
  • count the initial value for the number

We can use the component in an Angular template like this:

<my-counter [count]="42" [disabled]="true"></my-counter>
Enter fullscreen mode Exit fullscreen mode

The template looks familiar to all developers with an Angular background, but sometimes we might have team members that are proficient with vanilla HTML or Web Components instead. Imagine we are developing the components of our company's design system in Angular but teammates from product development work primarily with Web Components. Now upper management has tasked them with urgently building the prototype for a new product. In such situations we might want a more flexible and forgiving API that mimics how native HTML and Web Components work:

<!-- count === 42, disabled === true -->
<my-counter count="42" disabled="true"></my-counter>

<!-- count === 42, disabled === false -->
<my-counter count="42" disabled="false"></my-counter>

<!-- disabled === true -->
<my-counter disabled></my-counter>
Enter fullscreen mode Exit fullscreen mode

This API hides the complexities related to the Angular-specific property binding syntax and everything will work intuitively for our teammates. We as component authors won't have to babysit the product developers and they will feel empowered by the similarities with what they already know well.

However, we can't do that with the current state of our component. We can get either of two disappointing results depending on our project setup:

  1. We will receive strings for count and disabled instead of number and boolean respectively. This can cause hard to diagnose bugs and unexpected component behavior.
  2. Our code won't compile if we have the strictTemplates compiler flag turned on. The compiler will complain that we aren't passing the expected types to our component inputs.

Neither of these is something we desire. We want everything to just workβ„’. This problem is so common that the Angular team included a default solution for it in its CDK (component development kit). We can import the @angular/cdk/coercion package to use different coercion related utility functions in our code. This approach comes with some caveats on its own:

  • we must turn the simple public properties to a getter/setter pair with a private field backing each;
  • if we are using strict templates, we must declare the accepted input type separately to let the compiler know we use different input and internal types;

Let's see this in action:

// Note: irrelevant code skipped for brevity.
import {
  coerceBooleanProperty,
  BooleanInput,
  NumberInput,
  coerceNumberProperty,
} from "@angular/cdk/coercion"

export class Counter {
  // static properties prefixed with "ngAcceptInputType_"
  // tell the compiler figure what is the real input type
  static ngAcceptInputType_disabled: BooleanInput
  static ngAcceptInputType_count: NumberInput

  @Input()
  get disabled() {
    return this._disabled
  }
  set disabled(value: boolean) {
    this._disabled = coerceBooleanProperty(value)
  }
  private _disabled = false

  @Input()
  get count() {
    return this._count
  }
  set count(value: number) {
    this._count = coerceNumberProperty(value, 0)
  }
  private _count = 0
}
Enter fullscreen mode Exit fullscreen mode

It takes us about six lines of code to coerce an @Input property and this is for the simplest cases. We are not counting the static fields needed for correct template type inference - we can't work around this without turning off compiler checks. If we multiply the lines required for type coercion by the number of such inputs in all our components, the total size of boilerplate code will increase dramatically. Can you think of a way to express all this logic with a single line of code instead of six?

export class CounterComponent {
  static ngAcceptInputType_disabled: BooleanInput
  static ngAcceptInputType_count: NumberInput

  @OfTypeBoolean()
  @Input()
  disabled = false

  @OfTypeNumber()
  @Input()
  count = 0
}
Enter fullscreen mode Exit fullscreen mode

You guessed right - this is an ideal use case for property decorators. By extracting the type coercion logic into decorator functions, we can get rid of such boilerplate code from our components.

Creating the type coercion property decorators

Let's design a property decorator function that can turn a basic property into a getter/setter pair with an associated private field. The easiest one should be the boolean type, so we will start with it:

// of-type-boolean.decorator.ts
import { coerceBooleanProperty } from "@angular/cdk/coercion"

export function OfTypeBoolean() {
  return function decorator(target: unknown, propertyKey: PropertyKey): any {
    const privateFieldName = `_${String(propertyKey)}`

    Object.defineProperty(target, privateFieldName, {
      configurable: true,
      writable: true,
    })

    return {
      get() {
        return this[privateFieldName]
      },
      set(value: unknown) {
        this[privateFieldName] = coerceBooleanProperty(value)
      },
    }
  }
}

export type BooleanInputType = "" | "true" | "false" | boolean
Enter fullscreen mode Exit fullscreen mode

The code works as follows:

  1. Define a field prefixed with an underscore that stores the value of the property.
  2. Define a getter/setter pair that exposes this field and coerces it into boolean in the setter.
  3. Create a custom type that we will use in the Angular components for the static ngAcceptInputType fields.

Notice the use of this in the getter and setter - in this case it refers to the current component's instance. It's tempting to use target here, but that would be a mistake since target is actually the component's prototype. In other words, in the context of the get() function, Object.getPrototypeOf(this) === target will evaluate to true.

Let's create the same decorator but now for number inputs:

// of-type-number.decorator.ts
import { coerceNumberProperty } from "@angular/cdk/coercion"

export function OfTypeNumber() {
  return function decorator(target: unknown, propertyKey: PropertyKey): any {
    const privateFieldName = `_${String(propertyKey)}`

    Object.defineProperty(target, privateFieldName, {
      configurable: true,
      writable: true,
    })

    return {
      get() {
        return this[privateFieldName]
      },
      set(value: unknown) {
        this[privateFieldName] = coerceNumberProperty(value)
      },
    }
  }
}

export type NumberInputType = number | string
Enter fullscreen mode Exit fullscreen mode

As you can see, the difference is one line for the coercer function and one line for the input type declaration. We can go a step further and extract the common pattern into a factory function. This will make it even easier to create new type coercion decorators in the future.

Creating a coercion decorator factory function

Let's abstract away the repeating logic for all our coercion decorators as follows:

// coercion-decorator-factory.ts
export function coercionDecoratorFactory<ReturnType>(
  coercionFunc: (value: unknown) => ReturnType
) {
  return function (target: unknown, propertyKey: PropertyKey): any {
    const privateFieldName = `_${String(propertyKey)}`

    Object.defineProperty(target, privateFieldName, {
      configurable: true,
      writable: true,
    })

    return {
      get() {
        return this[privateFieldName]
      },
      set(value: unknown) {
        this[privateFieldName] = coercionFunc(value)
      },
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We can now pass the coercion function as an argument to the factory. We must also provide a return type for the coercion function as the generic argument - this is a sanity check to prevent us from failing to return the expected type.

Now let's use this decorator factory to build a new decorator for parsing Date objects. Its goal is to accept dates as ISO 8601 strings, timestamps (both number and string) and, of course, Date instances. As result it should transform the input argument into a Date, no matter the supported format:

// of-type-date.decorator.ts
import { coercionDecoratorFactory } from "./coercion-decorator-factory"

export function OfTypeDate() {
  return coercionDecoratorFactory<Date>((date: unknown) => {
    // that's pretty naive parsing,
    // please, don't use it in production!
    if (date instanceof Date) {
      return date
    } else if (typeof date === "string") {
      if (Number.isInteger(Number(date))) {
        return new Date(Number(date))
      }

      return new Date(Date.parse(date))
    } else if (typeof date === "number") {
      return new Date(date)
    }

    throw Error(`The value ${date} can't be converted to Date!`)
  })
}

export type DateInputType = string | number | Date
Enter fullscreen mode Exit fullscreen mode

And now let's integrate the date coercion decorator into a component which renders short dates (without time information):

// short-date.component.ts
import { Component, Input } from "@angular/core"
import { DateInputType, OfTypeDate } from "./decorators/of-type-date.decorator"

@Component({
  selector: "my-short-date",
  template: `{{ date | date: "shortDate" }}`,
})
export class ShortDateComponent {
  static ngAcceptInputType_date: DateInputType

  @OfTypeDate()
  @Input()
  date: Date | undefined
}
Enter fullscreen mode Exit fullscreen mode

We can use it like this:

<!-- 04/08/22 -->
<my-short-date date="2022-04-08T19:30:00.000Z"></my-short-date>

<!-- 01/01/00 -->
<my-short-date date="946677600000"></my-short-date>
<my-short-date [date]="946677600000"></my-short-date>

<!-- whatever the value of the bound `dateOfBirth` property is -->
<my-short-date [date]="dateOfBirth"></my-short-date>
Enter fullscreen mode Exit fullscreen mode

As you can see, this component is both easy to use and more resilient to imprecise user input.

Conclusion

We can use decorators to reduce code duplication and enhance our Angular components with useful behaviors. Decorators can both improve the developer experience and the correctness of our components' business logic. All these benefits come in the form of declarative expressions that don't add much noise and complexity to our codebase.

Due to the intricacies of the Angular runtime, its template compiler, TypeScript, and the tight integration between all these, metaprogramming in this environment might require resorting to ugly hacks and workarounds. That's why the UI engineer should always keep the right balance between developer experience, code quality and functionality.

You can get the complete source code for this demo on GitHub.

I hope this article inspired you to think of interesting use cases for JavaScript decorators that you can integrate in your projects!

πŸ’– πŸ’ͺ πŸ™… 🚩
tsvetanganev
Tsvetan Ganev

Posted on April 10, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related