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

Type inference is fuzzy / blackbox-y #16774

Closed
EmielM opened this issue Jun 27, 2017 · 5 comments
Closed

Type inference is fuzzy / blackbox-y #16774

EmielM opened this issue Jun 27, 2017 · 5 comments
Labels
Discussion Issues which may not have code impact

Comments

@EmielM
Copy link

EmielM commented Jun 27, 2017

TypeScript Version: 2.5.0-dev.20170627

When a generic type has multiple constraints (through extends in generic declaration), it is very hard to predict which one the compiler uses for type inference. I would expect this to be deterministic/predictable.

Two examples (and excuse the verbosity of the code, found it hard to replicate with less):

Listing 1

class Data<T> {
	constructor(v: T) {}
}

type Bound<O> = {
	[K in keyof O]: string | number | boolean
}

type Binding<O> = {
	[K in keyof O]: O[K] | Data<O[K]> | (() => Data<O[K]>)
}

function bind<O extends Bound<O>>(b: Binding<O>, c: ( (a: O) => {} )): null {
	return null
}

var MyComponent = bind({
	a: () => new Data<number>(123),
	b: new Data<boolean>(true)
},
function({a,b}) {
	var j : number = a;
	var l : boolean = b;
	return null;
});

MyComponent is type inferred as bind<{},{a: number, b: boolean}>... as I would expect. This snippet compiles fine.

Listing 2

class Data<T> {
	constructor(v: T) {}
}

type Bound<O> = {
	[K in keyof O]: string | number | boolean
}

type Binding<O> = {
	[K in keyof O]: O[K] | Data<O[K]> | (() => Data<O[K]>) | (() => O[K])
}

function bind<O extends Bound<O>>(b: Binding<O>, c: ( (a: O) => {} )): null {
	return null
}

var MyComponent = bind({
	a: () => new Data<number>(123),
	b: new Data<boolean>(true)
},
function({a,b}) {
	var j : number = a;
	var l : boolean = b;
	return null;
});

Binding<..> now has an additional map value in the form of | (() => O[K]). For some reason, this triggers MyComponent to be inferred as bind<{}, { a: string | number | boolean, b: string | number | boolean }> (which is the type of the Bound). And it then fails to compile because the assignment in the function is incorrect.

I can certainly see type inference under these kind of complex conditions is never going to be trivial, but it's really hard to have any clue as to what's happening here.

Does it work like this because of the spec, because (of some exploration bounds in) the implementation? And is there something I can do to force the inference towards the first case?

I'm posting here as to maybe have a more generic discussion about how such complex cases might be made more approachable by normal programmers. If this is better suited on SO, please do close the ticket.

@RyanCavanaugh RyanCavanaugh added the Discussion Issues which may not have code impact label Jun 27, 2017
@RyanCavanaugh
Copy link
Member

Can you simplify your examples to not depend on things from the React namespace?

@EmielM
Copy link
Author

EmielM commented Jun 28, 2017

@RyanCavanaugh Apologies, it was a long evening. I've updated the code.

@RyanCavanaugh
Copy link
Member

I would expect this to be deterministic/predictable.

It is definitely deterministic.

As for predictable, well, it's generic type inference and I can't guarantee anyone will be able to keep the entire program state in their head as well as a computer can.

A short version of the inference process is:

  • "Line up" the types as best we can so that type parameters appear in positions corresponding to concrete types (e.g. given string | {x : T}, figure out that {x: number} is an inference for the object type)
  • Collect those places where we find concrete types matching the generic types (e.g. add number to the candidate list)
  • Choose the "best" candidate from the candidate list

In your case, the problem is here:

[K in keyof O]: O[K] | Data<O[K]> | (() => Data<O[K]>) | (() => O[K])
                                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Both of those function types create inference sites for the corresponding property and we can't figure out which to prefer.

A simpler example with a possible fix:

    class Data<T> {
        x: T;
        constructor(v: T) { }
    }

    type Binding1<O> = {
        [K in keyof O]: Data<O[K]> |
        (() => Data<O[K]>) |
        (() => O[K]);
    }

    type Binding2<O> = {
        [K in keyof O]: Data<O[K]> |
        (() => (Data<O[K]> | O[K]));
    }

    declare function bind1<O>(b: Binding1<O>): O;
    declare function bind2<O>(b: Binding2<O>): O;

    const obj = {
        a: () => new Data<number>(123),
        b: new Data<boolean>(true)
    };
    const b1 = bind1(obj);
    b1.a.toFixed();
    const b2 = bind2(obj);
    b2.a.toFixed();

Alternatively:

    class Data<T> {
        x: T;
        constructor(v: T) { }
    }

    type Primitive = string | number | boolean
    type Bound<O> = {
        [K in keyof O]: Primitive
    }

    type Binding<O> = {
        [K in keyof O]: O[K] | Data<O[K]> | (() => Data<O[K]>) | (() => Primitive)
    }

    function bind<O extends Bound<O>>(b: Binding<O>, c: ((a: O) => {})): null {
        return null
    }

    var MyComponent = bind({
        a: () => new Data<number>(123),
        b: new Data<boolean>(true)
    },
    function ({ a, b }) {
        var j: number = a;
        var l: boolean = b;
        return null;
    });

@EmielM
Copy link
Author

EmielM commented Jun 29, 2017

@RyanCavanaugh You're a legend, thanks for your time and pointers.

I still have the feeling inference could be better with those generic constrains. I've simplified a bit further:

Basic case:

class Data<T> {
    constructor(v: T) { }
}

type Binding<O> = {
    [K in keyof O]: O[K] | Data<O[K]> | ( () => O[K] | Data<O[K]> )
}

declare function bind<X>(b: Binding<X>): void;

bind({
    a: 123,
    b: () => 123,
    c: new Data(234),
    d: () => new Data(234)
});

This now gets inferred as: function bind<{ a: number; b: number | ( () => number ); c: number; d: number }>. This already surprises me, because without any constraints in this definition, I would have expected:

bind<{
    a: number;
    b: number | (() => number);
    c: number | Data<number>;
    d: Data<number> | number | (() => Data<number>);
}>

So here the matcher already makes some "choices", right (perhaps the part you're referring to as "best candidate" in your answer)?

Now, let's extend by adding a Constrain indicating we really want primitive values in X:

class Data<T> {
    constructor(v: T) { }
}

type Constrain<O> = {
    [k in keyof O]: number | string | boolean
}

type Binding<O> = {
    [K in keyof O]: O[K] | Data<O[K]> | ( () => O[K] | Data<O[K]> )
}

declare function bind<X extends Constrain<X>>(b: Binding<X>): void;

bind({
    a: 123,
    b: () => 123,
    c: new Data(234),
    d: () => new Data(234)
});

Because of the added constraint, the matcher now seems to go in an entirely different direction: bind<Constrain<{ a: number; b: number | (() => number); c: number; d: number; }>>
(which is equal to bind<a: number | string | boolean, b: number | string | boolean, c: number | string | boolean, d: number | string | boolean>).

Would it be hard for the inference to explore all the types, and properly evaluating Constrain<X> with previously known bounds of X?

In this case:

X: { a: number; b: number | (() => number); c: number | Data<number>; d: Data<number> | number | (() => Data<number>); }
Constrain<X>: {a: number | string | boolean, b: number | string | boolean, c: number | string | boolean, d: number | string | boolean}

ergo X extends Constrain<X>: {a: number, b: number, c: number, d: number}

@RyanCavanaugh
Copy link
Member

I believe your example there might require multiple passes, which effectively turns it into a unification algorithm. See also #30134

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Discussion Issues which may not have code impact
Projects
None yet
Development

No branches or pull requests

2 participants