microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.08k stars 12.49k forks source link

TypeScript 4.1+: Generic binding too broad in recursive conditional types #41380

Open ddprrt opened 4 years ago

ddprrt commented 4 years ago

TypeScript Version: 4.2.0-dev.20201103

Search Terms: Recursive Conditional types, generics, tuple types

Code

// A test object
const obj = {
    a: '2',
    b: {
        c: 3,
        d: {
            e: 'string',
            f: {
                g: {
                    h: 4
                }
            }
        }
    }
} as const

// its type
type Struct = typeof obj;

/**
 * First recursive conditional type 
 * 
 * CheckArguments gets 
 * Obj - A nested object
 * Arugments - A tuple of keys to go down a nested path
 * 
 * The type is recursive, I check if the current argument lists extends keyof Obj --> Then the recursion ends
 * Otherwise, I check if the first value in the tuple is keyof Obj, infer the rest, and go down the same type
 * again
 */
type CheckArguments<Obj, Arguments> = 
    Arguments extends [keyof Obj] ? Obj[Arguments[number]] :
    Arguments extends [keyof Obj, ...infer U] ? CheckArguments<Exclude<Obj[keyof Obj], string | number>, U> : never;

/**
 * Tests, all πŸ‘
 */
type Foo = CheckArguments<Struct, ['a']> // "2"
type Foo2 = CheckArguments<Struct, ['b', 'd', 'e']> // "string"
type Foo3 = CheckArguments<Struct, ['b', 'd', 'f', 'g', 'h']> // 4

/**
 * Second recursive conditional type
 * 
 * Arguments gets
 * Obj - A nested Obj
 * 
 * The recursive conditional type creates a union type of possible nested arguments in a tuple
 * This is based on Anders example from TSConf: https://github.com/ahejlsberg/tsconf2020-demos/blob/master/template/main.ts
 * (Dotted paths)
 */
type Arguments<Obj> = 
    Obj extends object ?
        [keyof Obj] | SubArguments<Obj, keyof Obj> :
        never;

// A helper type
type SubArguments<Obj, Key> =  Key extends keyof Obj ? [Key, ...Arguments<Obj[Key]>] : never;

// For example, the possible tuples of Struct πŸ‘
type Bar = Arguments<Struct>;

// equals to this union type
type Bar2 = ["a" | "b"] | ["b", "c" | "d"] | ["b", "d", "e" | "f"] | ["b", "d", "f", "g"] | ["b", "d", "f", "g", "h"]

/**
 * So both recursive conditional types work on their own. A problem is once I want to combine
 * them in a function, where I expect the first argument to bind to a value type within the Arguments union
 * 
 * Instead of having just one value type passed to CheckArguments (the one that is bound through the generic Keys),
 * TypeScript passes all parts of the union to CheckArguments. This leads CheckArguments to return all possible values
 */

declare function get<Obj extends object, Keys extends Arguments<Obj>>(o: Obj, ...keys: Keys): CheckArguments<Obj, Keys>

/** 
 * Tests πŸ’₯
 * */
const foo = get(obj, 'a') // Should be "2" 😒 
const foo1 = get(obj, 'b', 'c') // Should be  3 😒 
const foo2 = get(obj, 'b', 'd', 'e') // Should be "string" 😒 
const foo3 = get(obj, 'b', 'd', 'f', 'g', 'h') // Should be 4 😒 

Expected behavior: Keys gets bound to the value type passed as an argument to the function. This value type is then used for CheckArguments

Actual behavior: Keys is the entire union type Arguments<Obj>, not the subset. This leads to CheckArguments returning a too broad return type (and taking very long to evaluate ;-))

Playground Link: Click here

Related Issues: Did not find any.

AlCalzone commented 4 years ago

One more use case of https://github.com/microsoft/TypeScript/issues/27808 I guess

RyanCavanaugh commented 4 years ago

This definition works, but doesn't fail where it'd be nice to:

declare function get<Obj extends object, Keys extends string[]>(o: Obj, ...keys: Keys): CheckArguments<Obj, Keys>

/** 
 * Tests πŸ’₯
 * */
const foo = get(obj, 'a') // "2"
const foo1 = get(obj, 'b', 'c') // 3
const foo2 = get(obj, 'b', 'd', 'e') // "string"
const foo3 = get(obj, 'b', 'd', 'f', 'g', 'h') // 4

// Want to fail, but doesn't
const foo4 = get(obj, 'x') // foo4: never