microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
101.08k stars 12.49k forks source link

String literal types as index signature parameter types? #5683

Closed mariusschulz closed 8 years ago

mariusschulz commented 9 years ago

According to https://github.com/Microsoft/TypeScript/pull/5185, string literal types are assignable to plain strings. Given the following type definition:

type NodeType = "IfStatement"
              | "WhileStatement"
              | "ForStatement";

Both these assignments are valid:

const nodeType1: NodeType = "IfStatement";
const nodeType2: string = nodeType1;

However, string literal types currently can't be used as index signature parameter types. Therefore, the compiler complains about the following code:

let keywords: { [index: NodeType]: string } = {
    "IfStatement": "if",
    "WhileStatement": "while",
    "ForStatement": "for"
};

// error TS1023: An index signature parameter type must be 'string' or 'number'.

Shouldn't that scenario be supported, given that string literal types are assignable to strings?

/cc @DanielRosenwasser

DanielRosenwasser commented 9 years ago

This issue is kind of the "dual" of #2491, and if we took this on we might want to reconsider that issue.

This is something that @weswigham has brought up a few times with me. One of the issues with this sort of thing is that undefined and null are possible values for each string literal type. This isn't something we consider to be a huge problem with numeric index signatures (they suffer from the same problem), but somehow I get a feeling like this is potentially more misleading. All in all, that doesn't seem to be a strong enough reason to dismiss it though.

mariusschulz commented 9 years ago

I'd like to argue from another perspective to point out why I think this feature is worth implementing. Consider the above NodeType type as found in a parser such as Esprima. String literal types are aimed at describing a set of finite possible string values, and listing all available statement/expression types of a parser is a perfect use case for that. If I'm forced to use plain strings as index signature parameter types, I lose the type safety that string literal types were made for to give me in the first place.

I agree, the issues are related. Let's see if we can find a solution here.

DanielRosenwasser commented 9 years ago

For that sort of thing, you don't necessarily need a string literal index signature - an alternative approach is to just use the appropriate property name when indexing with string literal types. It's one of the open questions on #5185:

Given that we have the textual content of a string literal type, we could reasonably perform property lookups in an object. I think this is worthwhile to consider. This would be even more useful if we performed narrowing.

jhlange commented 8 years ago

Dup of #2491

DanielRosenwasser commented 8 years ago

@jhlange I don't entirely think it is. While we are toying with the idea of unifying literal types with enums, this is somewhat distinct right now. If we do end up bringing them together, then #2491 will become much more relevant to the discussion.

ambition-consulting commented 8 years ago

+1

malibuzios commented 8 years ago

There's a highly relevant discussion on this at the exact duplicate issue #7656. I suggest checking out some of the information there.

malibuzios commented 8 years ago

In order to truly implement something like this, the index signature parameter would first need to be type checked against the specialized type they are constrained to, e.g. { [key: number]: any } would reject a string or Symbol used as a key. Currently that in not enforced. Please see this comment in #7660 and participate in the discussion.

malibuzios commented 8 years ago

This comment in #7660 is highly relevant to the topic here, though refers to the more general issue of how strictly should index signature keys be type-checked.

zakjan commented 8 years ago

+1

DanielRosenwasser commented 8 years ago

@christyharagan posted some more motivating examples in #8336.

As @malibuzios points out, membersof (#7722) would be a nice feature to pair with this, but I think that should be considered orthogonal and not discussed here.

asakusuma commented 8 years ago

Adding some more examples to what @mariusschulz was saying

describing a set of finite possible string values

The ability to enumerate the set of possible string values would be very useful. For example, given:

type DogName = "spike" | "brodie" | "buttercup";

interface DogMap {
  [index: DogName ]: Dog
}

let dogs: DogMap = { ... };

...it would be really nice to be able to do:

let dogNames: DogName[] = Object.keys(dogs);
// or
for (let dogName:DogName in dogs) {
  dogs[dogName].bark();
}
joeskeen commented 8 years ago

Here is another example of how it would be nicer if we were able to use string literal types as index keys.

Consider this interface:

interface IPerson {
  getFullName(): string;
}

If your tests this, you might write something like this:

let person: IPerson = jasmine.createSpyObj('person', ['getFullName']);

or

let person = jasmine.createSpyObj<IPerson>('person', ['getFullName']);

but suppose you messed up and instead wrote this:

let person: IPerson = jasmine.createSpyObj('person', ['foo']);

Currently there is no way for TS to let you know there is a problem. But if the type definition could look something like this:

function createSpyObj<T extends string>(baseName: string, methodNames: T[]): {[key: T]: any};

then the inferred type could be something more like { 'foo': any } which would cause an error.

Now, I'm not sure right now if the compiler would infer the type of T as a string literal union type for something like this:

let person: IPerson = jasmine.createSpyObj('person', ['foo', 'bar']); //ERROR

Ideally there would be a way to help TS infer the generic T as 'foo' | 'bar' so that the inferred return type could be { 'foo': any; 'bar': any; }.

amarinov commented 8 years ago

+1

ariutta commented 8 years ago

Another use case: representing valid BCP47 language tags as keys for language maps, e.g.:

{
  "@context":
  {
    "occupation": { "@id": "ex:occupation", "@container": "@language" }
  },
  "name": "Yagyū Muneyoshi",
  "occupation":
  {
    "ja": "忍者",
    "en": "Ninja",
    "cs": "Nindža"
  }
}

This would be difficult to represent, however, because there are so many valid language tags. They are not limited just the two-letter identifiers, but rather they can include things like region, e.g.:

  {
    "en-US": "elevator",
    "en-GB": "lift"
  }
pelotom commented 8 years ago

Flow has this feature and it's quite nice to have.

pelotom commented 8 years ago

Here's another use case: the typing of underscore's mapObject function would ideally maintain knowledge about the set of possible keys, so that:

// foo conforms to type { [key: 'x'|'y']: string }
const foo = {
  x: 'hello',
  y: 'world',
}

// type of bar is inferred to be { [key: 'x'|'y']: number }
const bar = mapObject(foo, s => s.length)
HerringtonDarkholme commented 8 years ago

Another usage, string literal key as constraints of object.

function prop<K extends string>(name: K): <S extends { [K]: any }>(s: S) => S[K] {
    return s => s[name];
}

prop('myProp')({myProp: 123})

This pattern is heavily used in functional library like rambda.

More generally, string literal key enables dynamic type building.

PyroVortex commented 8 years ago

Use of literal types (and more especially, generic type parameters constrained to literal types) provides a strict superset of the functionality provided in #11929. Notably:

function get<T, K extends keyof T>(obj: T, name: K): T[K] {
    return obj[name];
}
function set<T, K extends keyof T>(obj: T, name: K, value: T[K]): void {
    obj[name] = value;
}

is equivalent to, though definitely neater than:

function get<K extends (string | number), V, K1 extends K>(obj: { [key: K]: V }, name: K1): V {
    return obj[name];
}
function set<K extends (string | number), V, K1 extends K, V1 extends V>(obj: { [key: K]: V }, name: K1, value: V1): void {
    obj[name] = value;
}

Note that the additional type parameters K1 extends K, V1 extends V are necessary due to the fact that generic type parameter inference cannot be selectively disabled, so without them K and V would eventually fall back to (string | number if not {}) and ({} or any), respectively.

mhegazy commented 8 years ago

This should be possible now using Mapped types

So you can write the example in the original example as:


type NodeType = "IfStatement"
              | "WhileStatement"
              | "ForStatement";

let keywords: {[P in NodeType]: string };

keywords = {
      "IfStatement": "if",
      "WhileStatement": "while",
      "ForStatement": "for"
};  // OK

keywords.ifStatement; // OK

keywords = {  "another": "wrong" } // Error
DanielKucal commented 7 years ago

Why doesn't it work with classes and interfaces? E.g.

interface Keywords {
    [P in NodeType]: string;
}

Typescript playground

ccorcos commented 7 years ago

@mhegazy this doenst work for parameterized types either.

interface Component<Model, Action> {
  stateful: {
    init: () => Model,
    actions: {
      [Key in Action]: ({state}: {state: Model}, event: any) => Model
    }
  }
}
mhegazy commented 7 years ago

Why doesn't it work with classes and interfaces? E.g.

Mapped types are only supported in type aliases. Interfaces and classes have merging semantics that do would not fit make much sense with mapped type.

mhegazy commented 7 years ago

@ccorcos did you mean [Key in keyof Action] ?

Or is Action a string? if so then add a constraint for Action extends string.

masaori commented 7 years ago

I tried this, but didn't work.

  type ItemName = "A" | "B" | "C";
  namespace ItemName {
    export const A: ItemName = "A";
    export const B: ItemName= "B";
    export const C: ItemName = "C";
  };

  const itemComments: {[name in ItemName]: string} = {
    [ItemName.A]: "Good",
    [ItemName.B]: "So-so",
    [ItemName.C]: "Bad",
  };

The error says:

[ts]
Type '{ [x: string]: string; }' is not assignable to type '{ A: string; B: string; C: string; }'.
  Property 'A' is missing in type '{ [x: string]: string; }'.

I think [ItemName.A] should be recognized as ItemName type, not a string.

mhegazy commented 7 years ago

I think [ItemName.A] should be recognized as ItemName type, not a string.

This is the same issue tracked by https://github.com/Microsoft/TypeScript/issues/5579.

pimterry commented 7 years ago

@mhegazy If this doesn't work in interfaces, is there a different solution available, or a tracking issue for that case?

I'm trying to use this in some type definitions, and it doesn't support my case, using classes (or interfaces):

type ValidKey = "a" | "b" | "c";

declare class MyClass {
  [key: k in ValidKey]: string
}

I get "A parameter initializer is only allowed in a function or constructor implementation" (which isn't a particularly great error either, even given that this is unsupported).

Ciantic commented 7 years ago

I found out that in order to make keys optional you can use Partial with this e.g.

type mapping = Partial<{ [k in "a" | "b"]: string }>

Small thing, but I struggled for a while with this.

P.S. I probably should read specs more closely, there is also a { [k in "a" | "b"] ? : string } for optional keys.

leoasis commented 7 years ago

It'd be great if this also worked for number literals:

{ [k in 1 | 2]: string }

Since this already works:

{ [k: number]: string }

pelotom commented 7 years ago

@leoasis I seem to recall that when mapped types were first introduced they did support other literals than strings, but that support was subsequently removed. I wonder why...

vinz243 commented 6 years ago

Any update on this?

MaxGabriel commented 6 years ago

If I understand the thread correctly, is it not possible to parametrize a class by a type, then use the keys of that type in an index signature, like so:

class Form<T> {
    initialValues: {[fieldName: keyof T]: string} = {}
}
karol-majewski commented 6 years ago

@MaxGabriel Did you mean something like this?

class Form<T> {
  constructor(readonly initialValues: Partial<{ [field in keyof T]: string }> = {}) {}
}
MaxGabriel commented 6 years ago

@karol-majewski Oh that works great! Thank you :)

ashok-sc commented 6 years ago

I came here when I ran into the error for enums. This is how I made it work:

export enum ThreeLetters {
  a = 'a',
  b = 'b',
  c = 'c'
}

const letterToName: {[key in ThreeLetters]: string} = {
  a: 'Ashok',
  b: 'Bobby',
  c: 'Clever'
}
ducin commented 6 years ago

String literal types compatible with index signatures is one of the features I miss in TypeScript since a long time.

So often I define string literal enums in my project, such as:

type EmployeeType = "contractor" | "permanent";

or even list of geo, locale-related or money-related info, such as:

type Currency = "USD" | "GBP" | "EUR";

(whenever the number of items is fixed, not just a string).

Given a collection of items which include a property of above types, when reducing or grouping by that property, I expect to get:

{
  "contractor": ReducedValue...,
  "permanent": ReducedValue...
}

and I can't define the type as:

{ [type: EmployeeType]: ReducedType }

because it can be only a string or a number. So only thing I can do is to broaden it to a string, which is in fact to broad, since "other" is not a part of EmployeeType.

I wish this small feature lands in TypeScript one day :) not really sure why a string literal enum - being a subtype of string - is not allowed as an index signature (and string, the broader one, is).

niedzielski commented 6 years ago

@ducin can you help me understand your use case better? The following seems to work as (I) expect:

type EmployeeType = "contractor" | "permanent";

const employees: { [employeeType in EmployeeType]: {} } = {
  "contractor": {}, // ok
  "permanent": {}, // ok
  "volunteer": {} // error
}
ducin commented 6 years ago

@niedzielski I'm ashamed I never knew about this way to define an index signature. Thank you for this hint!

BTW why isn't { [type: employeeType]: {} } allowed (i.e. in is allowed and : isn't, what's the difference)?

weswigham commented 6 years ago

in makes a mapped type, while the other is an index signature. Mapped types are a little different in that they cannot have any properties other than the mapping operation (and can't be used in interfaces).

vehsakul commented 6 years ago

@niedzielski What if I want any subset of string literal type be ok. Can I do this in 2018?

karol-majewski commented 6 years ago

@vehsakul You mean like this?

type MyStringLiteral =
  | "a"
  | "b"
  | "c";

const asRecordType: Partial<Record<MyStringLiteral, any>> = {
    b: 1,
}

const asMappedType: { [property in MyStringLiteral]?: any } = {
    b: 1,
}
vehsakul commented 6 years ago

@karol-majewski Yes, exactly. Thanks. Dropped out from TS world for a while. Now ask dumb questions :)

MRayermannMSFT commented 6 years ago

Not sure if I'm missing something here, but I would've expected this to work.

type Department = "finance" | "engineering" | "marketing";
type DepartmentMap<TValue> = { [P in Department]: TValue };

let employeesByDepartment: DepartmentMap<string[]> = {
    "finance": ["Karen", "Jack"],
    "engineering": ["Natalie", "Matt"],
    "marketing": ["Michael", "Sally"]
};

for (let department in employeesByDepartment) {
    // Type of department is string, so:
    // Why am I allowed to use it as an indexer on line 13? Shouldn't I only be allowed to index with a Department?
    // Why is it not of type Department?
    let employees: string[] = employeesByDepartment[department];
    assignDepartmentInPayroll(department, employees); // this line does not compile
}

function assignDepartmentInPayroll(department: Department, employees: string[]) {
    // do something
}

I have a feeling there's a right way to do this, but I can't seem to find it.

ducin commented 6 years ago

@MRayermannMSFT Type assertion (department as Department, employees) does the job.

In my opinion above code is semantically correct, just the for..in is unable to narrow down the type of the key to a string literal enum. Such internal incompatibilities in TypeScript do appear, unfortunately. Maybe such issue could be easily improved, @mhegazy ?

pelotom commented 6 years ago

See https://github.com/Microsoft/TypeScript/pull/12253#issuecomment-263132208 for why department can't be inferred to have type Department. Having exact types might solve the problem?

MRayermannMSFT commented 6 years ago

Yep that would explain the reason why the type is not inferred. I'll look into exact types, @ducin , Yes, I could use as to get the property type, but to me that's not the ideal solution. Thank you for the suggestion though, as it is what I'll probably have to go with. 😄

benjamin21st commented 6 years ago

A related question, I wonder why: Given:

type NodeType = "IfStatement"
              | "WhileStatement"
              | "ForStatement";

The code below works:

type GoodStuff = {[P in NodeType]?: string } // Note that value type is set to "string"
let keywords: GoodStuff = {
      IfStatement: "if",
      WhileStatement: "while",
      another: "another", // This triggers error as expected
}; 

But this doesn't:

type GoodStuff = {[P in NodeType]?: NodeType } // Note that value type is set to "NodeType"
let keywords: GoodStuff = {
      IfStatement: "if",
      WhileStatement: "while",
      another: "another", // This no longer triggers error :(
}; 
karol-majewski commented 6 years ago

@benjamin21st It does seem to work correctly. See TypeScript playground with TypeScript 3.0.1 installed.

benjamin21st commented 6 years ago

@karol-majewski Ah~ Thanks! Then I'll just have to convince my team to upgrade to 3.0 :joy:

chharvey commented 6 years ago

If you have an enum (indexed by numbers), but you want your object to have string keys, here’s my solution:

enum NodeEnum {
    IfStatement,
    WhileStatement,
    ForStatement,
}

const keywords: { [index in keyof typeof NodeEnum]: string } = {
    "IfStatement": "if",
    "WhileStatement": "while",
    "ForStatement": "for",
}