Home > database >  Signature overload does not work as expected: Record vs Map
Signature overload does not work as expected: Record vs Map

Time:05-03

This is a pretty simple use case.

I have an overloaded signature which sometime accepts a Record, sometime a Map. Although I am using the Map version, somehow TS thinks i'm using the Record one. Why ? How should I fix this ?

    function doStuff(arg: Record<any, any>): Record<any, any>;
    function doStuff(arg: Map<any, any>): Map<any, any>;
    
    function doStuff(arg: any) {
      return arg;
    }
    
    let m: Map<any, any> = doStuff(new Map<any, any>());
    // error ! m is returned as a Record

also see the playground

CodePudding user response:

Record<any, any> is a type, while Map<any, any> is a value that "extends" Record, when you pass a Map value the first signature that coincides whit the call is the first one, and that's why Typescript see a call of dostuff(Record<any, any>) correctly returning a Record<any, any>.

Record is not assignable to Map for the reason above, this is why Typescript raise an error.

You should overload with just Record or Map

CodePudding user response:

TL;DR: you need to swap your two call signatures like this:

function doStuff(arg: Map<any, any>): Map<any, any>;
function doStuff(arg: Record<any, any>): Record<any, any>;
function doStuff(arg: any) {
  return arg;
}

The type Record<any, any> (using the Record<K, V> utility type) is equivalent to {[x: string]: any}:

type RecordAnyAny = Record<any, any>;
// type RecordAnyAny = {[x: string]: any}

an object-like type with a string index signature whose properties are of the any type. So it can have any keys at all, and any values at all. So just about any object will be assignable to it:

let r: RecordAnyAny
r = {}; // okay
r = {a: 0, b: "one", c: true}; // okay
r = new Date(); // okay;
r = new Map(); // okay, note well

As you can see, even a Map object is assignable to Record<any, any>.


On the other hand, the Map<K, V> type is an interface defined in the TypeScript library as something like:

interface Map<K, V> {
    clear(): void;
    delete(key: K): boolean;
    forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void;
    get(key: K): V | undefined;
    has(key: K): boolean;
    set(key: K, value: V): this;
    readonly size: number;
}

And Map<any, any> is equivalent to the above interface with any substituted in for both K and V.

This type is more restrictive than Record<any, any>. If I have a value m of type Map<any, any>, I know that it will have, say, a get() method, but with Record<any, any>, I don't know that, so it's not safe to use a Record<any, any> as a Map<any, any> (while it is safe to do the reverse):

let m: Map<any, any> = new Map();
m = r; // error
// Type 'RecordAnyAny' is missing the following properties from
//  type 'Map<any, any>': clear, delete, forEach, get, and 8 more.(2740)

So, hopefully this is clear: every Map<any, any> is a Record<any, any>, but not every Record<any, any> is a Map<any, any>.


Now we can look at your overloaded function:

// call signatures 
function doStuffOld(arg: Record<any, any>): Record<any, any>;
function doStuffOld(arg: Map<any, any>): Map<any, any>;

// implementation
function doStuffOld(arg: any) {
  return arg;
}

doStuffOld(new Map<any, any>());

When you call an overloaded function, the compiler picks the first matching call signature. So for doStuffOld(new Map<any, any>()), the compiler examines the first call signature (arg: Record<any, any>) => Record<any, any>. Does it work? YES. Every May<any, any> is also a Record<any, any>, so the compiler selects this overload. And so the return type is also Record<any, any>, and it's an error to assign the result to a Map<any, any>:

m = doStuffOld(new Map<any, any>()); // error!

The second call signature is never reached. Oops.


Because Map<any, any> is more specific than Record<any, any>, you need to swap the order of your two call signatures. This advice is also given in the TypeScript Handbook's Do's and Don'ts for function overloads:

// call signatures
function doStuff(arg: Map<any, any>): Map<any, any>;
function doStuff(arg: Record<any, any>): Record<any, any>;

// implementation
function doStuff(arg: any) {
  return arg;
}

m = doStuff(new Map<any, any>()); // okay
r = doStuff({a: 0, b: "one", c: true}); // okay

Now when you call doStuff(new Map<any, any>()), the compiler tries the Map call signature first. That matches, so you get the Map return type.

And when you call doStuff({a: 0, b: "one", c: true}), the compiler tries the Map call signature but it does not match (since that object is not a Map), so it then tries the Record call signature which does match... and you get the Record return type.

So now everything should work as desired.

Playground link to code

  • Related