microsoft/TypeScript

Conditional type inference fails when generic parameter has Readonly<T> in function parameter position

Opened this issue · 1 comments

🔎 Search Terms

Readonly generic parameter conditional type inference conditional type matching with Readonly parameter generic constraint Readonly intersection type mapped type readonly conditional inference failure Readonly parameter affects return type inference

🕗 Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about conditional types,
    generic inference, mapped types, and readonly

⏯ Playground Link

https://www.typescriptlang.org/play/?#code/PTAEF5K7NBlApgFwK4AdQGMD2ATBEMRRAUEgJ5oEAqlCAYigHaZICW2TAPAErIoAnJrSoRQzANZNsAdyYA+MQAoAdGoCGAgOYBnAFyh1TcgG0AugEoIivqiEiEAbjJ1Q9ADbrdYgN4lQAaAmAGaeWgBy6gC2CAY6SAJsTFpmBn6BGaAUVAYOjCzsnM6ZgfjB6ijuSAD8BpLScsWBAL7OrS6ieWFccJgAFghR6jqgCAAeSAhMuCMeXjqK4KDpgSFhoEmgEgjk2MHw-YPDqQcDQzprXmajE1Mzy1l0uXT5rBzcScEIAqDU8o6gZr+Eqgaq-YElAxMBAAN2+JHaJBAhGIUFAAHVsAIJCMZGwkH1sCgkOIdEktKA+OpcJx3OQuItUUySCR8JhPAICMFmG9OKBgkxMdjyVwAPJoQpMEbjSbTWZhBZKCGgbAS976UDiyU6EgWZ5UOZaMVqzgLZw4KUkzk6SpIIUScliAX28lKFYBABG2Gw7gQRkNaUeOVAACFvb6jICADTKr0+v1MQ0AEQQ5VtaWVGWysVD4YTMZBoDKFSqBnK7h0CALLRjzQszmttpdyRUcYjibCSLAhcCAD1qiRG1Vm1pW3n-WEU2mql2e32ByzZ0y0SGUBSZAMmKTHVSaUw6QyUcuYCy2RyuTzJfymKujVr1TdZfdDYrlartQZd7T6ffTfJdfqDDdL+Ur-CQFrxKAQ5ILeTo3mubqxuOHZeIG2YGGG8aRs01aeshyapiWSAZoW6G5lhTC4Zkxbpvy6gVlWyo4Qi9aDggNpVLeY4UYas5zqA-ZsRxMFrtx7YEdOSB8XOglAA

💻 Code

// ============ Setup code ==================
type TypeFunction<ReturnType = unknown> = (...args: any[]) => ReturnType;
type Flags = {
    [flagName: string]: {
        type: TypeFunction;
        default?: unknown;
    };
};
type TypeFlag<Schemas extends Flags> = {
    [flag in keyof Schemas]: Schemas[flag] extends { type: TypeFunction<infer T>; }
        ? T
        : never
};

// ============ Works without using Readonly<> ==================

declare function fnWorking<Options extends Flags>(
    options: Options
): TypeFlag<Options>;
const resultWorking = fnWorking({
    booleanFlag: { type: Boolean },
    booleanFlagDefault: {
        type: Boolean,
        default: false,
    },
});
resultWorking.booleanFlag
//            ^? (property) booleanFlag: boolean
resultWorking.booleanFlagDefault
//            ^? (property) booleanFlagDefault: boolean



// ============ Bug when using Readonly<> ==================

declare function fnBug<Options extends Flags>(
    options: Readonly<Options>
): TypeFlag<Options>;
const resultBug = fnBug({
    booleanFlag: { type: Boolean },
    booleanFlagDefault: {
        type: Boolean,
        default: false,
    },
});
resultBug.booleanFlag
//            ^? (property) booleanFlag: boolean
resultBug.booleanFlagDefault
//            ^? (property) booleanFlagDefault: never

🙁 Actual behavior

When a generic function has Readonly<T> in the parameter position, conditional type matching in the return type fails even though T itself is not readonly:

declare function fnBug<Options extends Flags>(
    options: Readonly<Options>
): TypeFlag<Options>; // Options is NOT readonly here

type Test = typeof resultBug. booleanFlagDefault; // Returns `never` (incorrect)

The conditional type Schemas[flag] extends { type: TypeFunction<infer T> } fails to match and falls through to the never branch.

This happens even though:

  1. Options itself is not Readonly in the return type TypeFlag<Options>
  2. The inferred type from the argument has the correct structure
  3. The same pattern works perfectly without Readonly<Options> in the parameter

🙂 Expected behavior

The conditional type should match successfully regardless of whether the parameter is Readonly<Options> or Options, since the return type TypeFlag<Options> uses Options directly (not Readonly<Options>).

Expected result:

type Test = typeof resultBug. booleanFlagDefault; // Should be `boolean`

The Readonly modifier in the parameter position should not affect type inference in the return type when the generic parameter Options itself is not readonly.

Additional information about the issue

Structural typing inconsistency

The types are structurally compatible:
{ type: BooleanConstructor; default: false } should match { type: TypeFunction<infer T> }

but TypeScript's conditional type matching fails when Readonly<Options> appears in the parameter.

Workaround exists

Adding an intersection to the conditional pattern fixes the issue, proving this is a pattern-matching bug rather than a semantic issue:

Schemas[flag] extends ({ type: TypeFunction<infer T> } & Record<PropertyKey, unknown>)

This workaround doesn't change the logical meaning but helps TypeScript's matcher recognize the types as compatible.

TS Playground

Real world Impact

This bug affects any library that wants to accept readonly parameters while using conditional type inference, forcing authors to use syntactic workarounds that pollute the type definitions.

🤖 Thank you for your issue! I've done some analysis to help get you started. This response is automatically generated; feel free to 👍 or 👎 this comment according to its usefulness.

Possible Relevant FAQs

Structural vs Instantiation-Based Inference

options is declared as Readonly<Options>, so the compiler can treat the call site as an instantiation of the generic Readonly<T> and apply instantiation-based inference. With that algorithm the compiler doesn’t decompose the argument object property-by-property; instead it works backwards from the fact that some T exists whose Readonly<T> is assignable to the argument. The minimal such T for the call

fnBug({
  booleanFlag: { type: Boolean },
  booleanFlagDefault: { type: Boolean, default: false }
});

is

{
  booleanFlag: { type: Boolean };
  booleanFlagDefault: { type: Boolean };
}

(The default property is optional in Flags, so it is dropped while solving for the least specific T.)

TypeFlag later asks whether Schemas[flag] extends { type: TypeFunction<infer X> }. For booleanFlagDefault the inferred type lacks the default property, so the match fails and the branch falls through to never.

Without Readonly<…> the parameter is just Options, so the compiler uses structural inference, keeps the default property, and the conditional type succeeds.

Workarounds:

  1. Provide the type argument explicitly:
    const result = fnBug<{
      booleanFlag: { type: Boolean };
      booleanFlagDefault: { type: Boolean; default: false };
    }>({
      booleanFlag: { type: Boolean },
      booleanFlagDefault: { type: Boolean, default: false },
    });
  2. Make the parameter accept both forms so structural inference is used:
    declare function fnBug<Options extends Flags>(
      options: Options & Readonly<Options>
    ): TypeFlag<Options>;
  3. Keep the original signature and cast internally:
    declare function fnBug<Options extends Flags>(options: Options): TypeFlag<Options>;
    // inside fnBug: treat options as Readonly<Options>

The behavior is consistent with the compiler’s preference for instantiation-based inference when a parameter is a direct generic instantiation such as Readonly<Options>. If full structural information is required for later conditional checks, one of the patterns above will preserve it.

Similar Issues

Here are the most similar issues I found

If your issue is a duplicate of one of these, feel free to close this issue. Otherwise, no action is needed.

Possibly Working as Intended

It looks like this behavior might be intentional. Here's what a computer had to say about it.

fnBug asks the compiler to infer a type parameter Options from a value that is only required to be assignable to Readonly<Options>. In other words, the compiler has to start with the actual argument type and – working backwards through the Readonly<…> mapped type – try to guess a suitable Options.

That process (called reverse-mapped-type inference) is intentionally conservative because, in the general case, a mapped type isn’t reversible. The easiest safe choice is to take the argument type itself as the inference candidate for Options. That leaves every top-level property in Options marked readonly.

Later, inside TypeFlag<Options> the conditional tests

Schemas[K] extends { type: TypeFunction<infer T> } ?  : never

For booleanFlagDefault the value type now looks like

{ readonly type: BooleanConstructor; readonly default: false }

Because the property type is readonly, this object is not assignable to { type: TypeFunction } (which expects a writable type property). The check therefore fails and the branch evaluates to never. The same problem doesn’t show up for booleanFlag because that value doesn’t carry a default property, so the compiler never inferred a conflicting readonly modifier for type.

Nothing is wrong with conditional types themselves; the mismatch comes from how Options was (conservatively) inferred through Readonly<…>. Supplying Options explicitly or stripping readonly in the conditional (e.g. -readonly Schemas[K]) makes the types line up. This limitation of inference is known and currently by design.