Skip to content

Reverse mapped types don't use their constraint types when no candidates are present unlike the regular type parametersΒ #56241

Description

πŸ”Ž Search Terms

reverse mapped constraint inference candidates

πŸ•— Version & Regression Information

  • This is the behavior in every version I tried

⏯ Playground Link

https://www.typescriptlang.org/play?ts=5.3.0-dev.20231028#code/JYOwLgpgTgZghgYwgAgApynAthSVgBeEAJgPIBGAVhAmMgN4BQyyYAngA4QBcyAzmHwgA5gG5myDhmx8A-LwBKNAPZRiAHgFDhAGmQBXEAGsQygO4gAfOIC+jRsRoAbDChiHawZSH659HdQkAFQBhb0gADzAdYIBxfQxiPmQIKIgQJOQlBFUNLVBdNGkcPEISCmpaAG0AIilMLD4agF1kAB8DDIgYUBJLGMsACgB9XiYWdi4+MYkWHPBUsF5Q8MXxFht15GEEtWmGWeQqgGlkUGQjCDZlGGQg+MS+Zt5B+cilu7CFqL16mWWHnsTs0AJTIAC8lmQADdlMBiFtNowbCCAbskuJGPMBMgoBA+PonHRwb4wP5BuNWJx8TMWHNVlFaXT6YYPgBGAAMHJizJsPOQfIkO0eTLmAAsaEYAGLKZQvYZ6ejIGCygVjZWy3j5EQCsGQjXKCHgkk1FXKGr8lhwJxmOBsPhBKD6HjIEZ6qGCZ38wUozF4glE8QAeiDyAAerJ7IwgA

πŸ’» Code

interface ParameterizedObject {
  type: string;
  params?: Record<string, unknown>;
}

declare function setup<
  TContext,
  TGuards extends Record<string, ParameterizedObject["params"] | undefined>,
>(_: {
  types: {
    context: TContext;
  };
  guards: {
    [K in keyof TGuards]: (context: TContext, params: TGuards[K]) => void;
  };
}): TGuards;

const result = setup({
  types: {
    context: {
      count: 100,
    },
  },
  guards: {
    checkFoo: (_, { foo }: { foo: string }) => foo === "foo",
    alwaysTrue: (_) => true,
  },
});

result;
// ^?

πŸ™ Actual behavior

Type '(_: { count: number; }, { foo }: { foo: string; }) => boolean' is not assignable to type '(context: { count: number; }, params: Record<string, unknown> | undefined) => void'.
  Types of parameters '__1' and 'params' are incompatible.
    Type 'Record<string, unknown> | undefined' is not assignable to type '{ foo: string; }'.
      Type 'undefined' is not assignable to type '{ foo: string; }'.(2322)

πŸ™‚ Expected behavior

I'd expect the inference to succeed here

Additional information about the issue

TGuards is inferred as { checkFoo: { foo: string; }; alwaysTrue: unknown; } and thus rejected by the constraint check in getInferredType. alwaysTrue is inferred as unknown because inferReverseMappedType has such a return:

return getTypeFromInference(inference) || unknownType

And getTypeFromInference doesn't attempt to read the type parameter's constraint at all. It simply handles .candidates and .contraCandidates and that's it.

Activity

  1. niieani commented on Feb 27, 2025

    @niieani

    Since Ryan Cavanaugh (@RyanCavanaugh) closed #61228 as duplicate, I'm adding the context from that one, since it's slightly different from what is mentioned here:

    πŸ”Ž Search Terms

    • tuple inference with function literal
    • inference tuple function and object literal
    • mixing tuple objects and function widening

    πŸ•— Version & Regression Information

    • This is the behavior in every version I tried (from 4.0.5 to 5.8.1-rc), and I reviewed the FAQ for entries possibly related to this

    ⏯ Playground Link

    Playground

    πŸ’» Code

    interface MatcherObj {
      name: string;
    }
    
    type MatcherFn = (name: string) => boolean
    
    type Matcher = MatcherObj | MatcherFn
    
    type MapTuple<KeysTuple extends unknown[], MapToValue> = {
      [Index in keyof KeysTuple]: MapToValue;
    }
    
    interface Definition<MatchersT extends Matcher[]> {
      matches: [...MatchersT];
      computeValueFromMatches: (
        ...matches: MapTuple<MatchersT, Matcher[]>
      ) => number;
    }
    
    interface Config<Defs extends {[K in keyof Defs]: Definition<any>}> {
      definition: Defs;
    }
    
    function create<Defs extends {[K in keyof Defs]: Matcher[]}>(
      config: Config<{ [K in keyof Defs]: Definition<Defs[K]> }>
    ) {
      // ...
    }
    
    
    create({
      definition: {
        valid: {
          matches: [{ name: "m3" }],
          computeValueFromMatches: (m) => m.length,
        },
        too_mang_args: {
          matches: [{ name: "m1" }, { name: "m2" }],
          // below correctly errors "Target signature provides too few arguments. Expected 3 or more, but got 2."
          computeValueFromMatches: (m1, m2, m3) => m1.length + m2.length,
        },
        not_enough_args: {
          matches: [],
          // below correctly errors "Target signature provides too few arguments. Expected 1 or more, but got 0."
          computeValueFromMatches: (m) => 123
        },
        // valid_using_function: {
        //   matches: [(a) => a === 'x'],
        //   computeValueFromMatches: (m) => m.length
        // },
    
        // as soon as you uncomment 'valid_using_function' all the errors go away, and inference breaks!
      },
    })

    πŸ™ Actual behavior

    When all elements in the matches tuple are object literals, TypeScript infers an exact tuple type (e.g. [MatcherObj] or [MatcherObj, MatcherObj]), and the signature of computeValueFromMatches enforces the correct number of arguments.

    However, if a function literal (or an object with a function as a property) is included in the tupleβ€”as shown in the commented-out valid_using_function case β€” the inference for ALL the cases breaks (not just the one property!), as the entire type widens to MatcherObj[].

    This causes the relationship between the number of tuple elements and the number of parameters expected by computeValueFromMatches to be lost, so TypeScript no longer emits errors for other keys when the number of parameters is incorrect.

    πŸ™‚ Expected behavior

    TypeScript should preserve the literal tuple type even when a function literal is included.
    In the example case, the tuple length of the matches property should be inferred exactly as provided, so that computeValueFromMatches enforces a parameter count matching the tuple length.

    This behavior should be consistent regardless of whether the tuple elements are object literals or function literals.

    Additional information about the issue

    This issue does not occur when using object literals exclusively in the matches tuple. It only manifests when a function literal is included, suggesting that the inference algorithm incorrectly widens the tuple type in that scenario.

    What's even more bizarre, even if you change the Matcher type to be an object whose a property can be a function, the same issue occurs! i.e.

    type Matcher = { name: string | (name: string) => boolean }

    As soon as any of the defined matches uses a function under the name property, e.g. { name: () => true }, causes same widening behavior!

    I have thoroughly searched the TypeScript repository and online discussions but have not found a related issue.

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

    Domain: Mapped TypesThe issue relates to mapped typesPossible ImprovementThe current behavior isn't wrong, but it's possible to see that it might be better in some cases

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions