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

Omit index signature with generic type constraints? #10941

Closed
JabX opened this issue Sep 15, 2016 · 14 comments
Closed

Omit index signature with generic type constraints? #10941

JabX opened this issue Sep 15, 2016 · 14 comments
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@JabX
Copy link

JabX commented Sep 15, 2016

TypeScript Version: nightly 2.1-20160914

I have a base class that takes a generic parameter with a constraint that is an index signature. Something like

interface Constraint {
    [key: string]: number
}

class Base<T extends Constraint> {
    entity: T
}

This class is meant to be inherited from and provided with another type that satisfies the index signature but doesn't carry it itself. Something like

interface MyType {
    prop: number;
    label: number;
    code: number
}

class MyClass extends Base<MyType> {
    // Error: Index signature is missing from MyType
}

I don't want to put the index signature on MyType because destructuring entity won't output an error when the property doesn't exist:

const {id} = this.entity // oops, no error

which is a critical feature for me.

I don't want to remove the index signature from the constraint because the base class completely revolves around it and I would lose a lot of type safety.

Is there something I missed? Should we have some new language feature to solve this issue?

(Now that I think of it I suppose it's a generic assignability issue between types, not restricted of type constraints, and it's not solved by #7029 since it's only about actual litterals, not types)

@RyanCavanaugh
Copy link
Member

The problem with not having the index signature is that your class becomes unsound to basic subtyping. Consider:

interface Stuff {
    age: number;
}
interface StuffWithName extends Stuff {
    name: string;
}

interface Constraint<T> {
    // [key: string]: T;
}

class Base<T extends Constraint<T>> {
    setT(x: T) { }
}

class Derived extends Base<Stuff> {
}

let d = new Derived();
let s: StuffWithName = { age: 10, name: '' };
d.setT(s);

@JabX
Copy link
Author

JabX commented Sep 15, 2016

Well, I definitely want to keep the index signature around, but at the same time I don't want every derived type to be forced to implement it. Like something that's defined somewhere up in the hierarchy of types that's properly restricting any derived type without having them carrying more information than they should. I don't know exactly how the type system works internally, but I'm pretty sure there's no such thing as an "inheritance chain for types", am I right?

Coming back to the realm of possibilities, how would you address my problem then? Am I really limited to one of the two propositions I made (no index signature or index signature everywhere)?

@RyanCavanaugh
Copy link
Member

You could write

interface MyType extends Constraint {
    prop: number;
    label: number;
    code: number
}

to save the trouble of typing the index signature. Thoughts?

@JabX
Copy link
Author

JabX commented Sep 15, 2016

Typing the index signature isn't a problem at all, my problem is more about the fact my derived type carries it.

Maybe I should tell you more about what I am trying to achieve:

My base class is a React component that's supposed to take some kind of store as parameter, wrap it up in some viewmodel logic, and make it up for grabs for a derived class to use it.

It looks like this

interface StoreItem<T> {
      metadata: {},
      value: T
}

interface Store {
     [key: string]: StoreItem<any>
}

abstract class Base<P, E extends Store> extends React.Component<P, void> {
   viewModel: E;
   constructor(props, store: E) {
      this.viewmodel = createViewModel(store): 
  }
   renderProp(prop: StoreItem<any>) {
       // Something something rendering
   }
}

interface MyStore {
    prop1: StoreItem<string>;
    prop2: StoreItem<number>;
}

class MyClass extends Base<{}, MyStore> {
     constructor(props) { super(props, myStore); }
     render() {
         const {prop1, prop2} = this.viewModel;
         return <div>{this.renderProp(prop1)}{this.renderProp(prop2)}</div>;
    }
}

That's the essence of what I'm trying to do. This doesn't work because MyStore doesn't have Store's index signature.

All those stores are generated from my server-side model, and I want to be able to safely modify this model, meaning if that one property is removed/changes, my app would stop compiling.

Let's say MyStore does inherit the index signature from Store, and then I do some refactorings that renames prop1 to name and prop2 to surname. The above code would still compile and still be typed alright because of the index signature, but at runtime it would crash because those properties don't exist anymore. I already made a similar thing that I extensively use in a project at work and I cannot stress enough how important that feature is for me.

Removing the index signature altogether could make this work, but at the expense of losing all typechecking in the base class and making the contract of the class implicit (how am I suppose to enforce that the type parameter should follow an index signature that's not there?).

@RyanCavanaugh RyanCavanaugh self-assigned this Sep 15, 2016
@Pajn
Copy link

Pajn commented Sep 16, 2016

This has been a problem for me as well. I think there needs to be a way to differentiate "handles objects with any key as long as the value has type T" and "requires object that can handle any key as long as the type is T"

@rado-nikolov
Copy link

rado-nikolov commented Apr 14, 2017

I had a similar problem and here is how I "solved" it:

I have a basic interface with index signature, e.g.:

interface StringOnlyProps {
  [name: string]: string;
}

I also have a generic class that takes above types:

class DoSomethingWithStringOnlyProps<T extends StringOnlyProps> {...}

Now, here is how I did it:

All my other interfaces do not extend the constraint interface as I don't want to introduce "dynamic" properties / index signature in them, e.g.:

interface X { ... }
interface Y { ... }

However, I defined an interface whose sole purpose is to break compilation if some of X,Y,... does not conform to StringOnlyProps:

interface Constraint implements X, Y, StringOnlyProps { } // empty

I even changed the generic above to require presence of such a constraint:

class DoSomethingWithStringOnlyProps<T, C extends StringOnlyProps & T> {
 // C is unused here but its existence guarantees T and StringOnly are compatible
}

Now, if you change a type - Constraint would not compile.

If you rename a property - code that uses X, Y or whatever, would not compile as they do not anymore provide index signature.

@RyanCavanaugh RyanCavanaugh added Needs Investigation This issue needs a team member to investigate its status. and removed Needs Investigation This issue needs a team member to investigate its status. labels May 24, 2017
@aigoncharov
Copy link

@RyanCavanaugh did you have a chance to think about the issue?

@aigoncharov
Copy link

@JabX have you found a way around the issue, other than what @rado-nikolov suggested?

@aigoncharov
Copy link

@RyanCavanaugh @JabX
I noticed an interesting thing. If try to pass an declared earlier interface as a generic, then compiler complains about it. If I declare the interface in place, then there's no compilation error.

const func = <T extends { [index: string]: string }>(a: T) => a

func<{ test: string }>({ test: '123' }) // works

interface A {
  test: string
}
func<A>({ test: '123' }) // fails

Playground
Tested with typescript 3.0-rc

@aigoncharov
Copy link

Found a workaround to have an index signature constraint for function parameters, but not generic itself

interface A {
    a: string,
    b: string
}
type AConstraint<T extends A> = T & { [index: string]: string }
const a = <T extends A>(props: AConstraint<T>): T => props 

const propsTest1: A = { a: '1', b: '2' }
a(propsTest1 as AConstraint<A>)

const propsTest2 = { a: '1', b: '2', c: 'ftdrff' }
a(propsTest2)

Playground

@aigoncharov
Copy link

@Andy-MS sorry for disturbing you directly, but could someone from TypeScript team take a look at the issue? It was marked as 'Needs investigation' almost one and a half years ago. Thank you!

@kristw
Copy link

kristw commented Mar 12, 2019

@keenondrums I ran into the same issue with you and after reading this thread over and over + many trials and errors, here is a working solution. Thanks for starting the discussion.

type ValueIsNumber<T> = {
  [key in keyof T]: number;
}

class Base<T extends ValueIsNumber<T>> {
  entity: T
}

interface MyType {
  prop: number;
  label: number;
  code: number
}

class MyClass extends Base<MyType> {
  // works!
}

@aigoncharov
Copy link

@kristw thank you! Yeah, using mapped types solves the issue, until I stumble upon a case when it doesn't :)

@RyanCavanaugh RyanCavanaugh removed their assignment Mar 12, 2019
@RyanCavanaugh RyanCavanaugh added Working as Intended The behavior described is the intended behavior; this is not a bug and removed Needs Investigation This issue needs a team member to investigate its status. labels Mar 12, 2019
@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

7 participants