Advanced TypeScript concepts: Classes and types

By on August 22, 2014 8:38 am

This article describes the features and functionality of TypeScript 2.5.

While TypeScript is very simple to understand when performing basic tasks, having a deeper understanding of how its type system works is critical to unlocking advanced language functionality. Once we know more about how TypeScript really works, we can leverage this knowledge to write cleaner, well-organised code.

If you find yourself having trouble with some of the concepts discussed in this article, try reading through the Definitive Guide to TypeScript first to make sure you’ve got a solid understanding of all the basics.

Behind the class keyword

In TypeScript, the class keyword provides a more familiar syntax for generating constructor functions and performing simple inheritance. But what if we couldn’t use the class keyword for some reason? How would we make an equivalent structure? Is it even possible? To answer these questions, let’s start with a basic example of a TypeScript class:

class Point {
  static fromOtherPoint(point: Point): Point {
    // ...
  }

  x: number;
  y: number;

  constructor(x: number, y: number) {
    // ...
  }

  toString(): string {
    // ...
  }
}

This archetypical class includes a static method, instance properties, and instance methods. When creating a new instance of this type, we’d call new Point(<number>, <number>), and when referring to an instance of this type, we’d use the type Point. But how does this work? Aren’t the Point type and the Point constructor the same thing? Actually, no!

In TypeScript, types are overlaid onto JavaScript code through an entirely separate type system, rather than becoming part of the JavaScript code itself. This means that an interface (“type”) in TypeScript can—and often does—use the same identifier name as a variable in JavaScript without introducing a name conflict. (The only time that an identifier in the type system refers to a name within JavaScript is when the typeof operator is used.)

When using the class keyword in TypeScript, you are actually creating two things with the same identifier:

  • A TypeScript interface containing all the instance methods and properties of the class; and
  • A JavaScript variable with a different (anonymous) constructor function type

In other words, the example class above is effectively just shorthand for this code:

// our TypeScript `Point` type
interface Point {
  x: number;
  y: number;
  toString(): string;
}

// our JavaScript `Point` variable, with a constructor type
let Point: {
  new (x: number, y: number): Point;
  prototype: Point;

  // static class properties and methods are actually part
  // of the constructor type!
  fromOtherPoint(point: Point): Point;
};

// `Function` does not fulfill the defined type so
// it needs to be cast to <any>
Point = <any> function (this: Point, x: number, y: number): void {
  // ...
};

// static properties/methods go on the JavaScript variable...
Point.fromOtherPoint = function (point: Point): Point {
  // ...
};

// instance properties/methods go on the prototype
Point.prototype.toString = function (): string {
  // ...
};

Prior to TypeScript 1.6, TypeScript artificially restricted the use of the extends keyword for equivalent effectively equivalent types: you could only extend a constructor that was explicitly created using the class keyword. Since TypeScript 1.6, this restriction has been lifted and the following will work:

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

let Point: {
  new (x: number, y: number): Point;
  prototype: Point;
} = function (): void {
  // ...
};

// This works in TypeScript 1.6+
class Point3d extends Point {
  z: number;
  // ...
}

// You can also extend from built-in types
class MyArray<T> extends Array<T> {
  // ...
}

TypeScript 1.6+ also added support for ES6 Class expressions.

Adding type properties to classes

A common problem in complex applications is how to keep related sets of functionality grouped together. We already accomplish this by doing things like organising code into modules for large sets of functionality, but what about things like types that are only applicable to a single class or interface? For example, what if we had a Widget class that accepted a keyword arguments object, as is common in Dojo 1.x?:

class Widget {
  constructor(kwArgs: {
    className?: string;
    id?: string;
    style?: Object;
  }) {
    for (let key in kwArgs) {
      this[key] = kwArgs[key];
    }
  }
}

In this code, we’ve succeeded in defining an anonymous type for the kwArgs parameter, but this is very brittle. What happens when we subclass Widget and want to add some extra properties? We’d have to write the entire type all over again. Or, what if we want to reference this type in multiple places, like within some code that instantiates a Widget? We wouldn’t be able to, because it’s an anonymous type assigned to a function parameter.

To solve this problem, we can use the namespace keyword to augment the Widget type with some new properties that can be accessed using an identifier path:

class Widget {
  constructor(kwArgs: Widget.KwArgs = {}) {
    for (let key in kwArgs) {
      this[key] = kwArgs[key];
    }
  }
}

namespace Widget {
  // accessible as Widget.KwArgs
  export interface KwArgs {
    className?: string;
    id?: string;
    style?: Object | Style;
  }

  // Classes can be exported in namespaces, too
  export class Style {
    // ...
  }
}

export default Widget;

Now, instead of having an anonymous object type dirtying up our code, we have a specific Widget.KwArgs subtype that can be referenced by our code as well as any other code that imports Widget. This means that we can easily subclass our kwArgs parameter while keeping everything DRY and well-organised:

import Widget from './Widget';

// normal class inheritance…
class TextInput extends Widget {
  // replace the parameter type with our new, more specific subtype
  constructor(kwArgs: TextInput.KwArgs = {}) {
    super(kwArgs);
  }
}

namespace TextInput {
  // normal inheritance here again!
  export interface KwArgs extends Widget.KwArgs {
    maxLength?: number;
    placeholder?: string;
    value?: string;
  }

  // inheritance here, too!
  export class Style extends Widget.Style {
    // ...
  }
}

export default TextInput;

As mentioned earlier, using this pattern, we can also reference these types from other code:

import Widget from './Widget';
import TextInput from './TextInput';

export function createWidget<
  T extends Widget = Widget,
  K extends Widget.KwArgs = Widget.KwArgs
>(Ctor: { new (...args: any[]): T; }, kwArgs: K): T {
  return new Ctor(kwArgs);
}

// w has type `Widget`
const w = createWidget(Widget, { style: new Widget.Style() });
// t has type `TextInput`
const t = createWidget(TextInput, { style: new TextInput.Style() });

Namespaces can also be nested in order to create sub-subtypes, if desired:

namespace Widget {
  export namespace Types {
    // Will be accessible as `Widget.Types.KwArgs`
    export interface KwArgs {
      // ...
    }
  }
}

Note that the inner namespace and interface definitions need to use the export keyword or they will be considered part of the internal namespace code and not publicly exposed.

Extending subclasses with function overrides

JavaScript toolkits like Dojo 1.x use custom get and set functions on most objects in order to enable advanced Proxy– and Object.observe-like functionality in browsers going all the way back to IE6. When upgrading to TypeScript, we still need to be able to specify the correct return type for these calls, the same way that an instance property would have the correct type. Unfortunately, TypeScript doesn’t allow us to simply define method overrides in subclasses:

class Widget {
  get(key: 'className'): string;
  get(key: 'id'): string;
  get(key: 'style'): Object;
  get(key: string): void;
  get(key: string): any {
    // ...implementation...
  }
}

class TextInput extends Widget {
  // Even though there is a function implementation
  // in the parent class, this will cause an error
  // because there is no implementation in this subclass:
  // “Function implementation expected”
  get(key: 'maxLength'): number;
  get(key: 'placeholder'): string;
  get(key: 'value'): string;
}

To work around this limitation, we can combine function interface definitions with the subtype declarations discussed above in order to create an extensible get property for the subclass:

class Widget {
  // now defined as a function property, not a method
  get: Widget.Get;
}

Widget.prototype.get = function (): any {
  // ...implementation...
};

module Widget {
  export interface Get {
    // note that these definitions have no name, which means
    // they are applied directly to the `Widget.Get` interface
    (key: 'className'): string;
    (key: 'id'): string;
    (key: 'style'): Object;
    (key: string): void;
  }
}

class TextInput extends Widget {
  get: TextInput.Get;
}

module TextInput {
  export interface Get extends Widget.Get {
    // now we can add more interface definitions that correctly
    // augment the original `Widget.Get` interface
    (key: 'maxLength'): number;
    (key: 'placeholder'): string;
    (key: 'value'): string;
  }
}

With this change, we can successfully call TextInput#get('maxLength') and TypeScript knows the return value is a number, and TextInput#get('id') and TypeScript knows the return value is a string, without needing to duplicate the get function inside every single subclass.

Abstract Classes (TS 1.6+)

TypeScript 1.6+ supports the abstract keyword for classes and their methods and TypeScript 2.0+ supports the abstract keyword for properties and accessors. An abstract class may have methods, properties, and accessors with no implementation, and cannot be constructed. See Abstract classes and methods and Abstract properties and accessors for more information.

Mixins and Compositional Classes (TS 2.2+)

TypeScript 2.2 has made some changes to make it easier to implement mixins and/or compositional classes. This was achieved by removing some of the restrictions on classes. For example, it’s now possible to extend from a value that constructs an intersection type. They’ve also changed the way that signatures on intersection types get combined.

Symbols, Decorators, and more

Symbols

Symbols are unique, immutable identifiers that can be used as object keys. They offer the benefit of guaranteeing safety from naming conflicts. A symbol is a primitive value with the type of “symbol” (typeof Symbol() === 'symbol').

// even symbols created from the same key are unique
Symbol('foo') !== Symbol('foo');

When used as object keys, you don’t have to worry about name collision:

const ID_KEY = Symbol('id');
let obj = {};
obj[ID_KEY] = 5;
obj[Symbol('id')] = 10;
obj[ID_KEY] === 5; // true

Strong type information in TS is only available for built-in symbols.

See our ES6 Symbols: Drumroll please! article for more information about Symbols.

Decorators

A decorator is a function that allows shorthand in-line modification of classes, properties, methods, and parameters. A method decorator receives 3 parameters:

  • target: the object the method is defined on
  • key: the name of the method
  • descriptor: the object descriptor for the method

The decorator function can optionally return a property descriptor to install on the target object.

function myDecorator(target, key, descriptor) {
}

class MyClass {
    @myDecorator
    myMethod() {}
}

myDecorator would be invoked with the parameter values MyClass.prototype, 'myMethod', and Object.getOwnPropertyDescriptor(MyClass.prototype, 'myMethod').

TypeScript also supports computed property names and Unicode escape sequences.

See our TypeScript Decorators article for more information about decorators.

In conclusion

Hopefully this post has helped to demystify parts of the TypeScript type system and given you some ideas about how you can exploit its advanced features to improve your own TypeScript application structure. If you have any other questions, or want some expert assistance writing TypeScript applications, get in touch to chat with us today!