Matt
Matt

Reputation: 43

Type Property Relying on Return Type of Another Property

I'm trying to pass an object to a function (in this case props to a React component).

This object contains the following properties:

I'm unsure how to type this out correctly.

I initially assumed it could be done this way:

type Props<D, S> = {
  data: D
  selector: (data: D) => S
  render: (data: S) => any
}

const Component = <D, S>(props: Props<D, S>) => null

Component({
  data: { test: true },
  selector: (data) => data.test,
  render: (test) => test, // test is unknown
})

This results in the generic, S, being unknown. However if we remove the render property which relies on S we get the correct return type (boolean).

I've also tried:

Upvotes: 4

Views: 740

Answers (2)

jcalz
jcalz

Reputation: 328152

Answer for TS4.7+

It looks like microsoft/TypeScript#48538 will be released with TypeScript 4.7, at which point object literals with methods will be able to have their parameters contextually typed by any generic type parameters inferred from previous members. Which means that your desired code will just work:

Component({
    data: { test: true },
    selector: (data) => data.test,
    render: (test) => test, // test is boolean, HOORAY! 🎉
})

Do note that this will be sensitive to order of object literal members, so you can't switch the order around without breaking things:

Component({
    data: { test: true },
    render: (test) => test, // test is unknown again
    selector: (data) => data.test,
})

Playground link for TS4.7+


Previous answer for TS4.6-

You've run into a design limitation of TypeScript. See microsoft/TypeScript#38872 for more information.

The problem is that you gave selector and render property callbacks whose parameters (data and test) had no explicit type annotation. Thus they are contextually typed; the compiler needs to infer types for these parameters and cannot use them directly to infer other types. The compiler holds off on this and tries to infer D and S from what it currently knows. It can infer D as {test: boolean} because the data property is of this type. But it has no idea what to infer S as, and it defaults to unknown. At this point the compiler can begin to perform contextual typing: the data parameter of the selector callback is now known to be of type {test: boolean}, but the test parameter of the render callback is given the type unknown. At this point, type inference ends, and you're stuck.

According to a comment by the lead architect of TypeScript:

In order to support this particular scenario we'd need additional inference phases, i.e. one per contextually sensitive property value, similar to what we do for multiple contextually sensitive parameters. Not clear that we want to venture there, and it would still be sensitive to the order in which object literal members are written. Ultimately there are limits to what we can do without full unification in type inference.


So what can be done? The problem is the interplay between contextual callback parameter types and generic parameter inference. If you're willing to give one of those up, you can cut the Gordian Knot of type inference. For example, you can give up some contextual callback parameter typing by manually annotating some callback parameter types:

Component({
  data: { test: true },
  selector: (data: { test: boolean }) => data.test, // annotate here
  render: (test) => test, // test is boolean
})

Or, you can give up on generic parameter type inference by manually specifying your generic type parameters:

Component<{ test: boolean }, boolean>({ // specify here
  data: { test: true },
  selector: (data) => data.test,
  render: (test) => test, // test is boolean
})

Or, if you're not willing to do that, maybe you can create your Props<D, S> values in stages where each stage only requires a little bit of type inference. For example, you can replace property values with function parameters (see above quote "similar to what we do for multiple contextually sensitive parameters"):

const makeProps = <D, S>(
  data: D, selector: (data: D) => S, render: (data: S) => any
): Props<D, S> => ({ data, selector, render });

Component(makeProps(
  { test: true },
  (data) => data.test,
  (test) => test // boolean
));

Or, more verbosely but possibly more understandable, use afluent builder pattern:

const PropsBuilder = {
  data: <D,>(data: D) => ({
    selector: <S,>(selector: (data: D) => S) => ({
      render: (render: (data: S) => any): Props<D, S> => ({
        data, selector, render
      })
    })
  })
}

Component(PropsBuilder
  .data({ test: true })
  .selector((data) => data.test)
  .render((test) => test) // boolean
);

I tend to prefer builder patterns in cases where TypeScript's type inference capabilities fall short, but it's up to you.

Playground link for TS4.6-

Upvotes: 6

Here you have a solution:

import React from 'react'

type Props<D, S> = {
  data: D
  selector: (data: D) => S
  render: (data: S) => any
}


const Comp = <D, S>(props: Props<D, S>) => null

const result = <Comp<number, string> data={2} selector={(data: number) => 'fg'} render={(data: string) => 42} /> // ok
const result2 = <Comp<number, string> data={2} selector={(data: string) => 'fg'} render={(data: string) => 42} /> // expected error
const result3 = <Comp<number, string> data={2} selector={(data: number) => 'fg'} render={(data: number) => 42} /> // expected error

You should explicitly define generics for component

Upvotes: 1

Related Questions