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

TypeScript: types vs. interfaces #64

Open
jhnns opened this issue Jul 7, 2019 · 8 comments
Open

TypeScript: types vs. interfaces #64

jhnns opened this issue Jul 7, 2019 · 8 comments

Comments

@jhnns
Copy link
Member

jhnns commented Jul 7, 2019

TypeScript has two ways to describe the shape of an object: a type alias and an interface. I would like to collect some arguments for the discussion around types vs interfaces.

Historically, interfaces were more powerful which is why a lot of people went with interfaces. But nowadays the differences are not that big anymore. To summarize:

  • Type aliases don't create new names in error messages (not correct anymore)
  • Type aliases cannot be implemented by classes (not correct anymore, only union types are not implementable which are impossible to do with interfaces anyway)
  • Two type aliases with the same name throw an error whereas two interfaces with the same name are merged into a single one (known as "Declaration merging"). This is an advantage of interfaces when dealing with third-party libraries, but can be a disadvantage if there is a name conflict in the current file (Example).
  • Mapped types via Key in AllowedKey are impossible with interfaces (Example)
  • Interfaces can reference itself in its definition via this. Type aliases need to use the type alias name (which can be impossible sometimes TypeScript 3.7 supports recursive types) (Example)
  • Interfaces have the polymorphic this type. Some inheritance patterns cannot be expressed via types (Example)
  • CodeLens In VSCode has better support for interfaces (not correct anymore, see below)
  • Intersection types can create types that are impossible to implement, whereas interfaces will show an error at the extends keyword (Example)
  • On the other hand, types can express intersection types that are impossible to express with interfaces (e.g. merging of types that overlap, like string and "some string") (Example)
  • Interfaces must always have a name. Types can be inlined without giving them a name, e.g. when instantiating the React.FC<{}> generic.

Personally I prefer type aliases because of the following reasons:

1. It's less to write (and less to read)

type A = AnotherType & ADifferentType & {
    a: boolean;
};

// vs

interface A extends AnotherType, ADifferentType {
    a: boolean;
}

Ok, that's not a big difference, but look at the next example:

const Component: React.SFC<Readonly<{
    a: boolean;
    b: string;
    c: number;
}>> = ({a, b, c}) => {};

// vs

interface ComponentProps {
   readonly a: boolean;
   readonly b: string;
   readonly c: number;
}

const Component: React.SFC<ComponentProps> = ({a, b, c}) => {};

2. It's a single way to handle types

One problem I see is that you can't really use interfaces in an ergonomic way when you want to use TS' utility types, like Readonly:

// we have to create a mutable interface first
// before we can create a readonly type :(
interface A {}
type B = Readonly<A>; 

type on the other hand provides a single way to define and merge types as you like. For instance, union types are impossible with interfaces, but straightforward with type aliases.

In general I think it's impossible to have a big project without using type, but it's possible to have a big project without interface. We used type aliases in a big project and never really missed interfaces.


For me there are only three valid reasons for interfaces:

Nicer hints in VSCode

Maybe they will improve it for type as well. I don't see a reason why this is only implemented for interfaces. It's possible to get a CodeLens for type references though (see below).

Third-party libs

It's often recommended to use interfaces for third-party libs because of declaration merging. This makes it possible to extend the type with own properties.

While this is true for libraries like Express that provide a single Request or Response object which is extended by middlewares, I don't think that it's true for third-party libraries that don't support this kind of API. Personally I even think that it's bad practice to have an API where objects can be extended by everyone. So why should I use interfaces if I don't want to support this kind of usage anyway.

OO programming style

If your project/team heavily uses an object-oriented programming style interfaces make much more sense.


What do you think?

@jhnns
Copy link
Member Author

jhnns commented Jul 18, 2019

Related discussion in the TypeScript ESLint repo: typescript-eslint/typescript-eslint#142


Btw: I just found out that the VSCode Lens can also show references of types:

Screenshot 2019-07-18 at 13 32 17
Screenshot 2019-07-18 at 13 32 05

Related discussion in the VSCode repo: microsoft/vscode#76706

@jhnns
Copy link
Member Author

jhnns commented Aug 1, 2019

I found an interesting use case for declaration merging I haven't considered yet:

const Todo = (name: string) => ({
    name,
    description: undefined as string | undefined,
});

interface Todo extends ReturnType<typeof Todo> {}

function renderTodo(todo: Todo) {
    // ...
}

renderTodo(Todo("Do something"));

(playground)

Here the Todo function is merged with the interface. With types you can't merge the Todo type with the Todo function:

const createTodo = (name: string) => ({
    name,
    description: undefined as string | undefined,
});

type Todo = ReturnType<typeof createTodo>;

function renderTodo(todo: Todo) {
    // ...
}

renderTodo(createTodo("Do something"));

(playground)

However, you could still use classes:

class Todo {
    description?: string = undefined;
    constructor(
        public name: string,
    ) {}
}

function renderTodo(todo: Todo) {
    // ...
}

renderTodo(new Todo("Do something"));

(playground)

I don't think that this is relevant to us since creating objects with a capitalized regular function (like Todo("Do something")) is not very typical in JS land and our linting rules would complain about it anyway. But I wanted to add that use case since I haven't considered it yet.

@hpohlmeyer
Copy link
Member

Two things I like about interfaces, that are problematic with types:

Code lens visibility in VS Code
Since types are aliases they show their definition instead of their name.
If you hover over the variables in this example you will see the name of the interface and the definition of the type. I think if the interface has a descriptive name it is way easier to understand what is behind it.

Impossible types with intersecion types

type X = {
    foo: number,
    bar: string
}

type Y = {
    foo: string,
    baz: boolean
}

type XY = X & Y;

const xy: XY = {
    foo: 3, // Impossible to get right, because it has to be a number and a string at the same time and won’t be number | string
    bar: 'test',
    baz: true
}

I think the result of X & Y is not clear immediately and can be very problematic if you use generics and do not know which types will be merged. Extending interfaces is far more predictable.

@jhnns
Copy link
Member Author

jhnns commented Aug 2, 2019

Code lens visibility in VS Code

Yes, I agree. Code lens is still better for interfaces. But sometimes it's even an advantage when you can inspect the type by just hovering it.

Since type is a type alias, I don't get why they don't just show the aliased name, but the whole underlying type. Isn't the purpose of an alias to use it "in place" of something else?

Impossible types with intersection types

I agree. When using interfaces, the error message appears at the location where the actual error happened:

interface X {
    foo: number,
    bar: string
}

interface Y {
    foo: string,
    baz: boolean
}

interface XY extends X, Y {} // Interface 'XY' cannot simultaneously extend types 'X' and 'Y'. Named property 'foo' of types 'X' and 'Y' are not identical.

Playground

I haven't experienced these kind of errors yet, but it's a valid point against types.

Here's an explanation why TypeScript can't detect impossible types: https://stackoverflow.com/a/39554027

@jhnns
Copy link
Member Author

jhnns commented Dec 26, 2019

Impossible types with intersection types

You could also come up with examples where this behavior of intersection types is an advantage:

type TypeA = {
    name: string;
};

type TypeB = {
    name: "Something";
};

type TypeAB = TypeA & TypeB;

const typeAB1: TypeAB = {
    // Works
    name: "Something",
};
const typeAB2: TypeAB = {
    // Type '"Something else"' is not assignable to type '"Something"'
    name: "Something else",
};

interface InterfaceA {
    name: string;
}

interface InterfaceB {
    name: "Something";
}

// Named property 'name' of types 'InterfaceA' and 'InterfaceB' are not identical.(2320)
interface InterfaceAB extends InterfaceA, InterfaceB {}

Playground

@jhnns
Copy link
Member Author

jhnns commented Dec 26, 2019

Interfaces used to have the advantage that they can express recursive types via this. Since TypeScript 3.7 recursive types are supported.

This is no error anymore:

type B = {
    a: boolean;
    b: B["a"];
};

Playground

@jhnns
Copy link
Member Author

jhnns commented Dec 27, 2019

Btw: Here is a (closed) issue at the TypeScript repo about the CodeLens issues of types: microsoft/TypeScript#13095

@jhnns
Copy link
Member Author

jhnns commented Dec 28, 2019

The fact that interfaces can use the polymorphic this type (and types cannot) leads to a situation where interfaces have a clear advantage over types when using classes:

interface I {
    returnThis: () => this;
}

type T = {
    returnThis: () => T;
};

class ClassI implements I {
    // Error which is correct, because returnThis should return the instance
    returnThis = () => new ClassI();
}

class ClassT implements T {
    // No error :(
    returnThis = () => new ClassT();
}

Playground

This leads me to the conclusion that interfaces should be used when you're describing objects that participate in an inheritance chain. Essentially: Objects created with new Something() and also objects that create instances (= Classes) should be described via interfaces.

For regular data objects (like React props for instance), where this has no meaning or is not used, you should use type. In short: If you used an object literal to create that thing, it should be a type.

I wonder whether that can be expressed as ESLint rule 😁

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants