Search code examples
typescriptfunctiongenericsconstants

TypeScript problem to infer constrained generic arguments through a generic function itself


Demoed through this TS playground link.

I'm trying to create a function that accepts the a constrained interface:

interface Data<TAttribute extends string = string> {
    readonly attributes: readonly TAttribute[]
    readonly subset: TAttribute extends infer U ? readonly U[] : never
}

The Data interface determines a set of attributes values, and then allows in its subset property it only allows a subset of these attributes. This works well when passing a data argument into a the function where the argument is just a plain object.

function dataNoFunc<TAttribute extends string>(data: Data<TAttribute>): Data<TAttribute> {
    return data
}

const goodNoFunc = dataNoFunc({ attributes: ['a', 'b'], subset: ['a']})
// WORKS: expected error on `subset` only - `d` is not part of `a` or `b`
const badNoFunc = dataNoFunc({ attributes: ['a', 'b'], subset: ['d']})

However, it doesn't work when the data argument is a CIF that returns a function (that returns that plain object):

function dataWithFunc<const TFunction extends (() => Data)>(dataFunction: TFunction): TFunction {
    return dataFunction
}

const goodWithFunc = dataWithFunc(() => ({ attributes: ['a', 'b'], subset: ['a']}))
// DOESN'T WORK: expected error on `subset` only - `d` is not part of `a` or `b`
const badWithFunc = dataWithFunc(() => ({ attributes: ['a', 'b'], subset: ['d']}))

Hovering over dataWithFunc inferred generics you can see the types of TAttribute isn't inferred at all (and ends up being a string). I tried solving it by making the returned function itself generic as well (with the new const keyword) in the parent's generic argument definition:

function dataWithFuncAndGeneric<const TFunction extends (<const T extends string, R>() => Data<T>)>(dataFunction: TFunction): TFunction {
    return dataFunction
}

// DOESN'T WORK NOW AS WELL DUE TO SUBTYPE ISSUE
const goodWithFuncAndGeneric = dataWithFuncAndGeneric(() => ({ attributes: ['a', 'b'], subset: ['a']} as const))
// DOESN'T WORK: expected error on `subset` only - `d` is not part of `a` or `b`
const badWithFuncAndGeneric = dataWithFuncAndGeneric(() => ({ attributes: ['a', 'b'], subset: ['d']}))

But that doesn't work due the common subtype issue (you can see the error in the playground as well):

Type 'string' is not assignable to type 'T'.
  'string' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'string'.

I'm wondering if this is solvable without having to specify the generic constraints manually (this is library code).


Solution

  • Using a const type parameter that is constrained to a function like <const T extends (() => Data)> won't work the way you want. A const context only applies to certain literal values and functions are not among them. It's like trying to use a const assertion on a function:

    const oops = (() => { }) as const; // error!
    // A 'const' assertions can only be applied to references to enum members, 
    // or string, number, boolean, array, or object literals.
    

    except that the const type parameter for function types just silently fails to infer the way you want, instead of issuing an error message like above.

    If you care about the literal type of the function return value, then you'll probably need to use a const type parameter that corresponds to that value, not the whole function. Like this:

    function dataWithFunc<const T extends string>(
      dataFunction: () => Data<T>): () => Data<T> {
      return dataFunction
    }
    

    But if you use that as-is with your definition of Data<T>, you'll fail to get the inference working as you want it:

    const goodWithFunc = dataWithFunc(() => ({
      attributes: ['a', 'b'], subset: ['a'] // error
      // -------------------> ~~~~~~
      // Type 'string[]' is not assignable to type 
      // 'readonly "a"[] | readonly "b"[]'.
    })) 
    

    Presumably your definition of Data<T>

    interface Data<T extends string = string> {
        readonly attributes: readonly T[]
        readonly subset: T extends infer U ? readonly U[] : never
    }
    

    involves that conditional type for subset as a means of trying to block inference of T from it. That is, you'd like TypeScript to infer T only from attributes, and then check it against subset... if T is inferred from both attributes and subset then you'd always just get the union of both.

    So you're attempting to implement something like a NoInfer<T> type as described in microsoft/TypeScript#14829:

    interface Data<T extends string> {
      readonly attributes: readonly T[]
      readonly subset: readonly NoInfer<T>[] 
    }
    

    But yours doesn't work because the conditional type is apparently blocking the const context as well as the inference, so ["a"] is inferred as type string[], which fails to match.


    Until and unless TypeScript ships its own version of NoInfer (which might well happen with microsoft/TypeScript#56794), there are various user implementations that work for some use cases. Your approach didn't work for your use case, but there are other ones to try.

    For example, this comment on microsoft/TypeScript#14829 says that T & {} creates a lower priority inference site for T, meaning that you can try T & {} to block inference. If we try that:

    interface Data<T extends string> {
      readonly attributes: readonly T[]
      readonly subset: readonly (T & {})[] 
    }
    

    then it suddenly works as desired:

    const goodWithFunc = dataWithFunc(() => ({
      attributes: ['a', 'b'], subset: ['a'] // okay
    }))
    const badWithFunc = dataWithFunc(() => ({
      attributes: ['a', 'b'], subset: ['d'] // error
      // ----------------------------> ~~~
      // Type '"d"' is not assignable to type '"a" | "b"'.
    }))
    

    So that's the approach I'd recommend, at least for now.

    Playground link to code