Tom Crockett
Tom Crockett

Reputation: 31579

TypeScript parameter type inference failure

I created a bug report for this, but perhaps someone has an idea for a workaround. Basically I want to have some arguments to a function based conditionally on the type of another:

declare function foo<P>(params: { f: (p: P) => void } & P): void;

foo({
  f(params: { bar(x: number): void }) {},
  bar(x) {},
});

So there is a special parameter f which has a function type, and whatever properties that function expects must be included as arguments to foo. Here again the parameter to bar is inferred as any but should be number.

Upvotes: 10

Views: 885

Answers (2)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249586

As we discussed in the comments the scenario is actually related to react components and the way the new generic component feature interacts with default generic parameters.

The problem

If we have a react component that has a generic parameter and the default value for the generic parameter has function field, the parameters to the function we pass in will not be inferred. A simple example would be:

  class Foo<P = { fn : (e: string)=> void }> extends React.Component<P>{}
  let foo = () => (
    <div>
      <Foo fn={e=> e}/> {/* e is any */}
    </div>
  )

Possible solution

Let me preface this with a caveat, I don't know it this works in all situations, or whether this depends on some compiler behavior that will change. What I did find is that it is a very fragile solution, change any detail, even with something that might reasonably be considered equivalent and it will not work. What I can say is that as presented it worked for what I tested it on.

The solution is to take advantage of the fact that the property types are taken from the return value of the constructor, and if we simplify things a little bit for the default generic parameter we get the inference we want. The only problem is detecting that we are dealing with the default generic parameter. We can do this by adding an extra field to the default parameter and then test for it using conditional types. Just one more caveat, this does not work for simple cases where P can be inferred based on used properties, so we will have an example where the component extracts properties from another component (which is actually closer to your use case):

// Do not change { __default?:true } to DefaultGenericParameter it only works with the former for some reason
type IfDefaultParameter<C, Default, NonDefault> = C extends { __default?:true } ? Default : NonDefault
type DefaultGenericParameter = { __default? : true; }

export type InferredProps<C extends React.ReactType> =
  C extends React.ComponentType<infer P> ? P :
  C extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[C] :
  {};

// The props for Foo, will extract props from the generic parameter passed in 
type FooProps<T extends React.ReactType = React.ComponentClass<{ fn: (s: string )=> void }>> = InferredProps<T> & {
  other?: string
}

// Actual component class
class _Foo<P> extends React.Component<P>{}

// The public facing constructor 
const Foo : {
  new <P extends React.ReactType & {__default?: true} = React.ComponentClass<{ fn: (s: string )=> void }> & DefaultGenericParameter>(p: P) :
  IfDefaultParameter<P, _Foo<FooProps>, _Foo<FooProps<P>>>
} = _Foo as any;

  let foo = () => (
    <div>
      <Foo fn={e=> e}/> {/* e is string */}
      <Foo<React.ComponentClass<{ fn: (s: number )=> void }>> fn={e=> e}/> {/* e is number */}
    </div>
  )

Applied in material ui

The one sample I have tried in the material-ui repo is the Avatar component and it seems to work:

export type AvatarProps<C extends AnyComponent = AnyComponent> = StandardProps<
  PassthruProps<C, 'div'>,
  AvatarClassKey
> & {
  alt?: string;
  childrenClassName?: string;
  component?: C;
  imgProps?: React.HtmlHTMLAttributes<HTMLImageElement>;
  sizes?: string;
  src?: string;
  srcSet?: string;
};

type IfDefaultParameter<C, Default, NonDefault> = C extends { __default?:true } ? Default : NonDefault
type DefaultGenericParameter = { __default? : true; }

declare const Avatar : {
  new <C extends AnyComponent & DefaultGenericParameter = 'div' & DefaultGenericParameter>(props: C) 
  : IfDefaultParameter<C, React.Component<AvatarProps<'div'>>, React.Component<AvatarProps<C>>>
}

Usage

const AvatarTest = () => (
  <div>
    {/* e is React.MouseEvent<HTMLDivElement>*/}
    <Avatar onClick={e => log(e)} alt="Image Alt" src="example.jpg" />
    {/* e is {/* e is React.MouseEvent<HTMLButtonElement>*/}*/}
    <Avatar<'button'> onClick={e => log(e)} component="button" alt="Image Alt" src="example.jpg" />
    <Avatar<React.SFC<{ x: number }>>
      component={props => <div>{props.x}</div>} // props is props: {x: number;} & {children?: React.ReactNode;}
      x={3} // ok 
      // IS NOT allowed:
      // onClick={e => log(e)}
      alt="Image Alt"
      src="example.jpg"
    />
  </div>

Good luck with this, let me know if any of it helped. It was a fun little problem that has been keeping me awake for 10 days now :)

Upvotes: 2

artem
artem

Reputation: 51619

Whenever I see a requirement to have function type that depends on argument values, "use overloads" is the first thing that comes to mind:

declare function foo(params: { tag: 'a', bar(x: number): void }): void;
declare function foo(params: { tag: 'b', bar(x: boolean): void }): void;

foo({ tag: 'a', bar(x) { console.log('x =', x)} }); // (parameter) x: number

Update I assume that in the following code foo and f are React components, and foo exposes f as its property, as well as all the properties that f has.

declare function foo<P>(params: { f: (p: P) => void } & P): void;

foo({
  f(params: { bar(x: number): void }) {},
  bar(x) {},
});

There is a way to rewrite it, it you don't insist that the name of f (which is a property of foo), as well as types of f properties must be declared inline within foo arguments when calling it.

You can declare a type that describes properties of such composed component, it takes two generic parameters: the type of inner component properties, and the name of inner component in outer properties:

type ComposedProps<InnerProps, N extends string> = 
       InnerProps & { [n in N]: (props: InnerProps) => void };  

// Also I assume in reality it does not return `void` but `React.Element`,
//  but I don't think it matters

declare function foo<P>(params: P): void;

// foo generic parameter is given explicitly,
//  which allows to infer (and not repeat) parameter types for `f` and `bar` 
foo<ComposedProps<{bar(x: number): void}, 'f'>>({
  f(params) {}, // params: { bar(x: number): void; }
  bar(x) {}, // (parameter) x: number
});

Upvotes: 4

Related Questions