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

Generic typing inference composition work as variable not as function #30808

Closed
MasGaNo opened this issue Apr 8, 2019 · 10 comments
Closed

Generic typing inference composition work as variable not as function #30808

MasGaNo opened this issue Apr 8, 2019 · 10 comments
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed

Comments

@MasGaNo
Copy link

MasGaNo commented Apr 8, 2019

TypeScript Version: 3.4.2

Search Terms:
Generic variable vs generic function
Generic composition
Generic inference parameter in function

Code
Let's consider this code

/**
 * Extract object props by a specific value
 */
type PickByValue<T, ValueType> = Pick<T, {
  [Key in keyof T]: T[Key] extends ValueType ? Key : never
}[keyof T]>;

/**
 * Map keys by value-compatibility. Non-recursive.
 */
type MatchKeys<T, U> = {
  [P in keyof T]: keyof PickByValue<U, T[P]>;
};

/**
 * Like keyof, but for value
 */
type ValueOf<A extends Object> = Exclude<A[keyof A], undefined>;

/**
 * The src props we have
 */
export interface SrcPropsInterface {
  srcAttributeString: string;
  srcAttributeNumber: number;
}

/**
 * The dest props we want to apply
 */
export interface DestPropsInterface {
    destAttributesString: string;
    anotherAttributesString: string;
    destAttributesNumber: number;
    anotherAttributesNumber: number;
}

In a variable approach, the following code works perfectly with all completion:

/**
 * Here it's perfectly constraint
 */
type Combi = Partial<MatchKeys<SrcPropsInterface, DestPropsInterface>>;
const comb: Combi = {
    srcAttributeNumber: "anotherAttributesNumber",
    srcAttributeString: 'destAttributesString',
    // toto: 'test' // Error as expected
};

image

With a generic function approach, we get some weird things:

declare const obj: DestPropsInterface;

// Try generic way + inference
/**
 * Return a object that exposed the missing attributes that still need to be fill somewhere
 */
function map<
    DestProps,
    T extends Partial<MatchKeys<SrcPropsInterface, DestProps>>
    >(data: DestProps, map: T) {

    // Consider the Proxy code here
    const obj = {} as Pick<DestProps, Exclude<keyof DestProps, ValueOf<T>>>;
    return obj;
}
Case 1: no completion anymore
const target = map(obj, {
});

image
But as soon as I write blindly a known attribute from SrcPropsInterface, the type checking work:

const target = map(obj, {
    srcAttributeNumber: 'anotherAttributesNumber',
    srcAttributeString: 'badValue' // error as expected
});

image

Case 2: any-like

We can put any kind of value and there is no complaint (and naturally no completion):

const target = map(obj, {
    srcAttributeNumber: 'anotherAttributesNumber',
    foo: 'bar',
    bar: 42
});
Case 3: no output anymore

As soon as the map object is correctly fill, the target object get correctly all remaining attribute:

const target = map(obj, {
    srcAttributeNumber: 'anotherAttributesNumber',
});
target.anotherAttributesString; // Ok
target.destAttributesNumber; // Ok
target.destAttributesString; // Ok
target.anotherAttributesNumber; // Error as expected: we already remove it

image
But if we provide a wrong map because the type checker doesn't provide the correct feedback, target is converted as never:

const target = map(obj, {
    srcAttributeNumber: 'anotherAttributesNumber',
    foo: 'bar'
});

image

Expected behavior:
Case 1: get correct type checker in the map parameters like for the variable approach
Case 2: I suppose it's just a side-effect of Case 1, but I expect to have TypeScript wiping me by attempting to introduce unknown parameters.
Case 3: I suppose it's just a side-effect of Case 1 and 2.

Actual behavior:
Check Case 1, Case 2 and Case 3: no type checking on the generic map parameter whereas in a variable approach, it works.

Playground Link:
Playground @ 2019-04-08

Related Issues:
#28938

@MasGaNo
Copy link
Author

MasGaNo commented Apr 8, 2019

Btw, everything work perfectly when I set explicitly all generic parameters:

const target = map<DestPropsInterface, Partial<MatchKeys<SrcPropsInterface, DestPropsInterface>>>(obj, {
    srcAttributeNumber: 'anotherAttributesNumber',
    srcAttributeString: 'destAttributesString',
    foo: 'bar' // Error as expected
});

@MasGaNo
Copy link
Author

MasGaNo commented Apr 8, 2019

Actually, with this approach, I get another strange thing:

function inject<DestProps>(data: DestProps) {
    return function <SrcProps>() {
        return function <Map extends Partial<MatchKeys<SrcProps, DestProps>>>(map: Map) {
            // Consider the Proxy code here
            const obj = {} as Pick<DestProps, Exclude<keyof DestProps, ValueOf<Map>>>;
            return obj;
        }
    }
}

inject<DestPropsInterface>(obj)<SrcPropsInterface>()({
    srcAttributeNumber: 'anotherAttributesNumber',
    foo: 'bar' // Still doesn't throw error
});

I still don't have any completion, but as soon as I write some letters, I remove the hint and ask again for the completion, I get some suggestion:
chrome-capture

@RyanCavanaugh RyanCavanaugh added the Needs More Info The issue still hasn't been fully clarified label Apr 9, 2019
@RyanCavanaugh
Copy link
Member

There's a lot going on here I don't understand which part(s) you consider to be the "bug" here - can you simplify this down into a basic example of a small amount of setup + what code you'd expect to error/not error?

@MasGaNo
Copy link
Author

MasGaNo commented Apr 9, 2019

Ok, I will try to simplify and to isolate which part of the code creates the bug.

@MasGaNo
Copy link
Author

MasGaNo commented Apr 9, 2019

Just simplified the code, and I think I just figured out the problem, a problem from my side 😓
So there is no bug directly... I will try to explain with this piece of code:

function map<T, P extends Partial<T>>(data: T, map: P) {
    return {};
}

interface Props {
    foo: string;
    bar: string;
}
declare const dest: Props;

Here if I just write something like, I have as expected some errors:

map(dest, {
    unknownProps: '',
});
// Argument of type '{ unknownProps: string; }' is not assignable to parameter of type 'Partial<Props>'.
//  Object literal may only specify known properties, and 'unknownProps' does not exist in type 'Partial<Props>'.

But as soon as I put a "known" attribute, everything is okay, as it fill the contract of the type as it extends the Partial type:

map(dest, {
    bar: '', // Adding at least 1 known property make everything fine
    unknownProps: '', // even adding unknown property
});

And changing the definition with this approach:

function map<T, P = Partial<T>>(data: T, map: P) {
    return {};
}

is not helpful at all, as at this moment, P works with inference.

Actually, I wanted to have this effect, but without creating another sub-function and/or passing explicitly all Generic Parameters, but correct me if I'm wrong, seems not to be possible, don't it?:

function map<T>(data: T) {
    return function (map: Partial<T>) { };
}

map(dest)({
    bar: '', // Only known attributes are admit
    foo: '',
    // unknownProps: '', // And unknown attribute are correctly rejected as expected
});

image

Buuuuuut, there is still one bug IMHO with the first implementation:

function map<T, P extends Partial<T>>(data: T, map: P) {
    return {};
}

Why don't I have any suggestion?:
image
Except if I write at least, one letter:
image
?

@RyanCavanaugh
Copy link
Member

function map<T, P extends Partial<T>>(data: T, map: P) {
    return {};
}

You don't get suggestions here because {} is a valid P / Partial<T>, so the call is considered 100% valid. Probably you want to define this as map<T>(data: T, map: Partial<T>) if possible

@MasGaNo
Copy link
Author

MasGaNo commented Apr 9, 2019

Yes, I can understand that {} is a valid P/Partial<T>, like when we say:

interface Props {
    foo: string;
    bar: string;
}

const test: Partial<Props> = {}; // Valid

But here, I still having all potential known values:
image
But for development purpose and suggestion, it's very surprising to not having suggestion on all potential values.
It's like with this approach:

function map<T, P extends (Partial<T> & { a: string })>(data: T, map: P) {
    return {};
}

interface Props {
    foo: string;
    bar: string;
}

declare const dest: Props;
map(dest, {
   // It throws an error as we expect to have at least the required `a: string` attribute 
})

Here, we get the full suggestion list:
image
But after providing the a attribute, suggestion disappear:
image
It's a little bit weird as experience IMHO, and that's make me confused about my first post, as I wasn't focus on the main problem.

What do you think?

@RyanCavanaugh
Copy link
Member

It's vexing. The language service is asking the checker what the type of the surrounding object literal is in order to show completions. The valid and correct answer is { a: string }, so the completion list is valid and correctly empty because a has already been provided.

Basically we'd need an entirely different kind of operation here where the language service would ask the checker, What property values would be valid here?, which almost requires a sort of counterfactual reasoning that we're really not capable of. For the specific case of U extends Partial<T> this is human-understandable, but the type system can't reasonably work backwards through that relationship to understand that the set of property keys for U falls back to T.

@RyanCavanaugh RyanCavanaugh added Design Limitation Constraints of the existing architecture prevent this from being fixed and removed Needs More Info The issue still hasn't been fully clarified labels Apr 9, 2019
@MasGaNo
Copy link
Author

MasGaNo commented Apr 10, 2019

It's vexing.

Sorry, it wasn't my intention ^^"

I don't really know how exactly the language service works under the hood.

I'm trying a different approach that provide us all what we need. So it's absolutely not a priority in comparison of what you already have in the roadmap 🙂

Especially when I see the evolution of TypeScript since I started to use it in 2014, it's absolutely fantastic, and I start all my project with it 😀

Keep continue like that, and we can close this issue if you want 👍

@johnrom
Copy link

johnrom commented May 14, 2021

I think I found another instance of this issue. Check out this TypeScript Playground

/**
 * Set a value in an object, inferring the path from the value attempting to be passed.
 * When passing "", the generic is resolved as `string` and thus can never match the `age` in Person.
 */
type SetFieldValueFn<Values> = <Value,>(
  name: PathLikeValue<Value, Values>,
  value: Value,
) => void;

interface Person {
  age: number | "";
}

const setFieldValue: SetFieldValueFn<Person> = (name, value) => {}

// this is resolving to `setFieldValue<string>("age", "");`, 
// instead of `setFieldValue<"">("age", "");`
setFieldValue("age", "");

// manually typing it works fine
setFieldValue<"">("age", "");

// the rest works
setFieldValue("age", 1);
setFieldValue("age", "" as "");
setFieldValue("age", 1 as number | "");
setFieldValue<number | "">("age", 1 as number | "");
setFieldValue<number | "">("age", 1);
setFieldValue<number | "">("age", "");
setFieldValue<number | "">("age", "");
setFieldValue<number>("age", 1);

"why are you doing this backwards?", please accept my apologies but this is the way.

this way allows a previously typed setFieldValue function to autosuggest names matching values to devs as they type.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design Limitation Constraints of the existing architecture prevent this from being fixed
Projects
None yet
Development

No branches or pull requests

3 participants