Search code examples
javascripttypescript

How can we relate two function parameters in TypeScript, in a generic function?


I am struggling adding the right types for function parameters, where the function should be generic and the parameters should relate to each other.

Here is a minimal reproducible example (in the actual code, the type Foo and the object objectOne come from a library that I cannot change, and the function func is one I am trying to create myself):

interface Foo {
  union: 'one' | 'two' | 'three';
  object: {
    One: {'oneRelatedKey': 'oneRelatedValue'};
    Two: {'twoRelatedKey': 'twoRelatedValue'};
    Three: {'threeRelatedKey': 'threeRelatedValue'};
  };
};

const objectOne = {
  one: {
    func: (obj: Foo['object']['One']) => {console.log(obj)}
  },
  two: {
    func: (obj: Foo['object']['Two']) => {console.log(obj)}
  },
  three: {
    func: (obj: Foo['object']['Three']) => {console.log(obj)}
  },
};

const func = <T extends Foo['union']>(one: T, two: Foo['object'][Capitalize<T>]) => {
  objectOne[one].func(two);
}

I am getting an error on two:

Property ''twoRelatedKey'' is missing in type '{ oneRelatedKey: "oneRelatedValue"; }' but required in type '{ twoRelatedKey: "twoRelatedValue"; }'

I want to ensure that func is called with a string from Foo['union'] and the corresponding object from Foo['object'], indexed on the capitalized version of the passed in Foo['union'] argument.

It should then work to call func as follows:

func('one', { 'oneRelatedKey': 'foo' })

Note: Only the parameter types should be changed, not the type of objectOne (the object comes from a library and the MRE is a simplified version).


I tried the following, and it seems closer:

type FirstTestGeneric<T extends Foo['union']> = T;
type FirstTest = FirstTestGeneric<'one'>; // 'one'

type SecondTestGeneric<T extends Foo['union']> = Capitalize<T> extends keyof Foo['object'] ? Foo['object'][Capitalize<T>] : never;
type SecondTest = SecondTestGeneric<'one'>; // {'oneRelatedKey': string}

const testFunc = <T>(one: FirstTestGeneric<'one'>, two: SecondTestGeneric<'one'>) => {
  objectOne[one].func(two);
}

Now I just need to somehow modify testFunc so it uses the type parameter. For some reason, this does not work:

const testFunc = <T extends Foo['union']>(one: FirstTestGeneric<T>, two: SecondTestGeneric<T>) => {
  objectOne[one].func(two);
}

I think the problem is that the type T accepts a union. If it just accepted one string (from the union Foo['union']), it would probably work.


I found the following: Restrict generic typescript type to single string literal value, disallowing unions

Trying to apply that here, we can do:

type GenericType<T extends Foo['union'] = Foo['union']> = { [U in T]: {
  type: U;
  parameter: Foo['object'][Capitalize<U>];
} }[T];


const testFuncOne = <T extends Foo['union']>(input: GenericType<T>) => {
  objectOne[input.type].func(input.parameter);
}

We still get this error on func(input.parameter):

Property ''twoRelatedKey'' is missing in type '{ oneRelatedKey: string; }' but required in type '{ twoRelatedKey: string; }'

For some reason, func is expecting an argument of the following type:

(property) func: (obj: {
    oneRelatedKey: string;
} & {
    twoRelatedKey: string;
} & {
    threeRelatedKey: string;
}) => void

I have a sneaking suspicion this has something to do with functions being contra-variant in their parameters.


Solution

  • It's possible with a little type manipulation:

    interface Foo {
      union: 'one' | 'two' | 'three';
      object: {
        One: {'oneRelatedKey': 'oneRelatedValue'};
        Two: {'twoRelatedKey': 'twoRelatedValue'};
        Three: {'threeRelatedKey': 'threeRelatedValue'};
      };
    }
    
    type OneObjType = {
      [K in Foo['union']]: { func: (x: Foo['object'][Capitalize<K>]) => void }
    }
    
    const objectOne = {
      one: {
        func: (obj: Foo['object']['One']) => {console.log(obj)}
      },
      two: {
        func: (obj: Foo['object']['Two']) => {console.log(obj)}
      },
      three: {
        func: (obj: Foo['object']['Three']) => {console.log(obj)}
      },
    };
    
    const retyped: OneObjType = objectOne; // NOTE: type-safe, no cast
    
    const func = <T extends Foo['union']>(one: T, two: Foo['object'][Capitalize<T>]) => {
      retyped[one].func(two);
    }
    

    Note that the signature of your original function was correct and I haven't modified it. The trick is to get the compiler to accept that the parameters to the methods in objectOne map back to the union and that the union connects to the corresponding objects in the Foo interface by using the alias retyped. Note also this is a reinterpreting of the type not a cast: the compiler still ensures safety.

    Playground

    Edit based on comments:

    The reason this works but your original code doesn't has to do with the type of objectOne, which you index into and call a method on in the body of func. The compiler does not understand that the keys in objectOne are related to Foo['union'] and that the parameters to the func methods of that object relate to the values of Foo['object']. All the information to determine that is available, we humans can see it, but the compiler has to infer the most general type it can (and in the 99% case that's actually the behavior you want) so it can't just assume what you want it to here.

    There are a couple of ways to override that behavior, one is to use the as const qualifier to tell the compiler to infer the narrowest type it can rather than the widest. But that would require modifying the annotation of the actual objectOne value, which you can't do since you're getting it from a 3rd party library (it also won't work if objectOne is mutable and not truly const, as const will make properties readonly).

    Another way though is to connect the dots for the compiler by assigning an existing value to a compatible but narrower type (than the one it already has), which is the approach I've taken here: I extract a mapped type from Foo called OneObjType that makes explicit how the keys and func method parameters map back to Foo.

    For a simplified example of the "assign to an alias with a narrower type", consider the following:

    const x: 'a' | 'b' = 'a';
    const y: 'a' = x; // legal narrowing assignment
    
    function foo(a: 'a') {}
    foo(x);  // ERROR: cannot assign 'a' | 'b' to 'a'
    foo(y);  // Fine, no error!
    x === y; // true
    

    Even though 'a' is a narrower type than 'a' | 'b' the assignment to y is legal because the term-level value is a const and the compiler can prove it's safe. You can then use y in a place that requires the literal type 'a', but you can't use x because you can't use 'a' | 'b' to satisfy a type that requires 'a'. This is true even though y is just an alias for x. They point to the same term-level value (the string literal 'a') but x and y have different types, and you can do things with one you can't with the other (like pass to foo).

    Making the assignment const retyped: OneObjType = objectOne; doesn't change objectOne at all, it just creates a new name for it that has a different but compatible type (which is why the compiler allows the assignment), and we can safely call the related methods by using that different view of the object: retyped[one].func(two); works because retyped is just an alias for objectOne but it has a different type, one that the compiler understands maps back to the Foo interface. It's just a way to tell the compiler to treat objectOne as the narrower type OneObjType rather than the type it already has when we refer to it via the retyped alias.

    This isn't that different than using type narrowing via conditional checks except that it happens purely at compile-time, there's no runtime code that needs to execute to narrow the type, making for a more elegant solution.

    Note that all of this works even if you get Foo and objectOne from a library because we don't modify either one of them at all, we just spell out the relation they have to each other so the compiler can understand it.