Interfaces in TypeScript
Famini-ProDev
Posted on April 23, 2022
Introduction
TypeScript is an extension of the JavaScript language that uses JavaScript’s runtime with a compile-time type checker.
TypeScript offers multiple ways to represent objects in your code, one of which is using interfaces. Interfaces in TypeScript have two usage scenarios: you can create a contract that classes must follow, such as the members that those classes must implement, and you can also represent types in your application, just like the normal type declaration. (For more about types, check out How to Use Basic Types in TypeScript and How to Create Custom Types in TypeScript.)
You may notice that interfaces and types share a similar set of features; in fact, one can almost always replace the other. The main difference is that interfaces may have more than one declaration for the same interface, which TypeScript will merge, while types can only be declared once. You can also use types to create aliases of primitive types (such as string and boolean), which interfaces cannot do.
Interfaces in TypeScript are a powerful way to represent type structures. They allow you to make the usage of those structures type-safe and document them simultaneously, directly improving the developer experience.
In this tutorial, you will create interfaces in TypeScript, learn how to use them, and understand the differences between normal types and interfaces. You will try out different code samples, which you can follow in your own TypeScript environment or the TypeScript Playground, an online environment that allows you to write TypeScript directly in the browser.
Creating and Using Interfaces in TypeScript
In this section, you will create interfaces using different features available in TypeScript. You will also learn how to use the interfaces you created.
Interfaces in TypeScript are created by using the interface keyword followed by the name of the interface, and then a {} block with the body of the interface. For example, here is a Logger interface:
interface Logger {
log: (message: string) => void;
}
Similar to creating a normal type using the type
declaration, you specify the fields of the type, and their type, in the {}
:
interface Logger {
log: (message: string) => void;
}
The Logger
interface represents an object that has a single property called log
. This property is a function that accepts a single parameter of type string
and returns void
.
You can use the Logger
interface as any other type. Here is an example creating an object literal that matches the Logger
interface:
interface Logger {
log: (message: string) => void;
}
const logger: Logger = {
log: (message) => console.log(message),
};
Values using the Logger
interface as their type must have the same members as those specified in the Logger
interface declaration. If some members are optional, they may be omitted.
Since values must follow what is declared in the interface, adding extraneous fields will cause a compilation error. For example, in the object literal, try adding a new property that is missing from the interface:
interface Logger {
log: (message: string) => void;
}
const logger: Logger = {
log: (message) => console.log(message),
otherProp: true,
};
In this case, the TypeScript Compiler would emit error 2322, as this property does not exist in the Logger interface declaration:
Output
Type '{ log: (message: string) => void; otherProp: boolean; }' is not assignable to type 'Logger'.
Object literal may only specify known properties, and 'otherProp' does not exist in type 'Logger'. (2322)
Similar to using normal type declarations, properties can be turned into an optional property by appending ? to their name.
Extending Other Types
When creating interfaces, you can extend from different object types, allowing your interfaces to include all the type information from the extended types. This enables you to write small interfaces with a common set of fields and use them as building blocks to create new interfaces.
Imagine you have a Clearable interface, such as this one:
interface Clearable {
clear: () => void;
}
You could then create a new interface that extends from it, inheriting all its fields. In the following example, the interface Logger is extending from the Clearable interface. Notice the highlighted lines:
interface Clearable {
clear: () => void;
}
interface Logger extends Clearable {
log: (message: string) => void;
}
The Logger
interface now also has a clear
member, which is a function that accepts no parameters and returns void
. This new member is inherited from the Clearable interface. It is the same as if we did this:
interface Logger {
log: (message: string) => void;
clear: () => void;
}
When writing lots of interfaces with a common set of fields, you can extract them to a different interface and change your interfaces to extend from the new interface you created.
Returning to the Clearable example used previously, imagine that your application needs a different interface, such as the following StringList interface, to represent a data structure that holds multiple strings:
interface StringList {
push: (value: string) => void;
get: () => string[];
}
By making this new StringList
interface extend the existing Clearable interface, you are specifying that this interface also has the members set in the Clearable interface, adding the clear property to the type definition of the StringList
interface:
interface StringList extends Clearable {
push: (value: string) => void;
get: () => string[];
}
Interfaces can extend from any object type, such as interfaces, normal types, and even classes.
Interfaces with Callable Signature
If the interface is also callable (that is, it is also a function), you can convey that information in the interface declaration by creating a callable signature.
A callable signature is created by adding a function declaration inside the interface that is not bound to any member and by using : instead of => when setting the return type of the function.
As an example, add a callable signature to your Logger interface, as in the highlighted code below:
interface Logger {
(message: string): void;
log: (message: string) => void;
}
Similar to creating a normal type using the type declaration, you specify the fields of the type, and their type, in the {}
:
interface Logger {
log: (message: string) => void;
}
The Logger interface represents an object that has a single property called log. This property is a function that accepts a single parameter of type string and returns void.
You can use the Logger
interface as any other type. Here is an example creating an object literal that matches the Logger
interface:
interface Logger {
log: (message: string) => void;
}
const logger: Logger = {
log: (message) => console.log(message),
};
Values using the Logger
interface as their type must have the same members as those specified in the Logger
interface declaration. If some members are optional, they may be omitted.
Since values must follow what is declared in the interface, adding extraneous fields will cause a compilation error. For example, in the object literal, try adding a new property that is missing from the interface:
interface Logger {
log: (message: string) => void;
}
const logger: Logger = {
log: (message) => console.log(message),
otherProp: true,
};
In this case, the TypeScript Compiler would emit error 2322, as this property does not exist in the Logger interface declaration:
Output
Type '{ log: (message: string) => void; otherProp: boolean; }' is not assignable to type 'Logger'.
Object literal may only specify known properties, and 'otherProp' does not exist in type 'Logger'. (2322)
Similar to using normal type
declarations, properties can be turned into an optional property by appending ?
to their name.
Extending Other Types
When creating interfaces, you can extend from different object types, allowing your interfaces to include all the type information from the extended types. This enables you to write small interfaces with a common set of fields and use them as building blocks to create new interfaces.
Imagine you have a Clearable interface, such as this one:
interface Clearable {
clear: () => void;
}
You could then create a new interface that extends from it, inheriting all its fields. In the following example, the interface Logger is extending from the Clearable interface. Notice the highlighted lines:
interface Clearable {
clear: () => void;
}
interface Logger extends Clearable {
log: (message: string) => void;
}
The Logger
interface now also has a clear
member, which is a function that accepts no parameters and returns void. This new member is inherited from the Clearable
interface. It is the same as if we did this:
interface Logger {
log: (message: string) => void;
clear: () => void;
}
When writing lots of interfaces with a common set of fields, you can extract them to a different interface and change your interfaces to extend from the new interface you created.
Returning to the Clearable example used previously, imagine that your application needs a different interface, such as the following StringList
interface, to represent a data structure that holds multiple strings:
interface StringList {
push: (value: string) => void;
get: () => string[];
}
By making this new StringList
interface extend the existing Clearable interface, you are specifying that this interface also has the members set in the Clearable interface, adding the clear property to the type definition of the StringList
interface:
interface StringList extends Clearable {
push: (value: string) => void;
get: () => string[];
}
Interfaces with Callable Signature
If the interface is also callable (that is, it is also a function), you can convey that information in the interface declaration by creating a callable signature.
A callable signature is created by adding a function declaration inside the interface that is not bound to any member and by using : instead of => when setting the return type of the function.
As an example, add a callable signature to your Logger interface, as in the highlighted code below:
interface Logger {
(message: string): void;
log: (message: string) => void;
}
Notice that the callable signature resembles the type declaration of an anonymous function, but in the return type you are using : instead of =>. This means that any value bound to the Logger interface type can be called directly as a function.
To create a value that matches your Logger interface, you need to consider the requirements of the interface:
- It must be callable.
- It must have a property called log that is a function accepting a single string parameter.
Let’s create a variable called
logger
that is assignable to the type of yourLogger
interface:
interface Logger {
(message: string): void;
log: (message: string) => void;
}
const logger: Logger = (message: string) => {
console.log(message);
}
logger.log = (message: string) => {
console.log(message);
}
To match the Logger interface, the value must be callable, which is why you assign the logger variable to a function:
interface Logger {
(message: string): void;
log: (message: string) => void;
}
const logger: Logger = (message: string) => {
console.log(message);
}
logger.log = (message: string) => {
console.log(message);
}
You are then adding the log property to the logger function:
interface Logger {
(message: string): void;
log: (message: string) => void;
}
const logger: Logger = (message: string) => {
console.log(message);
}
logger.log = (message: string) => {
console.log(message);
}
This is required by the Logger
interface. Values bound to the Logger
interface must also have a log
property that is a function accepting a single string parameter and that returns void.
If you did not include the log property, the TypeScript Compiler would give you error 2741
:
Output
Property 'log' is missing in type '(message: string) => void' but required in type 'Logger'. (2741)
The TypeScript Compiler would emit a similar error if the log property in the logger variable had an incompatible type signature, like setting it to true:
interface Logger {
(message: string): void;
log: (message: string) => void;
}
const logger: Logger = (message: string) => {
console.log(message);
}
logger.log = true;
In this case, the TypeScript Compiler would show error 2322:
Output
Type 'boolean' is not assignable to type '(message: string) => void'. (2322)
A nice feature of setting variables to have a specific type, in this case setting the logger variable to have the type of the Logger interface, is that TypeScript can now infer the type of the parameters of both the logger function and the function in the log property.
You can check that by removing the type information from the argument of both functions. Notice that in the highlighted code below, the message parameters do not have a type:
interface Logger {
(message: string): void;
log: (message: string) => void;
}
const logger: Logger = (message) => {
console.log(message);
}
logger.log = (message) => {
console.log(message);
}
And in both cases, your editor should still be able to show that the type of the parameter is a string, as this is the type expected by the Logger
interface.
Interfaces with Index Signatures
You can add an index signature to your interface, just like you can with normal types, thus allowing the interface to have an unlimited number of properties.
For example, if you wanted to create a DataRecord
interface that has an unlimited number of string fields, you could use the following highlighted index signature:
interface DataRecord {
}
You can then use the DataRecord
interface to set the type of any object that has multiple parameters of type string:
interface DataRecord {
}
const data: DataRecord = {
fieldA: "valueA",
fieldB: "valueB",
fieldC: "valueC",
// ...
};
In this section, you created interfaces using different features available in TypeScript and learned how to use the interfaces you created. In the next section, you’ll learn more about the differences between type and interface declarations, and gain practice with declaration merging and module augmentation.
Differences Between Types and Interfaces
So far, you have seen that the interface declaration and the type declaration are similar, having almost the same set of features.
For example, you created a Logger interface that extended from a
Clearable interface:
interface Clearable {
clear: () => void;
}
interface Logger extends Clearable {
log: (message: string) => void;
}
The same type representation can be replicated by using two type declarations:
type Clearable = {
clear: () => void;
}
type Logger = Clearable & {
log: (message: string) => void;
}
As shown in the previous sections, the interface declaration can be used to represent a variety of objects, from functions to complex objects with an unlimited number of properties. This is also possible with type declarations, even extending from other types, as you can intersect multiple types together using the intersection operator &.
Since type declarations and interface declarations are so similar, you’ll need to consider the specific features unique to each one and be consistent in your codebase. Pick one to create type representations in your codebase, and only use the other one when you need a specific feature only available to it.
For example, the type declaration has a few features that the interface declaration lacks, such as:
- Union types.
- Mapped types.
- Alias to primitive types. One of the features available only for the interface declaration is declaration merging, which you will learn about in the next section. It is important to note that declaration merging may be useful if you are writing a library and want to give the library users the power to extend the types provided by the library, as this is not possible with type declarations.
Declaration Merging
TypeScript can merge multiple declarations into a single one, enabling you to write multiple declarations for the same data structure and having them bundled together by the TypeScript Compiler during compilation as if they were a single type. In this section, you will see how this works and why it is helpful when using interfaces.
Interfaces in TypeScript can be re-opened; that is, multiple declarations of the same interface can be merged. This is useful when you want to add new fields to an existing interface.
For example, imagine that you have an interface named DatabaseOptions
like the following one:
interface DatabaseOptions {
host: string;
port: number;
user: string;
password: string;
}
This interface is going to be used to pass options when connecting to a database.
Later in the code, you declare an interface with the same name but with a single string field called dsnUrl
, like this one:
interface DatabaseOptions {
dsnUrl: string;
}
When the TypeScript Compiler starts reading your code, it will merge all declarations of the DatabaseOptions
interface into a single one. From the TypeScript Compiler point of view, DatabaseOptions
is now:
interface DatabaseOptions {
host: string;
port: number;
user: string;
password: string;
dsnUrl: string;
}
The interface includes all the fields you initially declared, plus the new field dsnUrl
that you declared separately. Both declarations have been merged.
Module Augmentation
Declaration merging is helpful when you need to augment existing modules with new properties. One use-case for that is when you are adding more fields to a data structure provided by a library. This is relatively common with the Node.js library called express, which allows you to create HTTP servers.
When working with express, a Request and a Response object are passed to your request handlers (functions responsible for providing a response to a HTTP request). The Request object is commonly used to store data specific to a particular request. For example, you could use it to store the logged user that made the initial HTTP request:
const myRoute = (req: Request, res: Response) => {
res.json({ user: req.user });
}
Here, the request handler sends back to the client a json
with the user field set to the logged user. The logged user is added to the request object in another place in the code, using an express middleware responsible for user authentication.
The type definitions for the Request
interface itself does not have a user
field, so the above code would give the type error 2339
:
Property 'user' does not exist on type 'Request'. (2339)
Posted on April 23, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024