Skip to content
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

Tuple types are not indexed by number in mapped types #48398

Closed
Jamesernator opened this issue Mar 24, 2022 · 8 comments
Closed

Tuple types are not indexed by number in mapped types #48398

Jamesernator opened this issue Mar 24, 2022 · 8 comments
Labels
Fixed A PR has been merged for this issue Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@Jamesernator
Copy link

Jamesernator commented Mar 24, 2022

Bug Report

πŸ”Ž Search Terms

tuple, mapped types, number

πŸ•— Version & Regression Information

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

⏯ Playground Link

Playground link with relevant code

πŸ’» Code

type ValType = "i32" | "i64";

class Local<Type extends ValType> {
    readonly #type: Type;

    constructor(type: Type) {
        this.#type = type;
    }

    get type(): Type {
        return this.#type;
    }
}


type Locals<Types extends readonly ValType[]> = {
    [K in keyof Types]: K extends number ? Local<Types[K]> : Types[K]
}

function toLocals<Types extends readonly ValType[]>(valTypes: Types): Locals<Types> {
    return valTypes.map(type => new Local(type)) as unknown as Locals<Types>;
}

const i32x2 = ["i32", "i32"] as const;
// Error, the function should've returned the correct type
const locals: readonly [Local<"i32">, Local<"i32">] = toLocals(i32x2);

πŸ™ Actual behavior

The tuple keys are not mapped correctly as numbers. For some reason K extends number is never triggered for the numeric keys in ["i32", "i32"].

πŸ™‚ Expected behavior

The mapping should set K = 0 and K = 1 in the tuple, while it does correctly set them to "0" and "1" as far as I can tell there is no way to identify all "numeric like" strings in a mapped type. My assumption was the whole point of allowing "number" keys for objects was so that this kind've thing worked.

Notes

The mapped types still work fine for array, i.e. Locals<readonly ("i32")[]> resolves just fine to readonly Local<"i32">[] but this functionality doesn't extend to tuples.

@Jamesernator
Copy link
Author

Jamesernator commented Mar 24, 2022

For the record the whole mapped types on tuples and arrays does kind've work:

type Locals<Types> = {
    [K in keyof Types]: Local<Types[K]>
}

function toLocals<Types extends readonly ValType[]>(valTypes: Types): Locals<Types> {
    return valTypes.map(type => new Local(type)) as unknown as Locals<Types>;
}

const i32x2 = ["i32", "i32"] as const;
// This actually works now, but there's a new error above
const locals: readonly [Local<"i32">, Local<"i32">] = toLocals(i32x2);

However it still reports an error in trying to wrap, essentially generics with a constraint can't be used properly in a mapped type:

type Locals<Types> = {
    // Type 'Types[K]' does not satisfy the constraint 'ValType'.
    //  Type 'Types[keyof Types]' is not assignable to type 'ValType'.
    //    Type 'Types[string] | Types[number] | Types[symbol]' is not assignable to type 'ValType'.
    //      Type 'Types[string]' is not assignable to type 'ValType'.(2344)
    [K in keyof Types]: Local<Types[K]>
}

@RyanCavanaugh
Copy link
Member

This is the intended behavior. Mapped types can be homomorphic but the keyof operation still uses the string names of the keys (since, even for arrays, the keys are "actually" '0', '1', '2', etc). From a caller perspective, giving you the ability to distinguish between the 0 and "0" meanings doesn't make any sense because objects can't have different property slots.

@RyanCavanaugh RyanCavanaugh added the Working as Intended The behavior described is the intended behavior; this is not a bug label Mar 24, 2022
@fatcerberus
Copy link

ability to distinguish between the 0 and "0" meanings

I thought it was more about being able to distinguish between 0 and, say, "foo". That's why numeric index signatures are generally convenient, because it's harder to make that distinction otherwise.

@Jamesernator
Copy link
Author

Jamesernator commented Mar 25, 2022

From a caller perspective, giving you the ability to distinguish between the 0 and "0" meanings doesn't make any sense because objects can't have different property slots.

I know that there's not actually "number keys" in JS, but the fact is there is no other way in TypeScript to say K extends IsValidNumberString. Which again is the whole point of allowing number signatures for types in the first place right? Like the fact SomeTupleType[0] is legal works on this presumption.

I thought it was more about being able to distinguish between 0 and, say, "foo". That's why numeric index signatures are generally convenient, because it's harder to make that distinction otherwise.

Arguably with template types, we should be able to do K extends `${ number }` , although at present that doesn't work either:

type Locals<Types extends readonly ValType[]> = {
    // Type 'Types[K]' does not satisfy the constraint 'ValType'.
    //   Type 'Types[`${number}` & string]' is not assignable to type 'ValType'.
    [K in keyof Types]: K extends `${ number }` ? Local<Types[K]> : Types[K]
}

In this case, K is too wide in the ? case, in practice it could be narrower as K in keyof Types should only include actual keys.

Although I suppose we can do this:

type Locals<Types extends readonly ValType[]> = {
    [K in keyof Types]: K extends `${ number }`
        ? Types[K] extends ValType ? Local<Types[K]> : never 
        : Types[K]
}

This is definitely "correct", but man is it a long and nuanced type to write just to map the usual keys of an array/tuple.

Like TypeScript already offers the magic mapped array/tuple types behaviour, it would probably be simpler if this just worked for generics with constraints as well as per my second comment.

Because for whatever reason adding the constraint:

type Locals<Types extends readonly ValType[]> = {
    [K in keyof Types]: Local<Types[K]>
}

Means the mapped type no longer acts as a array/tuple-only mapped type.

Alternatively alternatively, we could just have actual syntax to say some mapped type just applies to the entries in an array/tuple:

type Locals<Types extends readonly ValType[]> = {
    // All other keys are automatically passed through as is
    [K in arrayKeys Types]: Local<Types[K]>
}

@RyanCavanaugh
Copy link
Member

An operator to get the numeric keys out of a type, in number form, would be a reasonable suggestion

@jcalz
Copy link
Contributor

jcalz commented Mar 27, 2022

An operator to get the numeric keys out of a type, in number form, would be a reasonable suggestion

Like #48094?

@typescript-bot
Copy link
Collaborator

This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

@ahejlsberg
Copy link
Member

This issue was fixed by #48837. The original example now works as expected when Locals is simplified to just this:

type Locals<Types extends readonly ValType[]> = {
    [K in keyof Types]: Local<Types[K]>
}

@ahejlsberg ahejlsberg added the Fixed A PR has been merged for this issue label Apr 26, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Fixed A PR has been merged for this issue Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

6 participants