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

Index signature is missing in type (only on interfaces, not on type alias) #15300

Closed
RafaelSalguero opened this issue Apr 21, 2017 · 91 comments
Closed
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@RafaelSalguero
Copy link

RafaelSalguero commented Apr 21, 2017

TypeScript Version: 2.2.2

Code

interface IndexType {
     [key: string]: string;
}

interface doesNotWork {
    hola: string;
}
type doWorks = { hola: string };

let y: IndexType;

const correctA = { hola: "hello" };
const correctB: doWorks = { hola: "hello" };
//error should be assignable to y
const error: doesNotWork = { hola: "hello " };

y = correctA;
y = correctB;
y = error; //Index signature is missing in type 'doesNotWork'
y = {... error}; //workaround but not equivalent since the instance is not the same

Expected behavior:
The code should not result on a compiler error since the interface doesNotWork is equivalent to the type { hola: string }

Actual behavior:
Variable error of type doesNotWork can't be assigned to y

@mhegazy mhegazy added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Apr 21, 2017
@syabro
Copy link

syabro commented Apr 25, 2017

Same here for 2.3

@cainlevy
Copy link

Seeing this still in 2.4. Here's a playground repro.

This appears to be a bug considering implicit index signatures is listed as a feature of v2.0.

@rafaelkallis
Copy link

Any update regarding the issue? Still not fixed as of 2.4.2.

Playground

@RyanCavanaugh
Copy link
Member

Just to fill people in, this behavior is currently by design. Because interfaces can be augmented by additional declarations but type aliases can't, it's "safer" (heavy quotes on that one) to infer an implicit index signature for type aliases than for interfaces. But we'll consider doing it for interfaces as well if that seems to make sense

@NN---
Copy link

NN--- commented Sep 27, 2017

@RyanCavanaugh Thanx for reasoning. Can I say as a rule of thumb to use 'type' for internal interfaces since I don't need augmentation and 'interface' for external to be able to augment ?

@RyanCavanaugh
Copy link
Member

There are some other differences between type aliases and interface that might affect your choice - namely you can extend an interface but not a type alias. So it's not quite that simple, but as rules of thumb go it's not a bad one.

@NN---
Copy link

NN--- commented Sep 27, 2017

You can extend types through intersection types.
Is there anything I miss here?

// Interface
interface A { x: number; }
interface B { y: string;}

interface C extends A, B { b: boolean;}

var x: C = { b: true, x: 1, y: "" };


// Type
type TA = { x: number; };
type TB = { y: string; };

type TC = TA & TB & { b: boolean; };

var tx: TC = { b: true, x: 1, y: "" };

@Codesleuth
Copy link

I have an example where this rule is inconsistent when casting to the expected indexed type:

interface MyInterface {
  a: number
  b: number
}

const doesNotWork: { [key: string]: number } = {} as MyInterface
// Index signature is missing in type 'MyInterface'.

const doesWork: { [key: string]: number } = {} as { a: number, b: number }

Playground link

What is it that is considered different about these that is preventing the first case? Note that this is occurring in 2.7 (see the playground link)

@patrickneugebauer
Copy link

patrickneugebauer commented Mar 8, 2018

I was working on a somewhat related example today and I was pretty confused by the results. I tried to create some related situations so I could understand the rules.

As I understand:

  1. A specific type can be saved into a more generic type.
  2. A specific interface cannot be saved into a more generic interface. (unexpected)
  3. A specific interface can extend an more generic interface.
  4. A specific type can be saved into a more generic interface.
  5. A specific interface cannot be saved into a more generic type.

I tested this in typescript playground:

// First case (expected)
type A = {
    [x: string]: number
};
type B = {
    x: number
};
const b: B = { x: 1 };
const a: A = b; // no error


// Second case (unexpected)
interface C {
    [x:string]: number
}
interface D {
    x: number
}
const d: D = { x: 1 };
const c: C = d; // error
// Type 'D' is not assignable to type 'C'.
//   Index signature is missing in type 'D'.


// Third case (expected)
interface E {
    [x: string]: number
}
interface F extends E {
    x: number
}
const f: F = { x: 1 };
const e: E = f; // no error


// Fourth case (expected)
interface G {
    [x: string]: number
}
type H = {
    x: number
};
const h: H = { x: 1 };
const g: G = h; // no error


// Fifth case (maybe expected?)
type I = {
    [x: string]: number
}
interface J {
    x: number
}
const j: J = { x: 1 };
const i: I = j; // error
// Type 'J' is not assignable to type 'I'.
//   Index signature is missing in type 'J'.

@Codesleuth
Copy link

Thanks @patrickneugebauer - I had noticed those rules too, and have resorted to using type over interface in the common case, to allow building layers of type interactions without hitting walls of errors like these.

In my example, changing the interface to a type does indeed get around the error.

@kgaregin
Copy link

@RafaelSalguero thanks a lot for posting this, at least I found the workaround in this post. @patrickneugebauer thanks for your cases. Indeed weird behavior with interfaces :(

@TLadd
Copy link

TLadd commented Nov 7, 2018

Ran into this when trying to type a function argument that can have arbitrary keys but a known set of values. Unfortunately, the variable I was trying to pass into the function is generated from graphql-code-generator, so requires a bit more legwork than just changing an interface to a type manually. A quick workaround is to spread the variables before passing it in.

interface X {
    search?: string | null
}
const x: X = { search: "hello" }

function y(z: { [key: string]: string | string[] | null | undefined }) {
    console.log(y);
}

y(x) // Error
y({ ...x }) // Fine

Playground

@sindresorhus
Copy link

This limitation also makes it practically infeasible to use a custom JSON type as a generic constraint for HTTP request libraries. Context: sindresorhus/ky#80 (comment)

@hanzhangyu
Copy link
Contributor

hanzhangyu commented Jul 13, 2022 via email

@jcalz
Copy link
Contributor

jcalz commented Jul 18, 2022

Forever ago, @RyanCavanaugh said,

Because interfaces can be augmented by additional declarations but type aliases can't, it's "safer" (heavy quotes on that one) to infer an implicit index signature for type aliases than for interfaces.

Could someone walk me through why declaration merging matters again? Imagine a world in which there were no errors in the following code:

interface Foo {
  a: string;
  b: string;
  c: string;
}
function yellProps(x: { [k: string]: string }) {
  Object.keys(x).forEach(k => console.log(x[k].toUpperCase() + "!!!"));
}
function bar(foo: Foo) {
  yellProps(foo); // currently an error, but imagine if it were not
  yellProps({ ...foo }); // currently okay
}

Now someone comes along and merges something unexpected:

interface Foo {
  d: number;
}

Wouldn't this just produce errors inside bar(), as expected?

function bar(foo: Foo) {
  yellProps(foo); // now it should be an error
  yellProps({ ...foo }); // currently error
}

Isn't that... good? I mean, people who want this feature presumably would be happy with new errors appearing if merges happen. Right now every attempted use of a non-index-signature-having interface in a place that needs an index signature is an error. I'd think the removal of these errors would be worth the potential of some of them coming back due to merging.

So I'm obviously not seeing the argument that declaration merging matters... could someone spell it out for me?

@HansBrende
Copy link

Can we all agree on at least one fundamental concept here, given these types which I have made partial and readonly to eliminate any possible quibbles:

type Super = {readonly [_ in string]?: string}

interface Sub {readonly id: string}

There is no world in which Sub should not be assignable to super, yet this is a typescript error:

const sub: Sub = {id: '1234'};
const sup: Super = sub;  // TS2322: Type 'Sub' is not assignable to type 'Super'

So clearly there is a flaw in the current design. To what extent (removing partial or readonly) is another question.

@jcalz
Copy link
Contributor

jcalz commented Aug 7, 2022

Implicit index signatures are useful but unsound. I don't see how partial/readonly affects the issue, and there certainly does seem to be at least one world in which Sub being assignable to Super results in unsafe behavior. For example, SubSub is assignable to Sub but not to Super:

interface SubSub extends Sub {readonly age: number}

const subsub: SubSub = { id: "abc", age: 12 }; // okay
const sub: Sub = subsub; // okay
const sup: Super = sub; // okay?
sup.age?.toUpperCase(); // okay 💥 RUNTIME ERROR

This issue is about whether or not the usefulness of allowing implicit index signatures for interfaces outweighs the unsoundness.


Personally I think the usefulness does outweigh the unsoundness, just as it apparently does with implicit index signatures for type aliases:

type Super = { readonly [_ in string]?: string }
type Sub = { readonly id: string } // type, not interface
interface SubSub extends Sub { readonly age: number }

const subsub: SubSub = { id: "abc", age: 12 }; // okay
const sub: Sub = subsub; // okay
const sup: Super = sub; // okay
sup.age?.toUpperCase(); // okay 💥 RUNTIME ERROR

and just as it apparently does for "implicit optional properties" for all object types:

interface Foo { a: string }
interface Bar extends Foo { b?: number }
interface Baz extends Foo { b?: string }

const baz: Baz = { a: "", b: "" }; // okay
const foo: Foo = baz; // okay
const bar: Bar = foo; // okay
bar.b?.toFixed() // okay 💥 RUNTIME ERROR

And as I said above, I'm not sure I see how declaration merging matters, since merging can already cause existing code to error:

interface Here { a: string, b: number }
const here: Here = { a: "", b: 123 } // compiler error now
// ... later
interface Here { c: "HAHA" } 

which presumably is a good thing, since merging in stuff that invalidates the developer's assumptions should result in errors where those invalidations occur.


Playground link

@HansBrende
Copy link

@jcalz good point, didn't think of that example. Agree that the usefulness does outweigh the unsoundness.

@timongh

This comment was marked as resolved.

@TheDSCPL
Copy link

@RyanCavanaugh is there any news on this issue? I would have thought it would have been resolved by now as many more complex things have been built and released already.

lobsterkatie added a commit to getsentry/sentry-javascript that referenced this issue Oct 19, 2022
This makes a few small revisions to the new `RequestData` integration:

- Switch to using booleans rather than an array of keys when specifying what user data to include. This makes it match all of the other options, and is something I should have just done from the get-go. Given that the integration is new and thus far entirely undocumented, IMHO it feels safe to make what is technically a breaking change here.

- Rename the integration's internal `RequestDataOptions` type to `RequestDataIntegrationOptions`, to help distinguish it from the many other flavors of request data functions and types floating around.

- Make all properties in `RequestDataIntegrationOptions` optional, rather than using the `Partial` helper type.

- Switch the callback which actually does the data adding from being an option to being a protected property, in order to make it less public but still leave open the option of subclassing and setting it to a different value if we ever get around to using this in browser-based SDKs. Because I also made the property's type slightly more generic and used an index signature to do it, I also had to switch `AddRequestDataToEventOptions` from being an interface to being a type. See microsoft/TypeScript#15300.

- Rename the helper function which formats the `include` option for use in `addRequestDataToEvent` to more specifically indicate that it's converting from integration-style options to `addRequestDataToEvent`-style options.

- Refactor the aforementioned helper function to act upon and return an entire options object rather than just the `include` property, in order to have access to the `transactionNamingScheme` option.

- Add missing `transaction` property in helper function's output.

- Add tests for the helper function.
@JakeTompkins
Copy link

This issue is old enough to be in kindergarten by now. Has no consensus been reached or has it just been abandoned?

@hanzhangyu
Copy link
Contributor

hanzhangyu commented Nov 4, 2022 via email

@Webbrother
Copy link

Webbrother commented Nov 16, 2022

I believe that the current behavior of Interfaces is correct. Because I can not assign a type to a much wider type.
I can not assign Animal to Dog, but I can assign Dog to Animal.

See "L" letter meaning of SOLID principles.

BUT behavior of Types is wrong.

Let's say I have an Interface A.

interface A {
  prop: string;
}

I can not assign A to Record<string, string> because the Record can have any other property.

Example:

const fn = (record: Record<string, string>) => {
  record.value1;
  record.value2;
  record.value3; // No errors here
}

So if I'll pass A to fn it is obvious that an error must occur.

Unfortunately at the moment, I can assign Type to Record without error. And I believe this is wrong behavior.

type TObj = {
  title: string;
}

const obj: TObj = {
  title: 'title',
};


const fn = (record: Record<string, string>) => {
  record.value1;
  record.value2;
  record.value3; // No errors here
}

fn(obj); // No error here, but it has to be

Playground

@jcalz
Copy link
Contributor

jcalz commented Nov 17, 2022

^ Like it or not, implicit index signatures are an intended feature of TypeScript.

@Webbrother
Copy link

Webbrother commented Nov 17, 2022

@jcalz Thank you for your comment. Yes, I see that this is intended feature but I bealeve it has wrong implementation.

I bealive the root of problem here is how Record<string, string> works.

  1. If I can call any string property of the Record, like here:
// example 1
const fn = (record: Record<string, string>) => {
  record.value1;
  record.value2;
  record.value3; // No errors here
}

then It means that Record is "wider" than just object. We can call any string property of Record. So we can say that record has all possible string keys (in fact of course it has just a few props and it is impossible to have all possible keys).

Therefore,
according to Barbara Liskov principle we can not assign the object to the Record! But such a restriction makes a lot of people confusing, because assignment of the Object to Record intuitively looks good.

// example 2
interface IObj {
  prop: string;
}

const obj: IObj = { prop: 'string' };
const record: Record<string, string> = obj; // We expect this should work fine

So if we expect that such a assignment have to be correct then we have to agree that the Record is not "wider" type tyan IObj, therefore it should be impossible to call any string property on Record type.
So work with record should looks like:

// example 3
const fn = (record: Record<string, string>) => {
  if ('value1' in record) {
    record.value1
  }
  if ('value2' in record) {
    record.value2
  }
  if ('value3' in record) {
    record.value3;
  }
}

It is impossible to have an object with all possible string keys that's why code from example 3 looks more correct than code from example 1

To finalize:

Abviously that current types logic implementation has a problems and leads people to confusing. So I see two possible ways to fix it.

  1. To accept that Record is "wider" than object. Then current behavior of Interfaces is correct. But behavior of Types is wrong and should be fixed.

  2. To accept that Record is not "wider" than object. Then current behavior of Record and Interfaces are wrong and should be fixed.

After long thinking I personally prefer the 2nd option.

@matgr1
Copy link

matgr1 commented Nov 18, 2022

in case it helps anyone, I just found a workaround (essentially converting the interface to a type via Pick) for a slightly different situation where I hit the same underlying issue:

declare function foo<T extends Record<string, unknown>>(foo: T): void;

interface SomeInterface {
    bar: string;
}

const value: SomeInterface = { bar: 'baz' };

// does not work
foo(value);

// does work and is still typesafe, if a bit cheesy
foo<Pick<SomeInterface, keyof SomeInterface>>(value);

and depending on the use case, I think it can be applied to the original issue:

interface IndexType {
     [key: string]: string;
}

interface doesNotWork {
    hola: string;
}
type doWorks = { hola: string };

let y: IndexType;

const correctA = { hola: "hello" };
const correctB: doWorks = { hola: "hello" };
//error should be assignable to y
// const error: doesNotWork = { hola: "hello " }; // this is the original type which fails below
const error: Pick<doesNotWork, keyof doesNotWork> = { hola: "hello " }; // this will work below

y = correctA;
y = correctB;
y = error; // no longer an error
y = {... error}; // original workaround (notes from original workaround: not equivalent since the instance is not the same)

@lobsterkatie
Copy link

Nice, @matgr1.

I've extracted it into a helper type: type IndexOptional<T> = Pick<T, keyof T> and can confirm that, in the above example, it works to do:

const error: IndexOptional<doesNotWork> = { hola: "hello " }; // this will work below
y = error; // no longer an error
y.things = "stuff" // can still accept arbitrary keys

@Webbrother
Copy link

Webbrother commented Nov 18, 2022

@matgr1 @lobsterkatie
This is just a rough workaround.

Pick<SomeInterface, keyof SomeInterface> is just converting an Interface to the same Type and nothing more.
IndexOptional<Interface> - absolutely the same.

Those examples work because in Typescript Type can be assigned to Record<..., ...>. There are no other reasons.

As I explained above this is wrong behavior, because it contradicts to Liskov substitution principle.

It is still unsafe (demo):

interface IObj {
  prop: string;
}
const obj1: IObj = { prop: 'string' };
// TS2322: Type 'IObj' is not assignable to type 'Record<string, string>'. Index signature for type 'string' is missing in type 'IObj'.
const record1: Record<string, string> = obj1; 


type IndexOptional<T> = Pick<T, keyof T>
const obj2: IndexOptional<IObj> = { prop: 'string' };
// No error because now obj2 has "Type" typing, not an "Interface"
const record2: Record<string, string> = obj2; 

// It is still unsafe
const fn = (arg: Record<string, string>) => {
  arg.someUnexistedProp;
  arg.anotherUnexistedProp;
  arg.anyOtherPropertyWhatEverYouWant;
  arg.stillNoErrorButWhy;
}

// You have obj2 with only one property? Yes! 
// Can you use it in function with a lot of random properties? Yes. 
// Is it safe? No!
fn(obj2); 

@jcalz
Copy link
Contributor

jcalz commented Nov 18, 2022

@Webbrother Yes, TypeScript is intentionally unsound in places; implicit index signatures and unchecked indexed accesses are two of these places, and not really the subject of this GitHub issue. This issue is just about whether or not interfaces should be granted implicit index signatures, with all the accompanying baggage, for better or worse.

@lobsterkatie
Copy link

@Webbrother - yes, I understand it's a workaround (and what it's doing). It's just now a slightly nicer-looking workaround. 🙂

And yes, exactly what @jcalz said (though he said it better and more concisely than I could have, so thanks for that, @jcalz!).

@RyanCavanaugh
Copy link
Member

This issue is old enough to be in kindergarten by now. Has no consensus been reached or has it just been abandoned?

As I said above, before my preschooler was born 😅:

Just to fill people in, this behavior is currently by design

This has not changed.

Changing the behavior at this point would honestly be an incredibly disruptive breaking change without much concrete upside. For clarity, I'm going to close and lock this - interfaces should declare an explicit index signature if they intend to be indexed.

@RyanCavanaugh RyanCavanaugh closed this as not planned Won't fix, can't repro, duplicate, stale Nov 18, 2022
@microsoft microsoft locked as resolved and limited conversation to collaborators Nov 18, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests