From Effective TypeScript: 62 Specific Ways to Improve Your TypeScript by Dan Vanderkam
It is a really good book. Please purchase the book to support its author!
If you are the publisher and think this article should not be public, please write me an email to
trungk18 [at] gmail [dot] com
and I will make it private.
If you want to define a named type in TypeScript, you have two options. You can use a type
as show here:
type TState = {
name: string;
capital: string;
}
or an interface:
interface IState {
name: string
capital: string
}
You could also use a
class
, but that is a JavaScript runtime concept that also introduces a value.
Which should you use, type or interface? The line between these two options has become increasingly blurred over the years, to the point that in many situations you can use either. You should be aware of the distinctions that remain between type and interface and be consistent about which you use in which situation. But you should also know how to write the same types using both, so that you’ll be comfortable reading TypeScript that uses either.
Warning: The examples in this item prefix type names with I or T solely to indicate how they were defined. You should not do this in your code! Prefixing interface types with I is common in C#, and this convention made some inroads in the early days of TypeScript. But it is considered bad style today because it’s unnecessary, adds little value, and is not consistently followed in the standard libraries.
If you define an IState
or a TState
value with an extra property, the errors you get are character-by-character identical:
const wyoming: TState = {
name: 'Wyoming',
capital: 'Cheyenne',
population: 500_000
// ~~~~~~~~~~~~~~~~~~ Type ... is not assignable to type 'TState'
// Object literal may only specify known properties, and
// 'population' does not exist in type 'TState'
};
type TDict = { [key: string]: string };
interface IDict {
[key: string]: string;
}
type TFn = (x: number) => string;
interface IFn {
(x: number): string;
}
const toStrT: TFn = x => '' + x; // OK
const toStrI: IFn = x => '' + x; // OK
The type alias looks more natural for this straightforward function type, but if the type has properties as well, then the declarations start to look more alike:
type TFnWithProperties = {
(x: number): number;
prop: string;
}
interface IFnWithProperties {
(x: number): number;
prop: string;
}
You can remember this syntax by reminding yourself that in JavaScript, functions are callable objects.
type TPair<T> = {
first: T;
second: T;
}
interface IPair<T> {
first: T;
second: T;
}
interface IStateWithPop extends TState {
population: number;
}
type TStateWithPop = IState & { population: number; };
Again, these types are identical. The caveat is that an interface cannot extend a complex type like a union type. If you want to do that, you’ll need to use type and &
.
class StateT implements TState {
name: string = '';
capital: string = '';
}
class StateI implements IState {
name: string = '';
capital: string = '';
}
type AorB = 'a' | 'b';
Extending union types can be useful. If you have separate types for Input
and Output
variables and a mapping from name to variable:
type Input = { /* ... */ };
type Output = { /* ... */ };
interface VariableMap {
[name: string]: Input | Output;
}
then you might want a type that attaches the name to the variable. This would be:
type NamedVariable = (Input | Output) & { name: string };
This type cannot be expressed with interface.
A type is, in general, more capable than an interface. It can be a union, and it can also take advantage of more advanced features like mapped or conditional types.
type Pair = [number, number];
type StringList = string[];
type NamedNums = [string, ...number[]];
You can express something like a tuple using interface:
interface Tuple {
0: number;
1: number;
length: 2;
}
const t: Tuple = [10, 20]; // OK
But this is awkward and drops all the tuple methods like concat. Better to use a type.
One of these is that an interface can be augmented. Going back to the State
example, you could have added a population field in another way:
interface IState {
name: string;
capital: string;
}
interface IState {
population: number;
}
const wyoming: IState = {
name: 'Wyoming',
capital: 'Cheyenne',
population: 500_000
}; // OK
This is known as “declaration merging”, and it’s quite surprising if you’ve never seen it before. This is primarily used with type declaration files (Chapter 6), and if you’re writing one, you should follow the norms and use interface to support it. The idea is that there may be gaps in your type declarations that users need to fill, and this is how they do it.
TypeScript uses merging to get different types for the different versions of JavaScript’s standard library. The Array interface, for example, is defined in lib.es5.d.ts
. By default this is all you get. But if you add ES2015 to the lib entry of your tsconfig.json
, TypeScript will also include lib.es2015.d.ts
. This includes another Array interface with additional methods like find that were added in ES2015. They get added to the other Array interface via merging. The net effect is that you get a single Array type with exactly the right methods.
Merging is supported in regular code as well as declarations, and you should be aware of the possibility. If it’s essential that no one ever augment your type, then use type.
For complex types, you have no choice: you need to use a type alias. But what about the simpler object types that can be represented either way? To answer this question, you should consider consistency and augmentation. Are you working in a codebase that consistently uses interface? Then stick with interface. Does it use type? Then use type.
For projects without an established style, you should think about augmentation. Are you publishing type declarations for an API? Then it might be helpful for your users to be able to be able to merge in new fields via an interface when the API changes. So use interface. But for a type that’s used internally in your project, declaration merging is likely to be a mistake. So prefer type.