Home > Blockchain >  Typescript does not understand that record types must return the correct type
Typescript does not understand that record types must return the correct type

Time:10-20

Sorry for the difficult title, but please take a look at the following minimal representation:

// We define a couple of symbols
const A: unique symbol = Symbol();
const B: unique symbol = Symbol();

// And create an "enum" of them
const Fs = {
  A,
  B
} as const;

// This is the type A | B
type F = typeof Fs[keyof typeof Fs];

// This is our "input" types
type Foo = {
  [Fs.A]: string,
  [Fs.B]: number,
}

// This is our "output" types
type Bar = {
  [Fs.A]: number,
  [Fs.B]: string,
}

// We define a Mapping for the key (A or B) to be a function taking an
// input of type defined in Foo, and spitting out an output of type defined in Bar
type Mapping<T extends F> = (arg: Foo[T]) => Bar[T];

// We then define a map record as being a list of all such functions
type MapRecord = {
  [key in F]: Mapping<key>
}

// Here is the action mapping. It does indeed comply with map record.
const mappings: MapRecord = {
  [Fs.A]: (arg: string) => 123,
  [Fs.B]: (arg: number) => "asdf",
} as const;

// And here we execute the mapping. Can you spot the error?
function doMapping<T extends F>(t: T, arg: Foo[T]): Bar[T] {
  const mapper: Mapping<T> = mappings[t];
  return mapper(arg);
}

Seems perfectly fine. But on that const mapper... line, we actually get the following error:

Type 'Mapping<unique symbol> | Mapping<unique symbol>' is not assignable to type 'Mapping<T>'.
  Type 'Mapping<unique symbol>' is not assignable to type 'Mapping<T>'.
    Types of parameters 'arg' and 'arg' are incompatible.
      Type 'Foo[T]' is not assignable to type 'string'.
        Type 'string | number' is not assignable to type 'string'.
          Type 'number' is not assignable to type 'string'.

It seems to think that the result of mapping[t] could be any of the possible records, when we know that it has to be just one.

Why? And how do I convince typescript otherwise?

CodePudding user response:

Few side notes:

First:

const mappings: MapRecord = {
  [Fs.A]: (arg: string) => 123,
  [Fs.B]: (arg: number) => "asdf",
} as const;

Don't use explicit type MapRecord with as const. mappings will be infered as MapRecord. as const does not affect the type of mappings as all. Hence, you need to apply one or another bot not both.

Second:

The main problem is in this line: mappings[t]. mappings[t] returns a union of functions. When you want to call a union of functions, their arguments are intersected. Hence you are getting number & string === never. Please see the docs:

Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred:

Please see related answer, article, article


In order to infer mappings an allow it, you should also pass mappings as an argument. I usually curry it.

You can use this approach:

// We define a couple of symbols
const A: unique symbol = Symbol();
const B: unique symbol = Symbol();

// And create an "enum" of them
const Fs = {
    A,
    B
} as const;


// This is our "input" types
type Foo = {
    [Fs.A]: string,
    [Fs.B]: number,
}

// This is our "output" types
type Bar = {
    [Fs.A]: number,
    [Fs.B]: string,
}

// Here is the action mapping. It does indeed comply with map record.
const mappings = {
    [Fs.A]: (arg: string) => 123,
    [Fs.B]: (arg: number) => "asdf",
} as const;


// This is the type A | B
type F = typeof Fs[keyof typeof Fs];

const doMapping = <
    Key extends symbol,
    Value extends (arg: any) => any, // MAIN DRAWBACK
    Mapping extends Record<Key, Value>
>(mapping: Mapping) =>
    <
        Type extends keyof Mapping,
        >(type: Type, arg: Parameters<Mapping[Type]>[0]): ReturnType<Mapping[Type]> =>
        mapping[type](arg)

const result = doMapping(mappings)(Fs.A, 's') // number
const result2 = doMapping(mappings)(Fs.B, 42) // number

const error = doMapping(mappings)(Fs.B, 'a') // expected error

Playground

Key - is infered key of main argument Value - is infered function Mapping - is infered argument.

As you might have noticed it is similar to how we destructure JS objects. In similar way it is possible to infer them. The algorithm: infer each nested key and value and then assemble them into one data structure.

Type - infered first argument with appropriate constraint

You can find more information about function arguments inference in my blog

Personally, I think safest approach is just return a function:

// We define a couple of symbols
const A: unique symbol = Symbol();
const B: unique symbol = Symbol();

// And create an "enum" of them
const Fs = {
    A,
    B
} as const;


// This is our "input" types
type Foo = {
    [Fs.A]: string,
    [Fs.B]: number,
}

// This is our "output" types
type Bar = {
    [Fs.A]: number,
    [Fs.B]: string,
}

// Here is the action mapping. It does indeed comply with map record.
const mappings = {
    [Fs.A]: (arg: string) => 123,
    [Fs.B]: (arg: number) => "asdf",
} as const;

type Mappings = typeof mappings

const doMapping = <Type extends keyof Mappings,>(type: Type) =>
    mappings[type]

const result = doMapping(Fs.A) // (arg:string)=>number

Playground

Easy and simple.

  • Related