Skip to content

Record<"a" | "b", number> incorrectly extends Record<number, string>Β #48906

Closed

Description

@Josh-Cena

Bug Report

πŸ”Ž Search Terms

string literal mapped type extends number index signature

πŸ•— Version & Regression Information

  • This is the behavior in every version I tried, and I reviewed the FAQ for entries about index signatures

⏯ Playground Link

Playground link with relevant code

πŸ’» Code

Consider this (quite exhaustive) combination:

type T1 = Record<"a" | "b", number> extends Record<number, string> ? 1 : 2;
type T2 = Record<number, string> extends Record<"a" | "b", number> ? 1 : 2;
type T3 = Record<"a" | "b", number> extends Record<0 | 1, string> ? 1 : 2;
type T4 = Record<0 | 1, string> extends Record<"a" | "b", number> ? 1 : 2;
type T5 = Record<string, number> extends Record<0 | 1, string> ? 1 : 2;
type T6 = Record<0 | 1, string> extends Record<string, number> ? 1 : 2;
type T7 = Record<string, number> extends Record<number, string> ? 1 : 2;
type T8 = Record<number, string> extends Record<string, number> ? 1 : 2;
type T9 = Record<"2" | "3", number> extends Record<number, string> ? 1 : 2;
type T10 = Record<number, string> extends Record<"2" | "3", number> ? 1 : 2;
type T11 = Record<`a ${string}`, number> extends Record<number, string> ? 1 : 2;
type T12 = Record<"a" | "b", number> extends Record<`${number}`, string> ? 1 : 2;

Only T1, T11, and T12 are 1, which makes me think there's some level of unsoundness. Some more mind twisters:

type T1 = Record<"a" | "b", number> extends Record<number, infer T extends string> ? T : never;
type T2 = Record<"a" | "b", number> extends Record<number, infer T extends number> ? T : never;
type T3 = Record<"a" | "b", number> extends Record<number, infer T extends { any: "thing" }> ? T : never;
type T4 = Record<"a" | "b", number> extends Record<number, infer T> ? T : never;

πŸ™ Actual behavior

A mapped type with string literal keys (which aren't `${number}`) is assignable to a type with number index signature, with disjoint value types.

πŸ™‚ Expected behavior

It should not be assignable. If Record<"a" | "b", number> extends Record<number, string> because the key types are disjoint, then I'd expect the converse to be true as well, but Record<number, string> doesn't extend Record<"a" | "b", number>.

Activity

  1. Josh-Cena commented on May 1, 2022

    @Josh-Cena
    ContributorAuthor

    I may have got some idea about it. Because every Record<"a" | "b", number> is a valid Record<number, string> (there's no number keys in it, yet), but almost all Record<number, string> aren't Record<"a" | "b", number> (because there's no guarantee they have both a and b keys). Proof is that both of these are 1:

    type T1 = Record<`a ${string}`, number> extends Record<number, string> ? 1 : 2;
    type T2 = Record<number, string> extends Record<`a ${string}`, number> ? 1 : 2;
  2. Josh-Cena commented on May 2, 2022

    @Josh-Cena
    ContributorAuthor

    Direct assignment will be subject to excess property checks. The following works:

    const a: Record<"a" | "b", number> = { a: 1, b: 1 };
    const r: Record<number, string> = a;

    If the OP showed convincing real and urgent use cases where this was a problem, it would be more interesting.

    I got deceived by mapped types and index signatures both having the form of Record, but they are very different entities. Making that realization makes everything sound again. There's no problem here.

  3. craigphicks commented on May 2, 2022

    @craigphicks

    Joshua Chen (@Josh-Cena) - You are right on principle. I think it deserves an exact algorithmic description. A rough not-so-elegantly written and possibly not-exactly-correct stab at the algorithm - please correct it if it is wrong:

    Given Record<keytypes,any> extends Record<keytypes,any>

    • if r.h.s. keytypes contains string goto PASS; // string always passes
    • if r.h.s keytypes contains number goto PASS; // on the rhs, number is just an alias for string
    • foreach r.h.s. literal key LKR // literal number or literal string
      • LKR = toString(LKR) // notice that toString is an onto mapping for literal numbers
      • somepass = false;
      • for each l.h.s. keytype lhskeytype
        • if lhskeytype is string { somepass=true; break; }
        • else of lhskeytype is number and !isNaN(Number(LKR) { somepass=true; break; }
        • else if toString(lhskeytype) === LKR {somepass=true; break; }
      • if somepass GOTO PASS
    • FAIL (return false)
    • PASS (return true)

    When the types are not any ... any, this extra condition is logically AND-ed

    • lhstype extends rhstype

    For reference:

    type T1_true = Record<"a",any> extends Record<string,any> ?  1 : 2
    type T2_true = Record<"a",any> extends Record<number,any> ?  1 : 2
    type T3_true = Record<string,any> extends Record<"a",any> ?  1 : 2
    type T4_false = Record<number,any> extends Record<"a",any> ?  1 : 2
    type T5_true = Record<number,any> extends Record<"a"|string,any> ?  1 : 2
    type T6_true = Record<number,any> extends Record<string,any> ?  1 : 2
    

    I think your question and answer was actually pretty useful, although being a question, it's not really an issue.

  4. Josh-Cena commented on May 3, 2022

    @Josh-Cena
    ContributorAuthor

    Let's not think about it in terms of records, because records can mean two different things: mapped types and index signatures. Record<"a", any> is a mapped type (MT), Record<string, any> is an index signature (IS). For mapped types, every key must exist; for index signatures, there's no key that needs to exist at all: it only restricts what value must be associated with a key of that type. The reason for this approach is because your analysis has dropped index signatures in the form of template literals.

    So now we have four worlds: IS extends IS, MT extends IS, IS extends MT, MT extends MT.

    1. IS extends IS
      • If the two index signatures are disjoint, they are always cross-assignable. The proof is that Record<`a ${string}`, number> extends Record<number, string> and Record<number, string> extends Record<`a ${string}`, number> are both true. (Note the use of template literals to eliminate the case of `${number}`
      • Otherwise, the LHS's key type and value type must both be subtypes of RHS (covariance). In the position of index signatures, number is also a subtype of string, because `${number}` and number are equivalent. (In fact `${number}` may not be equivalent to number because " 1 " is a valid `${number}`, but I understand the choice of ergonomics)
    2. MT extends IS follows the same rule as above. For example, Record<0 | 1, string> extends Record<`a ${string}`, number>, and Record<"a" | "b", number> extends Record<number, string>
    3. IS extends MT
      • This will never be true, because there's no guarantee that any key exists in the index signature type, so it can't be assigned to any non-trivial mapped type. This is why Record<number, string> extends Record<"a" | "b", number> is false.
    4. MT extends MT
      • This is true only is LHS's key type and value type are both subtypes of RHS. They are NOT cross-assignable when their key types are disjoint (contrary to the case where one side is an IS). The rule of string-number equivalence still stands, so Record<0 | 1, string> and Record<"0" | "1", string> are equivalent.

    As you can see, out of the four worlds, our familiar covariance principle is always applicable, with a little exception that `${number}` = number in the key position. But when the RHS is an index signature, the rule is relaxed to also allow disjoint key types. My original example is the case (2).

  5. craigphicks commented on May 4, 2022

    @craigphicks

    Joshua Chen (@Josh-Cena) -

    You're right, lets not think about Records. 'Record' is only syntactic sugar for describing common subsets of object types.
    Here's an example of an object type that cannot be described using a single Record template expression, although (I think) any object can be expressed as a logical and (&) of nulitple records:

    type A = `${number}`;
    type B = `${string}${number}`;
    type X = {
        [k:A]:1|6,
        [k:B]:2,
        [k:string]:1|2|3|4|5|6,
        [k:number]:4|6,
        "a":5,
        "1":6
    }
    type Y = Record<A,1|6>&Record<B,2>&Record<string,1|2|3|4|5|6>&Record<number,4|6>&Record<"a",5>&Record<"1",6>
    type YeX_true = Y extends X ? true : false;
    type XeY_true = X extends Y ? true : false;
    const xa:X = {2:1}   // Error ... ooops ... shouldn't X be a disallowed type?
    const xb:X = {2:4}   // Error ...                  " 
    

    I do not understand how your classification fits arbitrary records, like Record<"a"|string,string>, as well as more complex object types like X above.

    My previous comment - using Record expressable object types only and leaving out template strings - isn't really good enough, for sure.

  6. fatcerberus commented on May 6, 2022

    @fatcerberus

    Record<"a" | string, string> is exactly the same thing as Record<string, string>

  7. craigphicks commented on May 6, 2022

    @craigphicks

    Bruce Pascoe (@fatcerberus) - All right then

    type T1 = Record<"a"|number, string>;
    type T2 = Record<number, string>;
    const a1n1:T1 = {1:"","a":""};
    const a2:T2 = {1:"","a":""}; // Type '{ 1: string; a: string; }' is not assignable to type 'T2'.
    

    I would also be wary of saying "exactly the same thing" because the internal rep, after converting from Record, probably still contains the "a" property, which would incurr different code paths. The final result (probably) should be the same, but to prove it is NP-hard.

  8. fatcerberus commented on May 7, 2022

    @fatcerberus

    You said "a"|string originally, your example there is "a"|number which is indeed different from number.

    "a"|string immediately simplifies to string (by design, before Record is instantiated) so the distinction is never observable to any of your code. Therefore, as far as your program can tell, they are exactly the same type. Any arguments beyond that are purely philosophical.

  9. fatcerberus commented on May 7, 2022

    @fatcerberus

    I'd also like to point out this:

    type T1 = Record<"a"|number, string>;
    type T2 = Record<number, string>;
    const a1n1:T1 = {1:"","a":""};
    const a2:T2 = a1n1;  // ok

    The only reason your version has an error is because object literals are checked for excess properties. It is not actually a type error, it is a lint check to prevent you from creating properties that you won't be able to access later.

  10. craigphicks commented on May 7, 2022

    @craigphicks

    You said "a"|string originally, your example there is "a"|number which is indeed different from number.

    The point was that a mixed literal & index Record exists - "All right then" = "you are correct and here is a correct example" . That's obvious from context. I enjoyed and am absorbing the rest of your comments thought - so thanks for those.

  11. craigphicks commented on May 7, 2022

    @craigphicks

    I'd also like to point out this:

    type T1 = Record<"a"|number, string>;
    type T2 = Record<number, string>;
    const a1n1:T1 = {1:"","a":""};
    const a2:T2 = a1n1;  // ok

    The only reason your version has an error is because object literals are checked for excess properties. It is not actually a type error, it is a lint check to prevent you from creating properties that you won't be able to access later.

    Correct. I didn't mean to claim otherwise.

  12. craigphicks commented on May 7, 2022

    @craigphicks

    Bruce Pascoe (@fatcerberus)
    FWIW in the TypeScript compiler environment I created a a new test file tests/cases/compiler/mytest.ts with content:

    type T1 = Record<string | "a",string>;
    type T2 = Record<string,string>;
    type T3 = Record<number | "a",string>;
    

    an ran gulp runtests --test=mytest to generate the file tests/baselines/local/mytest.types with content:

    type T1 = Record<string | "a",string>;
    >T1 : { [x: string]: string; }
    type T2 = Record<string ,string>;
    >T2 : { [x: string]: string; }
    type T3 = Record<number| "a" ,string>;
    >T3 : { [x: number]: string; a: string; }
    

    which adequately demonstrates the truth of your statements

    "a"|string immediately simplifies to string (by design, before Record is instantiated)

    and

    "a"|number ... is indeed different from number.

  13. locked as resolved and limited conversation to collaborators on Oct 22, 2025
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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions