microsoft / TypeScript

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

Allow extending multiple interfaces with different, but compatible types #16936

Open vilicvane opened 7 years ago

vilicvane commented 7 years ago
interface Change {
  uid: string;
  type: string;
}

interface SomeChangeExtension {
  type: 'some';
  foo: number;
}

interface SomeChange extends Change, SomeChangeExtension { }

In this example, I was expecting SomeChange to have a type equivalent to:

interface SomeChange {
  uid: string;
  type: 'some';
  foo: number;
}

But it would result in error:

Interface 'SomeChange' cannot simultaneously extend types 'Change' and 'SomeChangeExtension'.
  Named property 'type' of types 'Change' and 'SomeChangeExtension' are not identical.

In this case, 'some' is compatible with string if the order of interfaces being extended is respected.

The reason why I want this to be allowed is that, I need to maintain multiple interfaces of the same kind of change during different stages: raw change, change, broadcast change. And having to duplicate part of the "extension" on every of them doesn't look good.

mhegazy commented 7 years ago

So what is the proposal here? use intersection types?

vilicvane commented 7 years ago

@mhegazy I was thinking about respect the extensions order, and if the later one is compatible with the former one, then use the later one instead.

E.g.:

interface You {
  name: string;
  value: number;
}

interface FooYou {
  name: 'foo';
}

interface ConcreteFooYou extends You, FooYou {
  concrete: boolean;
}

In which ConcreteFooYou should be equivalent to:

interface ConcreteFooYou {
  name: 'foo';
  value: number;
  concrete: boolean;
}
iainjreid commented 7 years ago

Having the ability to perform multiple extends is something that I've personally been waiting for for a very long time from TS, it would really bring a lot to the language!

At some point I tried hacking about with the following to try to add this behaviour (I can't remember exactly what, and I know that the below doesn't work), but I haven't been able to make anything sit correctly with regards to types:

// Using two traits for the sake of an example, but obviously this would be overloaded
function mix<T1, T2>(trait1: T1, trait2: T2): new(...args: any[]) => T1 & T2 {
  // ... merge the prototypes of the two parent classes
}

In terms of runtime functionality, there was no issue what-so-ever with the merging of prototypes. Regarding conflicts in method or property names, the conflicting parent class takes a backseat thereby respecting the usual order of inheritance, the only real issue is the lack of the correct super behaviour (and of course the fact that this is really just a bad hack).

mhegazy commented 7 years ago

Having the ability to perform multiple extends is something that I've personally been waiting for for a very long time from TS, it would really bring a lot to the language!

you can extend multiple interfaces. for classes, you can do this using mixins.

iainjreid commented 7 years ago

Mixins require you to redeclare the types in the implementing class, which is pretty messy in large projects. What the community would benefit more from is a similar behaviour to Scala traits.

mhegazy commented 7 years ago

That is a discussion i would suggest bringing to TC39. any thing we do in this space can not conflict with future JS direction.

Tyriar commented 6 years ago

I just ran into this issue, here's my use case:

I was trying to use the public API I define in xterm.d.ts inside the actual library, instead of just reimplementing it. But the public API contains a specific overloads for Terminal than the actual Terminal class, for example:

on(type: 'blur' | 'focus' | 'linefeed' | 'selection', listener: () => void): void;

Since I was using multiple inheritance they were conflicting:

export interface ITerminal extends PublicTerminal, IEventEmitter, ... { ... }

This conflicts with the generic IEventEmitter interface:

on(type: string, listener: (...args: any[]) => void): void

Here's a small snippet that demonstrates my specific problem:

interface IBase1 {
  f(arg: 'data'): void;
  f(arg: string): void;
}

interface IBase2 {
  f(arg: string): void;
}

interface IOther extends IBase1, IBase2 {
}
Interface 'IOther' cannot simultaneously extend types 'IBase1' and 'IBase2'.
  Named property 'f' of types 'IBase1' and 'IBase2' are not identical.

I think in the end I can work around this by moving IEventEmitter into the .d.ts but ideally I didn't really want to expose all those methods (doesn't matter too much though). It now looks something more like this:

interface IBase1 extends IBase2 {
  f(arg: 'data'): void;
  f(arg: string): void;
}

interface IBase2 {
  f(arg: string): void;
}

interface IOther extends IBase1 {
}
SephReed commented 6 years ago

Bumping this. Would be nice to be able to...

import { IControlPanelRoutes } from "control-panel"
import { IHomepageRoutes } from "home-page";

interaface IAllRoutes extends IControlPanelRoutes & IHomepageRoutes {};

props.routing: IAllRoutes = {
  controlPanel: "/control-panel",
  controlPanel_overview: "/control-panel/overview",
  controlPanel_settings: "/control-panel/settings",
  homePage: "",
  homePage_about: "/about",
}
NewEraCracker commented 6 years ago

I think you already can:

interface IAllRoutes extends IControlPanelRoutes, IHomepageRoutes {};

Just a catch, if the objects share same property names, the types need to match.

nhhockeyplayer commented 6 years ago

has any ground been made on multiple extends? developers need object oriented abilities for real world modeling

in java this is permitted in interfaces

this should be ok in typescript at least for interfaces

declaration merging seems to be what Im looking for https://www.typescriptlang.org/docs/handbook/declaration-merging.html

vilicvane commented 6 years ago

@NewEraCracker You missed the whole point, the request here is to allow compatible (rather than identical) types to match.

manugb commented 6 years ago

+1 to compatible types when extending multiple interfaces.

If we define SomeChange with type alias and intersection we end up with the expected type.

type SomeChange = Change & SomeChangeExtension;
// end up typed as { uid: string; type: 'some'; foo: number; }

Anyway this is not a solution because we lose the properties of the ts interface. Why the type intersection is different from the interface extension?

RyanCavanaugh commented 6 years ago

Different syntax does different stuff. It's how we let you write different types 😉

mharj commented 5 years ago

+1 I'm try to model Ldap ObjectClass like array type in place and I'm facing issue that I can't combine two interfaces for object as they don't share same enum values. (even if any enum value is in allowed to objectClass in main level interface) https://stackoverflow.com/questions/54019627/building-combined-interface-array

Dalqin commented 5 years ago

If we define SomeChange with type alias and intersection we end up with the expected type.

type SomeChange = Change & SomeChangeExtension;
// end up typed as { uid: string; type: 'some'; foo: number; }

Anyway this is not a solution because we lose the properties of the ts interface. Why the type intersection is different from the interface extension?

@manugb Why can't you use your type, then make an interface that extends SomeChange? Or does this not get you what you're after?

interface SomeMoreChange extends SomeChange {
   // props of Change and SomeChangeExtension...
   // additional props...
}
Mmoks commented 5 years ago

@vilic This should help you

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

interface Change {
  uid: string;
  type: string;
}

interface SomeChangeExtension {
  type: 'some';
  foo: number;
  amount: number;
}

interface SomeChange extends Change, Omit<SomeChangeExtension, 'foo' | 'amount'> {
 foo: string;
 amount: string;
}
MicahZoltu commented 5 years ago
interface Message { kind: string }
interface Request extends Message { payload: string }
interface Response extends Message { success: boolean }

interface HelloMessage extends Message { kind: 'hello' }
interface HelloRequest extends HelloMessage, Omit<Request, 'kind'> { ... }
interface HelloResponse extends HelloMessage, Omit<Response, 'kind'> { ... }

interface GoodbeMessage extends Message { kind: 'goodbye' }
type GoodbyeRequest = GoodbyeMessage & Request & { ... }
type GoodbyeResponse = GoodbyeMessage & Response & { ... }

The above shows the two ways I have figured out how to make this work, I believe both come with their own caveats. It certainly feels like extending from two conflicting interfaces where one is a narrowing of the other should "just work". My expectation, like others here, is that TypeScript should treat it like an intersection, just like the type solution above does. In this particular case, the kind comes from Message in both base types (Request and HelloMessage), its just that in one type path has narrowed kind while the other has not, so we can be guaranteed (at least in this situation) that the types are compatible with narrowing.

MicahZoltu commented 5 years ago

Another option I just discovered:

interface AppleMessage extends Message { kind: 'apple' }
interface AppleRequest extends AppleMessage, Request { kind: 'apple' }
interface AppleResponse extends AppleMessage, Response { kind: 'apple' }

While this one isn't DRY, you'll get a compiler error if you put anything other than kind: 'apple' in the AppleRequest and AppleResponse, so you can't really screw it up.

eramitmittal commented 4 years ago

Following gives compilation error as well

 interface IParent
    {
        somData: Object;
    }

    interface IChild extends IParent
    {
        someData: Object;
    }

    interface IHavePropertyOfTypeParent
    {
        fields: IParent[];
    }

    interface IHavePropertyOfTypeChild
    {
        fields: IChild[];
    }

    interface IMustBeInferredToHavePropertyOfTypeParent extends IHavePropertyOfTypeParent, IHavePropertyOfTypeChild
    {
    }

Workaround to make it work is

interface IMustBeInferredToHavePropertyOfTypeParent extends IHavePropertyOfTypeParent, IHavePropertyOfTypeChild
    {
        fields: IParent[];
    }
joshuakb2 commented 4 years ago

I have another example where being able to extend multiple interfaces with compatible types would be very useful.

Consider the EventEmitter class, which is very useful to extend in Node when creating custom classes. It has many functions with similar signatures, taking a string (the event name) and a function (the event handler).

It would be beneficial to redefine those functions with more specific types for the event names and the event handlers, but doing so is very verbose and tedious.

See this Typescript Playground example to see what I mean: Playground

aboks commented 4 years ago

Another quite unfortunate example of this issue is as follows:

interface Obj {
    "f": () => string | number;
}

// Works due to covariance: narrower return type for method is allowed
interface ObjSub extends Obj {
    "f": () => string;
}

// This effectively extends both ObjSub and Obj, and works
interface ObjSubSub extends ObjSub {}

// But when Obj is added explicitly, these interfaces are deemed incompatible
interface ObjSubSub2 extends ObjSub, Obj {}

Playground Link

It shows a case where two interfaces are deemed compatible when one extends the other, but when another interface explicitly extends from both they are considered incompatible. That sounds inconsistent, or is there something I'm overlooking here?

Feirell commented 3 years ago

If you need this feature like me for event emitter you could use the combined variation suggested above like so:

// this is the class you want to attach events typings
class Example {
  // implement / extend the logic to actually have the addEventListener etc.
}

// if we would like to attach 4 possible events with the event type Structure

EventEmitDef<'exampleA', Structure>
EventEmitDef<'exampleB', Structure>
EventEmitDef<'exampleC', Structure>
EventEmitDef<'exampleD', Structure>

// You would define the interface for those event by their specific methods to help the method inference

interface EventEmitDef<Name extends string, Structure extends object> {
    addEventListener(name: Name, listener: (ev: Structure) => void): void;

    dispatch(name: Name, event: Structure): void;

    removeListener(name: Name, listener: (ev: Structure) => void): void;
}

// which you would apply on the class by mixins

interface Example extends
    EventEmitDef<'exampleA', Structure>,
    EventEmitDef<'exampleB', Structure>,
    EventEmitDef<'exampleC', Structure>,
    EventEmitDef<'exampleD', Structure> {}

// Which does not work currently, but instead you could join them first and then extend them

type SimpleEventGroup =
    EventEmitDef<'exampleA', Structure> &
    EventEmitDef<'exampleB', Structure> &
    EventEmitDef<'exampleC', Structure> &
    EventEmitDef<'exampleD', Structure>

interface Example extends SimpleEventGroup {}

The mixing by extending the joined event group works out and TypeScript can correctly infer the methods present on the class. So addEventListener, removeEventListener and dispatch only allow the correct event names and the correct event structure types.

The above mixin translates to:

interface ActualExample{
    addEventListener(name: 'exampleA', listener: (ev: Structure) => void): void;
    addEventListener(name: 'exampleB', listener: (ev: Structure) => void): void;
    addEventListener(name: 'exampleC', listener: (ev: Structure) => void): void;
    addEventListener(name: 'exampleD', listener: (ev: Structure) => void): void;

    dispatch(name: 'exampleA', event: Structure): void;
    dispatch(name: 'exampleB', event: Structure): void;
    dispatch(name: 'exampleC', event: Structure): void;
    dispatch(name: 'exampleD', event: Structure): void;

    removeListener(name: 'exampleA', listener: (ev: Structure) => void): void;
    removeListener(name: 'exampleB', listener: (ev: Structure) => void): void;
    removeListener(name: 'exampleC', listener: (ev: Structure) => void): void;
    removeListener(name: 'exampleD', listener: (ev: Structure) => void): void;
}

If now you could change the class type by a decorator, it would be perfect 😁

EDIT: Sadly this seems to destroy the suggestions provided by the language server, which means that you still receive compile errors as intended but are missing the live suggestions of the strings possible.

carlitorweb commented 3 years ago

A workaround I follow when need extend from 2 or more interface is this:

type InputELement = InputHTMLAttributes<HTMLInputElement>;
type TextAreaElement = TextareaHTMLAttributes<HTMLTextAreaElement>;

type MyProps = InputELement & TextAreaElement & {
    type: string;
    name: string;
};
zeckdude commented 3 years ago

I'm curious what I'm missing. I'm pretty new to TS, but according to https://www.typescriptlang.org/docs/handbook/2/objects.html, it sure seems like you can extend multiple interfaces:

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

interface ColorfulCircle extends Colorful, Circle {}

const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
};

If that's the case, shouldn't this issue be closed?

Feirell commented 3 years ago

@zeckdude Yes you can extend two non-overlapping interfaces but this issue is concerned with two interfaces which have common identifier with different types. Have a look at the code provided in the issue.

pastean commented 3 years ago

Any updates on this?

WuglyakBolgoink commented 2 years ago

I'm curious what I'm missing. I'm pretty new to TS, but according to https://www.typescriptlang.org/docs/handbook/2/objects.html, it sure seems like you can extend multiple interfaces:

interface Colorful {
  color: string;
}

interface Circle {
  radius: number;
}

interface ColorfulCircle extends Colorful, Circle {}

const cc: ColorfulCircle = {
  color: "red",
  radius: 42,
};

If that's the case, shouldn't this issue be closed?

@zeckdude it doesn't work if I use classes..


class A {
    propA:string
}

class B {
    propB: number
}

interface C extends A, B {
   // ...
}

const foor: C = {}

it show multiple errors like:

TS2320: Interface 'C' cannot simultaneously extend types 'A' and 'B'.  Named property 'propA' of types 'A' and 'B' are not identical.
DetachHead commented 2 years ago

looks like this also happens when an interface is augmented with the same method twice, even if the types are exactly the same:

interface Foo<T> {
    a(): T
}

interface Foo<T> {
    a(): T
}

interface Bar<T> {
    a(): T
}

//Interface 'Baz<T>' cannot simultaneously extend types 'Foo<T>' and 'Bar<T>'.
// Named property 'a' of types 'Foo<T>' and 'Bar<T>' are not identical.(2320)
export interface Baz<T> extends Foo<T> , Bar<T> {}
dgreensp commented 2 years ago

This is definitely a noticeable omission to me when doing "diamond" interface inheritance.

Here's the simplest/best example I can come up with, based on what I'm implementing right now. Imagine you have an interface Foo and a subtype Bar that specializes some return types and adds some Bar-specific methods. Then you create ListenableFoo extends Foo, and you want to combine ListenableFoo and Bar to make ListenableBar:

interface Foo<T> {
  copy(): Foo<T>
}

interface Bar extends Foo<number> {
  copy(): Bar; // specialized return value
  someBarOnlyMethod(): void;
}

interface ListenableFoo<T> extends Foo<T> {
  listen(): void;
}

interface ListenableBar extends ListenableFoo<number>, Bar {
  copy(): Bar // need to repeat the specialized method or else error
}

In an ideal world, I would not have to repeat the signature of copy() in ListenableBar. TypeScript would see that Bar's type for the copy method is not identical to ListenableFoo<number>'s, but it is compatible.

An alternative workaround is to write:

interface ListenableBar extends ListenableFoo<number>, Bar {
  copy: Bar["copy"]
}