TypeScript Types or Interfaces for React component props
Ronald Rey
Posted on February 3, 2020
Photo by Simone Hutsch on Unsplash
When writing React components with TypeScript, you have two options when it comes to typing its props. You can use either type aliases or interfaces. Which one would you choose? Does it matter? Is there really an optimal choice? Are there any drawbacks to one or the other? Let's explore the relevant differences between them before we conclude.
This is not a deep case study of how types aliases and interfaces differ in TypeScript, but I'll provide just a brief overview of some of the differences that are relevant to React props so we're on the same page. Later we will explore how those differences can come into play in this context.
Type aliases vs. Interfaces
Type aliases and interfaces in TypeScript are equivalent in the majority of cases. Things that you can do in one you can also do with the other with just syntax changes, and of course, there are exceptions.
Let's take a look at some examples:
Regular object with properties
✔ Equivalent
type User = {
name: string
}
// ...
interface User {
name: string
}
Arrays or indexers
✔ Equivalent
Arrays or indexers:
type Users = User[]
// ...
interface Users {
[index: number]: User
}
👆 In this case, though, the interface would be missing all array methods like .push
, .map
, etc. so both definitions won't exactly be equivalent, and the interface would be less useful unless that's precisely what you are aiming for.
To remediate this, you would have to explicitly extend from the array type like so:
type Users = User[]
// ...
interface Users extends Array<User> {
[index: number]: User
}
Functions
✔ Equivalent
type GetUserFn = (name: string) => User
// ...
interface GetUserFn {
(name: string): User
}
Function overloading with added properties
✔ Equivalent
Let's use a real-world example, this is the it: TestFunction
type definition from mocha
, see the source here.
type TestFunction =
& ((fn: Func) => Test)
& ((fn: AsyncFunc) => Test)
& ((title: string, fn?: Func) => Test)
& ((title: string, fn?: AsyncFunc) => Test)
& {
only: ExclusiveTestFunction;
skip: PendingTestFunction;
retries(n: number): void;
};
// ...
interface TestFunction {
(fn: Func): Test
(fn: AsyncFunc): Test
(title: string, fn?: Func): Test
(title: string, fn?: AsyncFunc): Test
only: ExclusiveTestFunction
skip: PendingTestFunction
retries(n: number): void
}
Although you can achieve this with both, I would recommend sticking to an interface in this case because of the clearer semantics and cleaner syntax.
Merging
✔ Equivalent
Merging properties from different types into one, referred to as intersections when using types aliases, or extensions when using interfaces.
type SuperUser = User & { super: true }
// ...
interface SuperUser extends User {
super: true
}
type Z = A & B & C & D & E
// ...
interface Z extends A, B, C, D, E {}
There's one significant difference here that is not obvious from just looking at those examples. When extending interfaces, you absolutely need to declare a new one with the extension result, whereas with a type alias, you can inline the intersection type, for example:
function(_: A & B) {}
//...
interface Z extends A, B {}
function(_: Z) {}
Class implementation
✔ Equivalent (!)
This might seem counter-intuitive, but you can implement both type aliases and interfaces in classes!
type AnimalType = {}
interface IAnimal = {}
class Dog implements AnimalType {} // ✔ Works
class Cat implements IAnimal {} // ✔ Works
However, although possible with both, this use-case is more commonly attributed to interfaces due to classic object-oriented language design, and it's safe to say you will rarely see types used this way in real-world codebases.
Union types
❌ NOT Equivalent
It is possible to define a type that is either one thing, or the other when declaring it as a type alias by using the union type syntax, but this is not possible with an interface:
type Z = A | B
//...
interface IZ extends A | B {} // <- ❌ INVALID SYNTAX, not possible to achieve this
It is also not possible to extend from a type that is declared as a union type.
type Z = A | B
interface IZ extends Z {} // ❌ Compilation error:
// "An interface can only extend an object type or intersection
// of object types with statically known members."
Redeclaration
❌ NOT Equivalent
There's another way of extending an interface definition. By redeclaring it, whatever is defined in the latest declaration will be merged with the properties of all previous declarations. So you can say that an interface's behavior is very similar to the cascading nature of CSS.
interface User {
name: string
}
interface User {
gender: string
}
const user: User = { name: 'Ronald', gender: 'male' }
This is not possible to achieve with a type alias though:
type User = { name: string }
type User = { gender: string } // ❌ Compilation error
// "Duplicate identifier 'User'."
This is particularly useful if you need to extend an existing object's definition whose type is declared outside of your reach, i.e. it's coming from a third-party package, or it's part of the standard library.
Imagine your web app adds a few properties to the window
object. You won't be able to use your added properties without getting a compilation error because they won't be part of the original definition of the Window
type. But since Window
is declared as an interface, you can do this somewhere near the entry point of your client app:
declare global {
interface Window {
$: jQuery
}
}
// ...
// and now you use $ globally without type errors
window.$; // 👍
NOTE: This is not an encouragement to use jQuery.
Using in React props
With all those considerations in mind, which one would you say is the best choice for typing a React component's props? Is there a unique best practice? Can we say that using one or the other is an anti-pattern or should be avoided? Let's unpack.
When I see props declared with an interface, I immediately stop in my tracks and think: "Is it declared as an interface because the developer is going to be implementing it in a class later?", "Is it declared as an interface because the developer is going to redeclare it later, or is the possibility of redeclaration an intended feature of this component? If it is, how does this affect the usage of the component, if at all?"
I then proceed to start looking for the answer to these questions before I continue doing what I was doing, most times to little fruition because those were not factors involved in the decision to use an interface, but at this point, I've already wasted development time and more importantly precious scarce cognitive resources that I will never get back.
I don't ask myself those questions when I see a type alias though. A type alias feels like a more appropriate language construct for plainly defining what the shape of an object should look like and is more akin to functional-style programming, so it feels more at home with React given how React itself is a functional stab at designing user interfaces. An interface, on the other hand, has a lot of object-oriented baggage associated with it that is irrelevant when we specifically talk about React component props, and object-oriented programming is not React's paradigm.
Also, as you could see from the previous examples, types declarations are almost always more concise than their interface counterparts because of their syntax, and they can also be more composable thanks to the possibility of unions. If the prop object you are typing is really small you can get away with inlining it in the function declaration too, which you wouldn't be able to do if you are strictly sticking to interfaces.
Cool, does this mean I would always use a type for props, rather than interfaces? If you go and explore the type definition files for the most popular React re-usable component libraries you will notice that most of them use interfaces for props instead, so you could conclude that this is the globally accepted community approach for some reason.
When it comes to re-usable libraries, using interfaces instead is a very good and pragmatic choice as it allows the library itself to be more flexible because now each consumer can re-declare each of those interfaces as needed to add properties. This is useful because many OSS libraries separately maintain their type definitions from their sources so it's common for those definitions to get out of date, and when they do, users can easily get around it by leveraging the interfaces, and maintainers themselves are not bombarded with compilation related issue reports from the community.
But let's imagine a different scenario now. Imagine you are working in a multi-team company where many different teams work independently in their own front-end apps, but all depend on a private/internal re-usable component library that your team owns but everybody else contributes to. By nature, humans will always strive to find the path of least resistance to their goals. If you decided to use interfaces because of the reasons I stated above, it's very likely that when another team encounters a typing inconsistency issue they decide to quickly fix it in their codebases by leveraging the flexibility of extension points rather than contributing a fix upstream, and further fragmenting the consistency of the development experience across the company as a result.
In this case, I want to avoid providing too much extension or flexibility, and an interface's characteristics would be harmful.
Conclusion
So, what's my definitive answer? Type aliases or interfaces? My answer is: "I don't care" and "it depends".
Both types and interfaces are almost the same, and their fundamental differences are not that relevant for the super-simplistic case of React component props. Use whichever one you feel comfortable with unless there's a specific valid reason to use one over the other, like in the examples I laid out above.
The only thing I ask of you is that you don't mislead others into thinking that "you should always use types for React props" or "React props should always be declared with interfaces" and that one or the other is a "best practice" or "anti-pattern". All "best practices" are best practices because of several reasons that are situational and conditional and may not apply to all cases. Based on my experience, many engineers won't be brave or confident enough to challenge those assumptions and will go on with their lives living a lie that can affect their careers.
If you take away anything from this blog post is this:
- Always challenge preconceived notions, assumptions and established "best practices".
- Don't forget the reasoning behind best practices. And if you do, look them up before you use it in an argument or make a decision based on it.
- If the line that divides many options is too blurry, the factors involved are too difficult to spot or are very trivial, don't waste the youth of your brain and go with whichever.
Posted on February 3, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
March 11, 2023