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

String literal types as index signature parameter types? #5683

Closed
mariusschulz opened this issue Nov 16, 2015 · 73 comments
Closed

String literal types as index signature parameter types? #5683

mariusschulz opened this issue Nov 16, 2015 · 73 comments
Labels
Domain: Literal Types Unit types including string literal types, numeric literal types, Boolean literals, null, undefined Fixed A PR has been merged for this issue Suggestion An idea for TypeScript

Comments

@mariusschulz
Copy link
Contributor

According to #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 DanielRosenwasser added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Nov 17, 2015
@DanielRosenwasser
Copy link
Member

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
Copy link
Contributor Author

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
Copy link
Member

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
Copy link

jhlange commented Dec 21, 2015

Dup of #2491

@DanielRosenwasser
Copy link
Member

@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.

@DanielRosenwasser DanielRosenwasser added the Domain: Literal Types Unit types including string literal types, numeric literal types, Boolean literals, null, undefined label Jan 25, 2016
@ambition-consulting
Copy link

+1

@malibuzios
Copy link

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

@malibuzios
Copy link

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
Copy link

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
Copy link

zakjan commented Mar 30, 2016

+1

@DanielRosenwasser
Copy link
Member

DanielRosenwasser commented Jun 7, 2016

@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
Copy link

asakusuma commented Jun 25, 2016

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
Copy link

joeskeen commented Jul 12, 2016

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
Copy link

+1

ghost pushed a commit to scality/Arsenal that referenced this issue Aug 12, 2016
Make the boolean field an optional parameter, due to the lack of typing
possible for the `key` (cf. microsoft/TypeScript#5683) at the moment.
@benjamin21st
Copy link

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
Copy link

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

@benjamin21st
Copy link

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

@chharvey
Copy link

chharvey commented Sep 20, 2018

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",
}

@blujedis
Copy link

Actually you can write this like the following:

enum Color {
    red,
    green,
    blue
}

type ColorMap<C extends object, T> = { [P in keyof C]: T };

const colors: ColorMap<typeof Color, string> = {
    red: 'red',
    green: 'green',
    blue: 'blue'
};

Boom type checking!

@jorgekleeen
Copy link

jorgekleeen commented Nov 1, 2018

Actually you can write this like the following:

enum Color {
    red,
    green,
    blue
}

type ColorMap<C extends object, T> = { [P in keyof C]: T };

const colors: ColorMap<typeof Color, string> = {
    red: 'red',
    green: 'green',
    blue: 'blue'
};

Boom type checking!

Thanks!!! This is AWESOME!!!

@benjamin21st
Copy link

benjamin21st commented Nov 3, 2018

enum Color { red, green, blue } type ColorMap<C extends object, T> = { [P in keyof C]: T }; const colors: ColorMap<typeof Color, string> = { red: 'red', green: 'green', blue: 'blue' };

Not really, if you change your colors to be:

const colors: ColorMap<typeof Color, string> = {
    red: 'green', // <------- We don't expect this, do we?
    green: 'green',
    blue: 'blue'
};

@harvey-woo
Copy link

harvey-woo commented Nov 24, 2018

Same situation

type Method = 'get' | 'post' | 'put' | 'delete'
const methods: Method[] = ['get', 'post', 'put', 'delete']
class Api {
  [method in Method]: Function // <-- Error here
  constructor() {
    methods.forEach(method => {
      this[method] = (options: any) => { this.send({ ...options, method }) }
    })
  }
  send(options:any) {
    // ....
  } 
}

How should i handle this case ?

@dcousens
Copy link

dcousens commented Dec 17, 2018

For Googlers and others

What you want (and does NOT work):

type Ma = { [key: 'Foo']: number }
type Mb = { [key: 'Foo' | 'Bar']: number }
type Mc = { [key: 'Foo' | 'Bar' | 0.3]: number }
// etc

What you need (and does work):

type Ma = { [key in 'Foo']?: number }
type Mb = { [key in 'Foo' | 'Bar']?: number }
type Mc = { [key in 'Foo' | 'Bar' | 0.3]?: number }

const x: Ma = {}
const y: Ma = { 'Foo': 1 }
const z: Mc = { [0.3]: 1 }
// const w: Ma = { 'boop': 1 } // error

Unfortunate constraints:
Example 1

type Mx = {
  Bar: number // OK, but has to be a number type
  [key: string]: number
}

Example 2

type My = {
  Bar: number, // Error, because of below
  [key in 'Foo']: number
}

@n1ru4l
Copy link

n1ru4l commented Feb 1, 2019

Is it possible to achieve something like this:

// TPropertyName must be a string
export type Foo<TPropertyName = "propertyName"> = {
  [key in TPropertyName]: number
};

@karol-majewski
Copy link

@n1ru4l You need to set a constraint on TPropertyName:

export type Foo<TPropertyName extends string = "propertyName"> = {
  [key in TPropertyName]: number
};

@LukasBombach
Copy link

LukasBombach commented Jul 28, 2019

Ok, with all these solutions proposed, it seems there is still none piece of the puzzle missing; iterating over an object's keys:

declare enum State {
  sleep,
  idle,
  busy,
}

type States = { [S in keyof typeof State]: number };

const states: States = {
  sleep: 0x00,
  idle: 0x02,
  busy: 0x03,
};

function getNameFromValue(state: number): State | undefined {
  for (const k in states){
    if (states[k] === state) {
      return k; // type string !== type State and cannot be typecasted
    }
  }
}

The solution @dcousens proposed doesn't really help because my State enum is actually 20 lines of code and I don't think anyone would want that in their project.

@karol-majewski
Copy link

@LukasBombach What do you want getNameFromValue to return — the name of one of the keys or the numeric value on the right-hand side of your enum?

  • State refers to the value of an enum,
  • typeof State would be the type of your enum (here: an object),
  • keyof typeof State is the key of your enum.

If your enum looks like this:

enum State {
  sleep = 0x00,
  idle = 0x02,
  busy = 0x03,
}

Then you can get the key by doing:

function getNameFromValue(state: number): keyof typeof State | undefined {
  return State[state] as keyof typeof State | undefined;
}

and the value by doing:

function getNameFromValue(state: number): State | undefined {
    for (const k of UNSAFE_values(State)) {
      if (state === k) {
        return k
      }
    }

    return undefined;
}

const UNSAFE_values = <T extends object>(source: T): T[keyof T][] =>
  Object.values(source) as T[keyof T][];

@LukasBombach
Copy link

@karol-majewski thank you! What I want to return is the String that is restricted to specific values, I managed to do it the way I do it up there. The way I understand your solution, it is similar to mine but the keys and values / the access to it is reversed.

What bugs me is that I have to do a type cast, which I'd like to avoid.

@joeskeen
Copy link

joeskeen commented Sep 18, 2019

I've looked over this thread, and I'm a little confused. Why does this work:

type Point<D extends string> = {
  [key in D]: number;
}

but this does not?

interface Point<D extends string> { 
  [key in D]: number 
}

image

It seems to me that the two should be equivalent. What am I missing?

@blujedis
Copy link

Perhaps you could post an example of what you're after here as what you're showing here is wanting each key in a string. Not typical.

If you have a point that has say x and y

const point = {
  x: 100,
  y: 200
}

Then you'd have a type something like this:

interface IPoint {
  x: number;
  y: number;
}
type PointKeys = keyof IPoint;

But again maybe post a little more of what you're after here.

@blujedis
Copy link

Or maybe you're after something like this:

interface IPoint {
  x: number;
  y: number;
}

const points = {
  one: { x: 100, y: 200 },
  two: { x: 200, y: 300 }
};

type PointKeys = keyof typeof points;

type Points = { [K in PointKeys]: IPoint };

@joeskeen
Copy link

joeskeen commented Sep 18, 2019

What I've been trying to express is a Point with an arbitrary number of named dimensions. For example:

const point2D: Point<'x' | 'y'> = {x: 2, y: 4};
const point6D: Point<'u' | 'v' | 'w' | 'x' | 'y' | 'z'> = {
  u: 0,
  v: 1,
  w: 2,
  x: 3,
  y: 4,
  z: 5
};

But I think my use case isn't as important as the question of why the index signature works in a type alias but not in an interface?

I just spent a long time trying to get it to work as an interface before realizing that the same thing as a type alias works. It's a little confusing why one would work but not the other.

@blujedis
Copy link

blujedis commented Sep 18, 2019

I see, I misunderstood you're not asking for a solution but the why?

So this works just fine, I'm assuming you realized that but to be clear:

type Point<Keys extends string> = { [K in Keys]: number };

const point2D: Point<'x' | 'y'> = {x: 2, y: 4};

const point6D: Point<'u' | 'v' | 'w' | 'x' | 'y' | 'z'> = {
  u: 0,
  v: 1,
  w: 2,
  x: 3,
  y: 4,
  z: 5
};

Unlike the type alias which is enumerating the keys an interface is a definition hence the generic type would have to be an object or a Symbol. So what you're trying to do here needs to be done with a type alias as you're not defining it but rather representing what it is based on the keys. Think of it like a Record<T, K extends string> if that makes sense.

@omidkrad
Copy link

omidkrad commented Oct 4, 2019

I think index signature parameter should also allow for the String type and sub-types because it is valid. I need this for the scenario I've explained here: #6579 (comment)

@MajidJafari
Copy link

@mariusschulz, how about let keywords: { [key in keyof NodeType]: string }?

@apieceofbart
Copy link

apieceofbart commented Nov 20, 2019

@mariusschulz, how about let keywords: { [key in keyof NodeType]: string }?

I don't think it makes sense, keyof NodeType will give you different literal strings - methods on String type.

What I tend to do is to reverse the problem, usually it's enough for my cases:

interface KeywordsMappings  {
  IfStatement: "if", // or string if you want to widen the type
  WhileStatement: "while",
  ForStatement: "for"
}

type Keywords = keyof KeywordsMappings

let keywords: KeywordsMappings = {
    "IfStatement": "if",
    "WhileStatement": "while",
    "ForStatement": "for"
};

@SanCoder-Q
Copy link

Not sure what happens but I guess it's a similar problem:

type TestMap<T extends string> = {[key in T]: string}

const a = <T extends string>(aa: T) => {
    const x: TestMap<T> = {
        [aa]: 'string'
    }
}

//Type '{ [x: string]: string; }' is not assignable to type 'TestMap<T>'.(2322)

@colxi
Copy link

colxi commented Jan 19, 2020

We can achieve this by using Record :

type NodeType = 'IfStatement' | 'WhileStatement' | 'ForStatement'
type NodeTypeObject = Record<NodeType, string>

// works!
var myNodeTypeObject: NodeTypeObject = {
  IfStatement: 'if',
  WhileStatement: 'while',
  ForStatement: 'for',
}

// complains if there are missing proeprties
var myNodeTypeObject: NodeTypeObject = {
  IfStatement: 'if',
  WhileStatement: 'while',
} // --> Error : Property 'ForStatement' is missing but required by type 'Record<NodeType, string>'.

// Complains if additional properties are found
var myNodeTypeObject: NodeTypeObject = {
  IfStatement: 'if',
  WhileStatement: 'while',
  ForStatement: 'for',
  foo :'bar'  // --> Error :  'foo' does not exist in type 'Record<NodeType, string>'.
}

Bonus: If we want the properties to be optional we can do it by using Partial:

type NodeType = 'IfStatement' | 'WhileStatement' | 'ForStatement'
type NodeTypeObject = Partial<Record<NodeType, string>>

// works!
var myNodeTypeObject: NodeTypeObject = {
  IfStatement: 'if',
  WhileStatement: 'while',
}

try it in the typescript playground

@LukasBombach
Copy link

But in this solution your cannot iterate the keys:

type NodeType = 'IfStatement' | 'WhileStatement' | 'ForStatement'
type NodeTypeObject = Record<NodeType, string>

// works
var myNodeTypeObject: NodeTypeObject = {
  IfStatement: 'if',
  WhileStatement: 'while',
  ForStatement: 'for',
}

function getNameFromValue(str: string): NodeType | undefined {
  for (const k in myNodeTypeObject){
    if (myNodeTypeObject[k] === str) { // any
      return k; // is a string
    }
  }
}

Playground

#5683 (comment)

@loqusion
Copy link

If we allow template literal types to be used as index signature parameter types, then we could do something like this to let CSS variables be assignable to the React style prop:

type CSSVariable = `--${string}`;

interface CSSProperties {
  [index: CSSVariable]: any;
}

// ...

<div style={{ '--color-text': 'black' }}>{/* ... */}</div>

@jods4
Copy link

jods4 commented Jun 10, 2021

Same thing happens in Vue.
Volar provides type checking in Vue templates, but the following template is currently an error:

<div :style="{ '--color-text': 'black' }" />

To fix this in a generic way we need to have the template literal type CSSVariable in interface CSSProperties as shown by @Flandre-X in previous comment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Domain: Literal Types Unit types including string literal types, numeric literal types, Boolean literals, null, undefined Fixed A PR has been merged for this issue Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests