Change Method Signature in TypeScript Subclass

July 12th 2019 TypeScript

JavaScript prototype inheritance offers much greater flexibility than regular inheritance in object-oriented programming. For example, when extending a class, it allows you to change the method signature:

class ExtendedMap extends Map {
    get(prefix, key) {
        return super.get(prefix + key);
    }
}

ExtendedMap::get has two parameters unlike its base class counterpart Map::get which only has one:

const fromMap = map.get('ab');
const fromExtendedMap = extendedMap.get('a', 'b');

However, when using TypeScript, it's not as easy. This would be the TypeScript equivalent of the above code:

export class ExtendedMap extends Map<string, number> {
    get(prefix: string, key: string): number {
        return super.get(prefix + key);
    } 
}

TypeScript compiler doesn't like it and emits the following error:

Property 'get' in type 'ExtendedMap' is not assignable to the same property in base type 'Map<string, number>'.
  Type '(prefix: string, key: string) => number' is not assignable to type '(key: string) => number'.

In essence, it complains that the two method signatures don't match which is exactly what we wanted to achieve.

Fortunately, TypeScript's type system is capable of describing (almost?) everything JavaScript allows us to do. The problem with the example above is that TypeScript assumes we want to keep the same method signatures as one would have to in object-oriented languages. We need to tell it that this isn't what we're trying to achieve, i.e. we don't want to include the Map::get method in our ExtendedMap class:

type MapWithoutGet = new<K, V>(entries?: ReadonlyArray<readonly [K, V]> | null)
    => { [P in Exclude<keyof Map<K, V>, 'get'>] : Map<K, V>[P] }

const MapWithoutGet: MapWithoutGet = Map;

export class ExtendedMap extends MapWithoutGet<string, number> {
    get(prefix: string, key: string): number {
        return Map.prototype.get.call(this, prefix + key);
    } 
}

The syntax above can be overwhelming if you're not familiar with with conditional types and mapped types in TypeScript. Let's dissect it:

  • [P in Exclude<keyof Map<K, V>, 'get'>] specifies the member names from Map<K, V> without its get member (the one with the single parameter).
  • Map<K, V>[P] defines the type of each member from the above to be the same as is the type of the member with that name in Map<K, V>.

Both together define a type with the same members as Map<K, V> except for get. The remaining part of the type definition in front of this return type (new<K, V>(entries?: ReadonlyArray<readonly [K, V]> | null)) describes the original Map<K, V> constructor. I have copied it from its type definition:

new<K, V>(entries?: ReadonlyArray<readonly [K, V]> | null): Map<K, V>;

So, effectively, MapWithoutGet describes an almost identical class/constructor which differs only in the return type (i.e. it lacks the get method which we want to get rid off so that we can change its signature). By declaring a variable with the same name and assigning it the original Map<K, V> class/constructor, we instruct TypeScript to consider it as a class from there on:

const MapWithoutGet: MapWithoutGet = Map;

The Map<K, V> constructor of course returns a valid MapWithoutGet<K, V> class because it returns an object with all the required methods (and the additional get method which is ignored).

Now, our ExtendedMap class can extend the MapWithoutGet class instead of the original Map class to avoid the conflict in the get method signature. However, because we removed the get method from the new base class, we can't call it from our new get method with the super keyword. The following would result in an error because of the missing get method i nMapWithoutGet:

return super.get(prefix + key);

Instead, we need to call this method directly from the Map prototype:

return Map.prototype.get.call(this, prefix + key);

Everything I've described so far works great for custom EcmaScript classes. However, there's a caveat when extending built-in classes such as Map: the code can't be properly transpiled to EcmaScript 5. Attempting to do so, will result in the following error:

TypeError: Constructor Map requires 'new'
    at ExtendedMap.Map (<anonymous>)

To avoid it, you will need to change the transpile target to EcmaScript 6 in tsconfig.json:

{
    "compilerOptions": {
        // ...
        "target": "es6"
        // ...
    }
}

Of course, this will mean that the generated JavaScript will only run in engines with full EcmaScript 6 support which for now excludes browsers. Nevertheless, unless you're extending built-in classes, everything described in this post can be used even when you're transpiling your code to EcmaScript 5.

Copyright
Creative Commons License