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

blujedis commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 5 years ago

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 commented 5 years ago

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 commented 5 years ago

Is it possible to achieve something like this:

// TPropertyName must be a string
export type Foo<TPropertyName = "propertyName"> = {
  [key in TPropertyName]: number
};
karol-majewski commented 5 years ago

@n1ru4l You need to set a constraint on TPropertyName:

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

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 commented 5 years ago

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

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 commented 5 years ago

@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 commented 5 years ago

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 commented 5 years ago

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 commented 5 years ago

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 commented 5 years ago

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 commented 5 years ago

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 commented 5 years ago

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: https://github.com/microsoft/TypeScript/issues/6579#issuecomment-537306546

MajidJafari commented 5 years ago

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

apieceofbart commented 4 years ago

@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 commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

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

https://github.com/microsoft/TypeScript/issues/5683#issuecomment-515744911

loqusion commented 3 years ago

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 commented 3 years ago

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.