Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Higher order function type inference #30215

Merged
merged 20 commits into from
Mar 8, 2019

Conversation

ahejlsberg
Copy link
Member

@ahejlsberg ahejlsberg commented Mar 4, 2019

With this PR we enable inference of generic function type results for generic functions that operate on other generic functions. For example:

declare function pipe<A extends any[], B, C>(ab: (...args: A) => B, bc: (b: B) => C): (...args: A) => C;

declare function list<T>(a: T): T[];
declare function box<V>(x: V): { value: V };

const listBox = pipe(list, box);  // <T>(a: T) => { value: T[] }
const boxList = pipe(box, list);  // <V>(x: V) => { value: V }[]

const x1 = listBox(42);  // { value: number[] }
const x2 = boxList("hello");  // { value: string }[]

const flip = <A, B, C>(f: (a: A, b: B) => C) => (b: B, a: A) => f(a, b);
const zip = <T, U>(x: T, y: U): [T, U] => [x, y];
const flipped = flip(zip);  // <T, U>(b: U, a: T) => [T, U]

const t1 = flipped(10, "hello");  // [string, number]
const t2 = flipped(true, 0);  // [number, boolean]

Previously, listBox would have type (x: any) => { value: any[] } with an ensuing loss of type safety downstream. boxList and flipped would likewise have type any in place of type parameters.

When an argument expression in a function call is of a generic function type, the type parameters of that function type are propagated onto the result type of the call if:

  • the called function is a generic function that returns a function type with a single call signature,
  • that single call signature doesn't itself introduce type parameters, and
  • in the left-to-right processing of the function call arguments, no inferences have been made for any of the type parameters referenced in the contextual type for the argument expression.

For example, in the call

const f = pipe(list, box);

as the arguments are processed left-to-right, nothing has been inferred for A and B upon processing the list argument. Therefore, the type parameter T from list is propagated onto the result of pipe and inferences are made in terms of that type parameter, inferring T for A and T[] for B. The box argument is then processed as before (because inferences exist for B), using the contextual type T[] for V in the instantiation of <V>(x: V) => { value: V } to produce (x: T[]) => { value: T[] }. Effectively, type parameter propagation happens only when we would have otherwise inferred the constraints of the called function's type parameters (which typically is the dreaded {}).

The above algorithm is not a complete unification algorithm and it is by no means perfect. In particular, it only delivers the desired outcome when types flow from left to right. However, this has always been the case for type argument inference in TypeScript, and it has the highly desired attribute of working well as code is being typed.

Note that this PR doesn't change our behavior for contextually typing arrow functions. For example, in

const f = pipe(x => [x], box);  // (x: any) => { value: any[] }

we infer any (the constraint declared by the pipe function) as we did before. We'll infer a generic type only if the arrow function explicitly declares a type parameter:

const f = pipe(<U>(x: U) => [x], box);  // <U>(x: U) => { value: U[] }

When necessary, inferred type parameters are given unique names:

declare function pipe2<A, B, C, D>(ab: (a: A) => B, cd: (c: C) => D): (a: [A, C]) => [B, D];

const f1 = pipe2(list, box);  // <T, V>(a: [T, V]) => [T[], { value: V }]
const f2 = pipe2(box, list);  // <V, T>(a: [V, T]) => [{ value: V }, T[]]
const f3 = pipe2(list, list);  // <T, T1>(a: [T, T1]) => [T[], T1[]]

Above, we rename the second T to T1 in the last example.

Fixes #417.
Fixes #3038.
Fixes #9366.
Fixes #9949.

@jack-williams
Copy link
Collaborator

jack-williams commented Mar 4, 2019

Is this the correct behaviour?

function ap<A>(fn: (x: A) => A, x: A): () => A {
    return () => fn(x);
}
declare function id<A>(x: A): A
const w = ap(id, 10); // Argument of type '10' is not assignable to parameter of type 'A'. [2345]

I think the logic that floats type parameters out when the return value is a generic function might be too eager. In existing cases where {} would have been inferred it might not be because the application is sufficiently generic, rather inference didn't produce anything sensible but that still type-checked.

I haven't looked into this much, so apologies in advance if I've got something wrong.

@ahejlsberg
Copy link
Member Author

@jack-williams The core issue here is the left to right processing of the arguments. Previously we'd get it wrong as well (in that we'd infer {} instead of number), but now what was a loss of type safety instead has become an error. Ideally we ought to first infer from arguments that are not generic functions and then process the generic functions afterwards, similar to what we do for contextually typed arrow functions and function expressions.

@jack-williams
Copy link
Collaborator

jack-williams commented Mar 5, 2019

@ahejlsberg Ah yes, that makes sense. I agree that it was previously 'wrong', but I disagree that it was a loss of type safety (in this example). The call is perfectly safe, it's just imprecise. Previously this code was fine:

const x = ap(id, 10)();
if (typeof x === "number" && x === 10) {
    const ten: 10 = x;
}

but it will now raise an error. Could this be a significant issue for existing code?

I'll just add the disclaimer that this is really cool stuff and I'm not trying to be pedantic or needlessly pick holes. I'd like to be able to contribute more but I really haven't got my head around the main inference machinery, so all I can really do is try out some examples.

@RyanCavanaugh
Copy link
Member

The safe/imprecise distinction is sort of lossy given certain operations that are considered well-defined in generics but illegal in concrete code, e.g.

function ap<A>(fn: (x: A) => A, x: A): (a: A) => boolean {
    return y => y === x;
}
declare function id<A>(x: A): A
let w = ap(id, 10)("s");

or by advantage of covariance when you didn't intend to

function ap<A>(fn: (x: A) => A, x: A[]): (a: A) => void {
  return a => x.push(a);
}
function id<A>(x: A): A { return x; }
const arr: number[] = [10];
ap(id, arr)("s");
// prints a string from a number array
console.log(arr[1]);

@jack-williams
Copy link
Collaborator

I think being lossy in isolation is ok; it's when you try and synthesise information that you get into trouble. The first example is IMO completely fine. The conjunction of union types, and equality over generics makes comparisons like that possible.

function eq<A>(x: A, y: A): boolean {
    return x === y;
}
eq<number | string>(10, "s");
eq(10, "s"); // error

It's just the behaviour of type inference that rejects the second application because it's probably not what the user intended. I accept that my definition of 'fine' here comes from a very narrow point-of-view, and that being 'fine' and unintentional is generally unhelpful.

I agree that the second example is very much borderline, though stuff like that is going to happen with covariance because you end up synthesising information.

I'm not trying to defend the existing behaviour; I'm all in favour of being more explicit. My only concern would stem from the fact that the new behaviour is somewhat unforgiving in that it flags the error immediately at the callsite. Users that previously handled the {} type downstream (either safely, or not), will now have to fix upstream breaks. I have no idea whether this is a practical problem, though.

On a slightly different note I wonder if the new inference behaviour could come with some improved error messages. There is a long way to go until TypeScript reaches the cryptic level of Haskell, but with generics always comes confusion. In particular, I wonder in the case of:

const w = ap(id, 10);

The error message is Argument of type '10' is not assignable to parameter of type 'A'. I wonder if there is a way to mark the parameter A as rigid and when relating to that type, suggest that the user given an instantiation for the call, and say that inference flows left-to-right.

@KiaraGrouwstra
Copy link
Contributor

@ikatyang @Aleksey-Bykov @whitecolor @HerringtonDarkholme @aluanhaddad @ccorcos @gcnew @goodmind

@zpdDG4gta8XKpMCd
Copy link

best day ever! thank you so much @ahejlsberg and the team you made my day! i don't really know what to wish for now (except HKT's 😛 )

@weswigham
Copy link
Member

The call to forwardRef in that issue provides type arguments, which in turn disables any potential automatic type parameter propagation.

@kalbert312
Copy link

I'm creating a declaration file for a function component that accepts generic props.
Snippet:

declare interface HeaderMenuItem<E extends object = ReactAnchorAttr> extends React.RefForwardingComponent<HTMLElement, HeaderMenuItemProps<E>> { }

How can I export this as a constant and as the default export so

<HeaderMenuItem<{ extendedProp: string }> extendedProp="foo" .../>

will work?

@weswigham
Copy link
Member

weswigham commented Aug 2, 2019

RefForwardingComponent declares its type parameters as static, but for a generic component, you need type parameters on the signature, ergo you make your component look like a ref forwarding component (and it will be compatible with one), but not actually inherit from the declared shape:

export const HeaderMenuItem: <E extends object = ReactAnchorAttr>(props: React.PropsWithChildren<HeaderMenuItemProps<E>>, ref: React.Ref<HTMLElement>): React.ReactElement;

@kalbert312
Copy link

That works, but was hoping it was possible to do something with the interface so all future changes on that interface are propagated to my definition. Is it not possible?

@weswigham
Copy link
Member

Not really, no. An interface simply can't have free type variables in the location you need for a generic component.

@bergerbo
Copy link

Hello @ahejlsberg !
Thanks for your work it's of great help !

I'm facing a weird issue regarding higher order function type inferance and I'd like your insight.

I'm trying to type a compose function and can't get it to work,
it's a mere copy of your pipe function, only with the parameters in reverse order.
Not only does it not infer, it doesn't even compile !

Here's what I have

declare function compose4<A extends any[], B, C, D, E>(
    de: (d: D) => E,
    cd: (c: C) => D,
    bc: (b: B) => C,
    ab: (...args: A) => B,
): (...args: A) => E;

const id = <T>(t: T) => t

const compose4id = compose4(id, id, id, id)

The error is positionned on the second parameter

Argument of type '<T>(t: T) => T' is not assignable to parameter of type '(c: T1) => T'.
  Type 'T1' is not assignable to type 'T'.
    'T1' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{}'.(2345)

On the other hand, the same function with parameters in piping order works fine :

declare function pipe4<A extends any[], B, C, D, E>(
    ab: (...args: A) => B,
    bc: (b: B) => C,
    cd: (c: C) => D,
    de: (d: D) => E,
): (...args: A) => E;

const pipe4id = pipe4(id, id, id, id)

Is this by design ? Do you think of any work around I might be able to use here ?

Thanks again for everything !

@bergerbo
Copy link

My bad found the related issue : #31738

@maraisr
Copy link
Member

maraisr commented Jan 8, 2020

RefForwardingComponent declares its type parameters as static, but for a generic component, you need type parameters on the signature, ergo you make your component look like a ref forwarding component (and it will be compatible with one), but not actually inherit from the declared shape:

export const HeaderMenuItem: <E extends object = ReactAnchorAttr>(props: React.PropsWithChildren<HeaderMenuItemProps<E>>, ref: React.Ref<HTMLElement>): React.ReactElement;

Further that, is there a reason why something this wouldnt work?

const Test: <PayloadType extends unknown, E = HTMLUListElement>(props: SuggestionProps<PayloadType> & RefAttributes<E>) => ReactElement
	= forwardRef((props: SuggestionProps<PayloadType>, ref: Ref<E>) => {
	return <h1>test</h1>;
});

cc @weswigham

@johnrom
Copy link

johnrom commented Jan 20, 2020

@maraisr PayloadType is not available in the right side of the assignment, it's only available within the type definition itself. You could also define it as a generic to your inner component, but then PayloadType1 will not be assignable to PayloadType2 because of this error:

const Test: <PayloadType extends unknown, E = HTMLUListElement>(
  props: PayloadType & React.RefAttributes<E>
) => React.ReactElement = React.forwardRef(
  <PayloadType extends unknown, E = HTMLUListElement>(props: PayloadType, ref: React.Ref<E>) => {
    return <h1>test</h1>;
  }
);
// ERROR: 'unknown' is assignable to the constraint of type 'PayloadType', but 'PayloadType' could be instantiated with a different subtype of constraint 'unknown'.

So right now, at least as far as I can tell, the answer is to add // @ts-ignore and hope the types never change. The reason being that the error is actually not applicable here as we are guaranteeing that PayloadType will not be instantiated with a different subtype. Using Test will then work exactly how you expect it to in TS.

It wouldn't be so bad if we could just ignore the specific error above, but sadly ts-ignore will ignore all issues. ref: #19139

@tony
Copy link

tony commented May 4, 2020

Hi there! What commit is this merged in? It's difficult to find in the issue since there's a lot of mentions.

Thanks

@DanielRosenwasser
Copy link
Member

TypeScript 3.4: https://github.com/Microsoft/TypeScript/wiki/Roadmap#34-march-2019

@tony
Copy link

tony commented May 4, 2020

Thank you @DanielRosenwasser!

@MeowningMaster
Copy link

Hello 👋. I'm trying to create a Provider class that stores a handler function and can be executed with some context:

interface ProviderHandler<A extends any[] = any[], R = any> {
  (this: RequestContext, ...args: A): R
}

type FlatPromise<T> = Promise<T extends Promise<infer E> ? E : T>

interface WrapCall {
  <T extends ProviderHandler>(provider: Provider<T>): T extends ProviderHandler<
    infer A,
    infer R
  >
    ? (...args: A) => FlatPromise<R>
    : never
}

interface RequestContext {
  /* ... */
  call: WrapCall
}

class Provider<T extends ProviderHandler = ProviderHandler> {
  handler: T

  constructor(options: { handler: T }) {
    this.handler = options.handler
  }
}

const context = { /* imagine it implemented */ } as RequestContext

Everything works great if handler is a simple function:

const p1 = new Provider({
    async handler() {
        return 5
    }
})

// r1 = number
const r1 = await context.call(p1)()

But it does not work with generic handlers:

const p2 = new Provider({
    async handler<T>(t: T) {
        return t
    }
})

// actual: r2 = unknown
// expected: r2 = string
const r2 = await context.call(p2)('text')

Playground link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet