How to strictly type an identity function as a specific transform?

Viewed 2746

How do I define a function type transform<I, O> that takes input of type I and return output of type O, and then define an identity function as a specific implementation of a transform?

For example

type transform<I, O> = (input: I) => O;

const identity = // ...

Such that I can define a function

const mapToObject = <K, V, O>(
  map: Map<K, V>, 
  transformValue: transform<V, O> = identity
) => Array.from(map.entries()).reduce((obj, [key, val]) => {
  obj[key] = transformValue(value);
  return obj;
}, {});

The mapToObject function should take a Map and transformValue function and convert the map to a simple JS object, applying transformValue to each value in the map. transformValue should default to the identity function v => v.

The identity function mapping any to any works, though I am interested in a strictly typed solution.

2 Answers

There is only one sensible way to type an identity function, no surprises here

const identity = <T>(t: T) => t;

But strictly typed identity function is not assignable to arbitrary transform - compiler knows that it returns the same type as it received, so your typing for mapToObject is inconsistent - what is the result type when you call it as

   mapToObject<string, number, Date>(new Map<string, number>())
  • since it uses the default identity transform, resulting object must hold numbers
  • but you explicitly provided third generic parameter, so resulting object must hold Dates

This needs to be expressed as dependent type - type of resulting object depends on whether the transform argument is given or the default is used. In TypeScript, you can do that with overloading function declarations:

function mapToObject<K, V>(map: Map<K, V>)
    : { [n: string]: V };
function mapToObject<K, V, O>(map: Map<K, V>, transformValue: transform<V, O>)
    : { [n: string]: O };
function mapToObject<K, V, O>(map: Map<K, V>, transformValue?: transform<V, O>)
    : { [n: string]: {} } 
{
    const transform: (v: V) => {} = transformValue || identity; 
    return Array.from(map.entries()).reduce((obj, [key, val]) => {
        obj[key.toString()] = transform(val) ;
        return obj;
    }, {});
}

Since actual value type is not used in the implementation at all, it can be given as empty object type {}, which is compatible with both V and O types (you could use union type V | O instead because it's also compatible, but it's more verbose and buys you nothing here)

With these declarations, inconsistent usage is detected by the compiler:

// error: Expected 1-2 arguments, but got 1 
mapToObject<string, number, Date>(new Map<string, number>())

mapToObject<string, number>(new Map<string, number>()) // ok
mapToObject<string, number, Date>(new Map<string, number>(), n => new Date(n)) //ok

and the result type is inferred as expected:

const o1 = mapToObject(new Map<string, number>())
 // {[n: string]: number}

const o2 = mapToObject(new Map<string, number>(), n => new Date(n))
 //  {[n: string]: Date}

You can put generic arguments to a arrow function or a regular function

type transform<I, O> = (input: I) => O;

const identity = <I, O> (input: I) => {
    return null;
}

//Or 
const identity2 = function <I, O> (input: I) {
    return null;
}

const mapToObject = <K extends string, V, O>(
    map: Map<K, V>, 
    transformValue: transform<V, O> = identity
  ) => Array.from(map.entries()).reduce((obj, [key, val]) => {
    obj[key] = transformValue(val);
    return obj;
  }, <{ [index: string]: O }>{});
Related