Skip to content

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

Closed

Description

@MasGaNo

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

Activity

  1. MasGaNo commented on Apr 8, 2019

    @MasGaNo
    Author

    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
    });
  2. MasGaNo commented on Apr 8, 2019

    @MasGaNo
    Author

    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

  3. RyanCavanaugh commented on Apr 9, 2019

    @RyanCavanaugh
    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?

  4. MasGaNo commented on Apr 9, 2019

    @MasGaNo
    Author

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

  5. MasGaNo commented on Apr 9, 2019

    @MasGaNo
    Author

    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
    ?

  6. RyanCavanaugh commented on Apr 9, 2019

    @RyanCavanaugh
    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

  7. MasGaNo commented on Apr 9, 2019

    @MasGaNo
    Author

    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?

  8. RyanCavanaugh commented on Apr 9, 2019

    @RyanCavanaugh
    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.

  9. added
    Design LimitationConstraints of the existing architecture prevent this from being fixed
    and removed
    Needs More InfoThe issue still hasn't been fully clarified
    on Apr 9, 2019
  10. MasGaNo commented on Apr 10, 2019

    @MasGaNo
    Author

    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 👍

  11. johnrom commented on May 14, 2021

    @johnrom

    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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Design LimitationConstraints of the existing architecture prevent this from being fixed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions