Search code examples
typescripttypesmapped-typestypescript-types

TypeScript Mapped Types - Is there a way to use interface value type as parameter of function type in mapped type?


I'm not sure how I might precisely describe my goal in words. Here is some code to explain what I would like to do:

type validTypes = string | number

type item = Record<string, validTypes>

interface Test extends item {
  someint: number;
  something: string;
}

let test: {
  [P in keyof Test]: (arg: Test[P]) => boolean
} = {
  someint: (arg) => arg > 0,
  something: (arg) => arg == "hi"  
}

I'm trying to make the mapped type [P in keyof Test]: (arg: Test[P]) => Test[P] work, however, it's giving me the following error:

Type '{ someint: (arg: number) => boolean; something: (arg: string) => boolean; }' is not assignable to type '{ [x: string]: (arg: validTypes) => boolean; someint: (arg: number) => boolean; something: (arg: string) => boolean; }'.
  Property 'someint' is incompatible with index signature.
    Type '(arg: number) => boolean' is not assignable to type '(arg: validTypes) => boolean'.
      Types of parameters 'arg' and 'arg' are incompatible.
        Type 'validTypes' is not assignable to type 'number'.
          Type 'string' is not assignable to type 'number'.

This is quite confusing to me, as [P in keyof Test]: Test[P] (without the function) seems to work. The error seems to appear when I try to use Test[P] as a parameter in the function type.


Solution

  • Unfortunately the type of test (which you can inspect via IntelliSense) is:

    let test: {
        [x: string]: (arg: ValidTypes) => boolean;
        someint: (arg: number) => boolean;
        something: (arg: string) => boolean;
    }
    

    And this is not a valid type. Indeed, if you tried to create your own variable of that type explicitly, the compiler would complain:

    declare let test2: {
        [x: string]: (arg: ValidTypes) => boolean;
        someint: (arg: number) => boolean; // error!
        //^^^^^ <-- not assignable to string index type '(arg: ValidTypes) => boolean'.
        something: (arg: string) => boolean;
        //^^^^^^^ <-- not assignable to string index type '(arg: ValidTypes) => boolean'.
    };
    

    The problem is that Item, and thus Test, has an index signature which gets mapped according to the same rule as the individual someint and something properties. The index signature property becomes (arg: string | number) => boolean, meaning that every individual property needs to be a function that accepts an argument of type string | number. But your someint and something properties become functions that only accept number and string respectively. With --strictFunctionTypes enabled (part of the --strict suite of compiler options), functions need to be contravariant in their parameter types (see Difference between Variance, Covariance, Contravariance and Bivariance in TypeScript). So if you widen the function parameter type you narrow the function type. But the type mapping you apply is unfortunately covariant, and it produces an invalid type to which no value will be seen as assignable.


    If you're only planning to access the someint and something properties of test then you don't really care about or want an index signature at all. Indeed I'm not sure you want an index signature in Test. So one way to proceed is to drop the index signature entirely:

    type Satisfies<T, U extends T> = U;
    type Test = Satisfies<Item,
        {
            someint: number,
            something: string
        }>;
    

    Here Test has no index signature; it's just {someint: number, something: string}. I've created a Satisfies helper type which acts kind of like a type-level satisfies operator. The above compiles, so Test is compatible with Item, but you're not extending it. And so the following compiles:

    let test: {
        [P in keyof Test]: (arg: Test[P]) => boolean
    } = {
        someint: (arg) => arg > 0,
        something: (arg) => arg == "hi"
    } // okay
    

    Or, if you need Test to have the index signature you can build it in two parts:

    interface TestWithoutIndexSignature {
        someint: number,
        something: string
    }
    interface Test extends Item, TestWithoutIndexSignature { }
    

    And then make sure test maps over TestWithoutIndexSignature instead of Test:

    let test: {
        [P in keyof TestWithoutIndexSignature]:
        (arg: TestWithoutIndexSignature[P]) => boolean
    } = {
        someint: (arg) => arg > 0,
        something: (arg) => arg == "hi"
    } // okay
    

    Finally, if you can't change Test at all, then you can compute TestWithoutIndexSignature from it, using key remapping in mapped type to filter out string from the keys:

    type TestWithoutIndexSignature =
        { [K in keyof Test as string extends K ? never : K]: Test[K] }
    /* type TestWithoutIndexSignature = {
        someint: number;
        something: string;
    } */
    
    let test: {
        [P in keyof TestWithoutIndexSignature]:
        (arg: TestWithoutIndexSignature[P]) => boolean
    } = {
        someint: (arg) => arg > 0,
        something: (arg) => arg == "hi"
    } // okay
    

    The particular approach is up to you, but the goal here is not to let test have an index signature, since it only hurts you.

    Playground link to code