Skip to content

Storing a keyof to a string quoted number key produces TS2344: invalid constraint in declaration file #37292

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
rsesek opened this issue Mar 9, 2020 · 6 comments · Fixed by #39658
Assignees
Labels
Bug A bug in TypeScript Fix Available A PR has been opened for this issue

Comments

@rsesek
Copy link

rsesek commented Mar 9, 2020

TypeScript Version: 3.8.3 and 3.9.0-dev.20200309

Search Terms: [keyof quote], [keyof number declaration], [key number], [key quote], [object key number declaration]

Code

// test.ts
class C {
    readonly lines = {
        '1': 'abc',
        '2a': 'def'
    }
}

class Ref<T extends keyof C['lines']> {
    private _key: T;
    private _c = new C();

    constructor(key: T) {
        this._key = key;
    }

    value(): C['lines'][T] {
        return this._c.lines[this._key];
    }
}

const r1 = new Ref('1');
const r2 = new Ref('2a');

Expected behavior:

  1. Run tsc --declaration test.ts
  2. Run npx tsc --noEmit test.d.ts
  3. No error is emitted

Actual behavior:
Step (2) actually produces an error:

  1. Run npx tsc --noEmit test.d.ts
test.d.ts:13:23 - error TS2344: Type '"1"' does not satisfy the constraint '1 | "2a"'.

13 declare const r1: Ref<"1">;

When examining the output, note the two lines marked "BUG": the key in the lines object for 1 has been converted to number, but the Ref<"1"> is trying to access the key as a string.

// test.d.ts
declare class C {
    readonly lines: {
        1: string;     // BUG
        '2a': string;
    };
}
declare class Ref<T extends keyof C['lines']> {
    private _key;
    private _c;
    constructor(key: T);
    value(): C['lines'][T];
}
declare const r1: Ref<"1">;   // BUG
declare const r2: Ref<"2a">;

When tsc un-quotes the numeric key in C's definition, the Ref<"1"> produces a type mismatch.

Playground Link: Playground Link

Related Issues:

@RyanCavanaugh RyanCavanaugh added the Bug A bug in TypeScript label Mar 12, 2020
@RyanCavanaugh RyanCavanaugh added this to the TypeScript 4.0 milestone Mar 12, 2020
@a-tarasyuk
Copy link
Contributor

// test.ts
const x = {
    '1': 1,
    '2a': 1,
};
// test.d.ts
declare const x: {
    1: number;
    '2a': number;
};
// test.js
const x = {
    '1': 1,
    '2a': 1,
};

Which the right property name should be in the test.d.ts? 1 or '1'?

I noticed that TS converts property name from string to number explicitly

function createPropertyNameNodeForIdentifierOrLiteral(name: string, singleQuote?: boolean) {
  return isIdentifierText(name, compilerOptions.target) ? createIdentifier(name) : 
    createLiteral(isNumericLiteralName(name) ? +name : name, !!singleQuote);
}
function isNumericLiteralName(name: string | __String) {
  return (+name).toString() === name;
}

cc @RyanCavanaugh @weswigham

@RyanCavanaugh
Copy link
Member

@ahejlsberg we could use some feedback here. This is pretty messy.

Our type system sort of traffics around whether property keys were quoted or not, and this observable effects in .d.ts emit and assignability when keyof is involved.

declare function getKeys<T>(x: T): keyof T;

// Both .d.ts emit as { 1: string }
const unquo = { 1: 'a' };
const quotd = { '1': 'a' };

// They are cross-assignable
let q1: typeof unquo = quotd;
let q2: typeof quotd = unquo;
// Error
let e1: keyof typeof unquo = null as any as keyof typeof quotd;
// Error
let e2: keyof typeof quotd = null as any as keyof typeof unquo;

// .d.ts emit here differs:
// 1
const unquoKey = getKeys(unquo);
// "1"
const quotdKey = getKeys(quotd);

Basically, the object type printback for an object will always "normalize" numeric-like property keys to their unquoted form, but keyof will retain the quotedness. This can induce the error shown in the OP:

// .d.ts emit is { 1: string, 2: string }
const mixer = { 1: 'a', "2": 'a' };

class X<K extends keyof typeof mixer> {
  constructor(obj: K) { }
}
// OK in original, OK in .d.ts
const unquoInst = new X(1);
// OK in original, error in .d.ts because "2" is not 2
const quotdInst = new X("2");

I don't know how we touch this without badly breaking "manual tuple" scenarios like { length: 1, 0: n }. Any thoughts?

@ahejlsberg
Copy link
Member

I'm thinking that object types with numerically named properties (whether declared with quotes or without quotes) should have their keyof type include both representations (because you're permitted to index with either representation). So, keyof { 1: string } and keyof { '1': string } would both yield 1 | "1". Not sure how breaky that would be, but might be worth a try.

@weswigham
Copy link
Member

weswigham commented Jul 16, 2020

I've thought the same, since they resolve to the same slot - the issue is really mapped types; when you feed both of those into a mapped type an get two differing property types back for '1' vs 1, you're gunna have a bad time.

@ahejlsberg
Copy link
Member

@weswigham We recently merged #39101 which addresses a similar situation where members from distinct enum types have the same underlying numeric value. We now union together the results of the individual mapping operations. An alternative would be to run the mapping operation just once for each slot and have the iterated key type be a union of those key values that resolve to the slot. But either way, there may not be an issue with mapped types.

@ahejlsberg
Copy link
Member

Of course the simpler fix is to just preserve the spelling of property names in declaration files. I'm putting up a PR for that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Bug A bug in TypeScript Fix Available A PR has been opened for this issue
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants