Change Method Signature in TypeScript Subclass
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 fromMap<K, V>
without itsget
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 inMap<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.