JavaScript gives developers a great deal of flexibility. A variable initialized as an integer can be assigned a function literal at run-time. Types of variables are not predictable in JavaScript. As you can see in the example below, a is initialized as an integer and is then assigned a function literal:
var a = 2
a = function () {
console.log("I was initialized with a value of an integer, but now I'm a function")
}
Variable a with integer value is assigned a function literal.
Let’s consider the implementation plan for building a Tesla Model S Car.
Ten of the Tesla engineers built their prototype model. There were no specifications laid down before implementation, so the engineers all came up with their own set of specifications and implementation model. One of these prototypes had the means to show a user the car charging details while the other one had a tire monitoring system in place.
If a set of specifications were defined beforehand, it would have been convenient and easy for these engineers to implement prototypes based on the specifications. We deal with the same problem while building complex entities in JavaScript:
function buildTeslaModelS (teslaObj) {
// Implementation Details
}
buildTeslaModelS({
length: 196,
width: 86,
measureTirePressure: function () {
},
wheelbase: 116
})
buildTeslaModelS({
length: "196",
width: "86",
wheelbase: "116",
measureRemCharging: function () {
}
})
The buildTeslaModelS function returns a Tesla Model S car using the parameters as defined in teslaObj. It makes some assumptions for input parameters and returns a model based on those assumptions. It assumes that the length, width and wheelbase properties would be integers and it performs some computations based on this assumption. However, as you can see in the second function call to buildTeslaModelS, these values are of type string and so the assumption is no longer valid.
Also, the buildTeslaModelS function doesn’t know that it would have to deal with measureRemCharging property, so it completely skips that part. It assumes that measureTirePressure is a mandatory property and that it should be present in all of these models. However, when it doesn’t find this property in the second function call, it throws an error at run-time.
This is an extremely flexible functionality! There should be a way to tell buildTeslaModelS function the shape of the input teslaObj parameter. It would have been easier if there was a validation check for checking mandatory properties and their types on teslaObj at compile-time.
Here come TypeScript interfaces to help!
TypeScript has built-in support for interfaces. An interface defines the specifications of an entity. It lays out the contract that states what needs to be done but doesn’t specify how it will be done.
In the above example, we can define an interface for Tesla Model S car and each of its prototypes would then use this interface to come up with their implementation plan for various functionalities as defined in the interface.
This is the interface for the Tesla Model S Car:
interface TeslaModelS {
length: number;
width: number;
wheelbase: number;
seatingCapacity: number;
getTyrePressure: () => number;
getRemCharging: () => number;
}
Interface for Tesla Model S
An interface contains the name of all the properties along with their types. It also includes the signature for functions along with the type of arguments and return type. For example, getTyrePressure and getRemCharging functions return the value of type number.
How to use an interface
A class or function can implement an interface to define the implementation of the properties as defined in that interface.
Let’s write a function to implement TeslaModelS interface:
function buildTeslaModelS (teslaObj: TeslaModelS) {
}
buildTeslaModelS({
length: "196",
width: "86",
wheelbase: "116",
measureRemCharging: function () {
}
})
teslaObj has a shape of TeslaModelS Interface.
When you run the code shown above, the TypeScript compiler will give the following error:
Argument of type
{ length: string; width: string; wheelbase: string; measureRemCharging: () => void; }
is not assignable to parameter of typeTeslaModelS
. Object literal may only specify known properties, andmeasureRemCharging
does not exist in typeTeslaModelS
.
The compiler complains for two reasons:
- The properties length, width, and wheelbase are defined as type number in the interface and so it expects them to be of type number and not string
- The property measureRemCharging is not defined on the interface. It should be named as getRemCharging and it should return an integer. The implementation of an entity should follow the contract as defined in its interface
To build a Tesla Model S as defined in the interface, we will have to define the function like this:
function buildTeslaModelS (teslaObj: TeslaModelS) {
}
buildTeslaModelS({
length: 196,
width: 86,
wheelbase: 116,
seatingCapacity: 4,
getTyrePressure: function () {
let tyrePressure = 20 // Evaluated after doing a few complex computations!
return tyrePressure
},
getRemCharging: function () {
let remCharging = 20 // Evaluated after doing a few complex computations!
return remCharging
}
})
The above implementation of teslaObj is exactly what the interface expects!
How to define optional properties in interfaces
Interfaces do a great job in making sure the entities are implemented as expected. However, there would be cases when it is not necessary to have all of the properties as defined in the interface. These properties are called optional properties and are represented in the interface like this:
interface TeslaModelS {
length: number;
width: number;
wheelbase: number;
seatingCapacity: number;
getTyrePressure?: () => number;
getRemCharging: () => number;
}
Defining Optional Properties in TeslaModelS Interface.
Please note the ? in getTyrePressure property. The question mark suggests that the property getTyrePressure is optional and is not mandatory for entities to implement this functionality in all of the models. The compiler won’t complain even if you don’t specify this property in the teslaObj parameter.
The compiler also checks for excess properties that are not defined in the interface. Let’s say, the teslaObj contains an excess property turningCircle, which is not specified in the TeslaModelS interface:
buildTeslaModelS({
length: 196,
width: 86,
wheelbase: 116,
getTyrePressure: function () {
let tyrePressure = 20 // Evaluated after doing a few complex computations!
return tyrePressure
},
getRemCharging: function () {
let remCharging = 20 // Evaluated after doing a few complex computations!
return remCharging
},
turningCircle: 10
})
The compiler gives the following error:
Argument of type
{ length: number; width: number; wheelbase: number; getTyrePressure: () => number; getRemCharging: () => number; turningCircle: number; }
is not assignable to parameter of typeTeslaModelS
. Object literal may only specify known properties, andturningCircle
does not exist in typeTeslaModelS
.
Read-only properties in interfaces
Read-only properties are the ones that cannot be changed once they are initialized. For example, the properties length, width, wheelbase, and seatingCapacity should never be modified in any case after they are initialized with some fixed value.
We will have to modify our interface to reflect this change:
interface TeslaModelS {
readonly length: number;
readonly width: number;
readonly wheelbase: number;
readonly seatingCapacity: number;
getTyrePressure?: () => number;
getRemCharging: () => number;
}
Read-only Properties.
Note the use of readonly keyword with the name of the properties. It suggests that these properties cannot be modified after they are initialized with some value.
Indexable properties in interfaces
Indexable properties, as the name suggests are used for defining types that are indexed into a unique number or a string. For example, we can define a type CustomArray as:
interface CustomArray {
[index: number]: string
}
let cars: CustomArray = ['Hatchback', 'Sedan', 'Land Rover', 'Tesla Model S']
console.log('Element at position 1', cars[1]) // 'Sedan'
Please note the cars variable is not an ordinary array and so you cannot use array built-in functions like push, pop, filter, etc. You might argue that it is better to define ordinary arrays instead of using indexable types. Indexable types are helpful when you have to define custom properties and functions that should operate on a range of values of the same data-type.
Since we have clearly put together the specifications of Tesla Model S car in an interface, it has improved the efficiency of Tesla engineers and they are now ready with the first set of 100 cars. It is time for the reviewing committee to go through each of the models and test them for performance and other factors:
interface TeslaModelSMap {
engineer: string,
model: TeslaModelS,
readonly rating: number
}
interface TeslaModelSReview {
[id: number]: TeslaModelSMap
}
const TeslaModelSReviewQueue: TeslaModelSReview = [
{
engineer: 'John',
model: modelByJohn1, // modelByJohn1 is of type `TeslaModelS`
rating: 2
},
{
engineer: 'Ray',
model: modelByRay1, // modelByRay1 is of type `TeslaModelS`
rating: 3
},
{
engineer: 'John',
model: modelByJohn2, // modelByJohn2 is of type `TeslaModelS`
rating: 4
},
// ... other 97 models
]
The TeslaModelSReview interface indexes the group of properties — engineer model and rating associated with a particular model into a unique numeric index. The TeslaModelSReviewQueue is of type TeslaModelSReview. It lists down the Tesla models built by different engineers. From the above code, we can see that John has built two models — modelByJohn1 and modelByJohn2 that are rated as 2 and 4 respectively.
The type of indexer can either be a string or a number. We can also define other properties in TeslaModelSReview interface but these properties should return a subtype of TeslaModelS type.
The indices of TeslaModelSReview can be made read-only to prevent modifying its values while it is in the review process. We’ll have to change our TeslaModelSReview interface like this:
interface TeslaModelSReview {
readonly [id: number]: TeslaModelS
}
How to define function types in interfaces
An interface can also be used for defining the structure of a function. As we saw earlier, the functions getTyrePressure and getRemCharging are defined as properties on the TeslaModelS interface. However, we can define an interface for functions like this:
interface Order {
(customerId: number, modelId: number): boolean
}
let orderFn: Order = function (cId, mId) {
// processing the order
return true // processed successfully!
}
The orderFn function is to type Order. It takes two parameters of type number and returns a value of type boolean. There is no need to define the type of parameters again in the definition of orderFn function as you can see in the code above. The compiler just makes one-to-one mapping of the arguments as defined in the interface with the one defined in the function declaration. It infers that cId maps to customerId and its type is number and mId maps to modelId and its type is also number. Even the return type for orderFn function is inferred from its definition in the interface.
How to use interfaces with classes
So far, we’ve learned how a function implements an interface. Now let’s build a class for TeslaModelS interface:
class TeslaModelSPrototype implements TeslaModelS {
length: number;
width: number;
wheelbase: number;
seatingCapacity: number;
private tempCache: string;
constructor (l, w, wb, sc) {
this.length = l;
this.width = w;
this.wheelbase = wb;
this.seatingCapacity = sc;
}
getTyrePressure () {
let tyrePressure = 20 // Evaluated after doing a few complex computations!
return tyrePressure
}
getRemCharging () {
let remCharging = 20 // Evaluated after doing a few complex computations!
return remCharging
}
}
let teslaObj = new TeslaModelSPrototype(196, 86, 116, 4)
console.log('Tyre Pressure', teslaObj.getTyrePressure())
Class TeslaModelSPrototype implements the interface TeslaModelS)
The class TeslaModelSPrototype has defined all the properties of an interface. Please note, the interface defines only the public properties of a class. As can be seen from the above code, the property tempCache has an access modifier private and so it is not defined in the interface TeslaModelS.
Different types of variables in a class
A class has three different types of variables
- Local variables — A local variable is defined at the function or block level. It exists only until the function or the block is in execution. Every time a function runs, new copies of the local variables are created in memory
- Instance variables — Instances variables are members of the class. They are used to store the attributes of class objects. Each of the objects has its own copy of instance variables
- Static variables — Static variables are also called as Class Variables because they are associated with a class as a whole. All of the objects of a class share the same copy of static variables
Please note interfaces deal only with the instance part of the class. For example, the constructor function comes under the static part. The interface TeslaModelS does not specify anything related to the constructor or the static part.
Extending interfaces
An interface can extend any other interface and import its properties. This helps in building small and reusable components. For example, we can create different interfaces to handle the different components of the Tesla Model like this:
interface Wheel {
wheelBase: number;
controls: Array<string>,
material: string;
}
interface Charger {
adapter: string;
connector: string;
location: string;
}
interface TeslaModelS extends Wheel, Charger {
// ... All other properties
}
Extending interfaces.
The TeslaModelS interface extends the properties of the Wheel and the Charger. Instead of dumping all of the properties in a single interface, it is a good practice to make separate interfaces for handling different components.
How are Type Aliases different from Interfaces?
Type Alias is used for giving a name to a combination of different types in TypeScript.
For example, we can create a type that can either be of type string or null :
type StringOrNull = string | null;
Type Alias and Interfaces are often used interchangeably in TypeScript. The shape of the TeslaModelS
object can also be defined using type like this:
type TeslaModelS {
length: number;
width: number;
wheelbase: number;
seatingCapacity: number;
getTyrePressure: () => number;
getRemCharging: () => number;
}
TeslaModelS type.
Similar to how Interfaces extend other interfaces and type aliases using the keyword, type aliases can also extend other types and interfaces using the intersection operator. Type Alias can also be implemented by a class.
Type Alias is generally used in cases, where we have to define a merge of different types. For example, consider the function renderObject:
function renderObject (objShape: Square | Rectangle | Triangle) {\
// ...
}
The renderObject
function takes an input parameter objShape
. Square
, Rectangle
, and Triangle
are types and |
is called the union operator. objShape
can be of type Square
, Rectangle
or Triangle
. However, the union of shapes cannot be expressed using an interface.
Interfaces are used for defining a contract regarding the shape of an object; hence they cannot be used with the union of multiple shapes. Even a class cannot implement a type that describes a union of shapes. This is one of the important functional differences between interfaces and type alias.
When we define two interfaces with the same name, both of them gets merged into one. The resulting interface will have properties from both the interfaces. However, the compiler will complain if we try to define multiple types with the same name.
Hybrid types in interfaces
In JavaScript, functions are also considered as objects and so it is valid to add properties even on function literals like this:
function manufactureCar (type) {
const model = function getModel (type) {
console.log('inside getModel function')
// get the model of type as mentioned in the argument
}
model.getCustomerDetails = function () {
console.log('inside customer details function')
// get the details of customer who has purchased this model
}
model.price = 100000
model.trackDelivery = function () {
console.log('inside trackDelivery function')
// track the delivery of the model
}
return model
}
let tesla = manufactureCar('tesla')
tesla() // tesla is a function
tesla.getCustomerDetails() // getCustomerDetails is a property defined on function
As you can see from the above code, the variable model is assigned a value of function and getCustomerDetails, price and trackDelivery are attached as properties on the model. This is a common pattern in JavaScript. How do we define this pattern with TypeScript interfaces?
interface CarDelivery {
(string): TeslaModelS,
getCustomerDetails (): string,
price: number,
trackDelivery (): string
}
function manufactureCar (type: string): CarDelivery {
const model = <CarDelivery> function (type: string) {
// get the model of type as mentioned in the argument
}
model.getCustomerDetails = function () {
// get the details of customer who has purchased this model
return 'customer details'
}
model.price = 100000
model.trackDelivery = function () {
// track the delivery of the model
return 'tracking address'
}
return model
}
let tesla = manufactureCar('tesla')
tesla() // tesla is a function
tesla.getCustomerDetails() // getCustomerDetails is a property defined on function
The Object of type CarDelivery is returned from the manufactureCar function. The interface CarDelivery helps in maintaining the shape of the object returned from the manufactureCar function. It makes sure that all the mandatory properties of the model — getCustomerDetails, price and trackDelivery are present in the model.
How to use generics in interfaces
Generics in TypeScript are used when we have to create generic components that can work on multiple data types. For example, we don’t want to restrict our function to accept only number as the input parameter. It should scale as per the use-case and accept a range of types.
Let’s write code for implementing a stack that handles generic data types:
interface StackSpec<T> {
(elements: Array<T>): void
}
function Stack<T> (elements) {
this.elements = elements
this.head = elements.length - 1
this.push = function (number): void {
this.elements[this.head] = number
this.head++
}
this.pop = function <T>(): T {
this.elements.splice(-1, 1)
this.head--
return this.elements[this.head]
}
this.getElements = function (): Array<T> {
return this.elements
}
}
let stacksOfStr: StackSpec<string> = Stack
let cars = new stacksOfStr(['Hatchback', 'Sedan', 'Land Rover'])
cars.push('Tesla Model S')
console.log('Cars', cars.getElements()) // ['Hatchback', 'Sedan', 'Land Rover', 'Tesla Model S']
The interface StackSpec takes in any data-type and puts it in the definition of the function. T is used for defining type. The function Stack takes an array of elements as the input. The Stack has methods — push for adding a new element of type T in the original elements array, pop is used for removing the top-most element of the elements array and getElements function returns all the elements of type T.
We’ve created a Stack of strings called stacksOfStr which takes in string and accordingly replaces T with string. We can reuse this stack implementation for creating stacks of number and other data-types.
We can also create a stack of Tesla Models. Let’s see how we can do that:
let stacksOfTesla: StackSpec<TeslaModelS> = Stack
let teslaModels = [
{
engineer: 'John',
modelId: 1,
length: 112,
//...
},
// ...
]
let teslaStack = new stacksOfTesla(teslaModels)
console.log(teslaStack) // prints the value of `teslaModels`
Please note that we are using the same stack implementation for an array of type TeslaModelS. Generics coupled with interfaces is a powerful tool in TypeScript.
How TypeScript compiler compiles interfaces
TypeScript does a great job in handling the weird parts of JavaScript. However, the browser doesn’t understand TypeScript and so it has to be compiled down to JavaScript.
The TypeScript Compiler compiles the above TeslaModelSPrototype class as:
var TeslaModelSPrototype = /** @class */ (function () {
function TeslaModelSPrototype(l, w, wb, sc) {
this.length = l;
this.width = w;
this.wheelbase = wb;
this.seatingCapacity = sc;
}
TeslaModelSPrototype.prototype.getTyrePressure = function () {
var tyrePressure = 20; // Evaluated after doing a few complex computations!
return tyrePressure;
};
TeslaModelSPrototype.prototype.getRemCharging = function () {
var remCharging = 20; // Evaluated after doing a few complex computations!
return remCharging;
};
return TeslaModelSPrototype;
}());
var teslaObj = new TeslaModelSPrototype(196, 86, 116, 4);
console.log('Tyre Pressure', teslaObj.getTyrePressure());
I’m using TypeScript Playground to see the compiled code. The instance variables — length, width, wheelBase, and seatingCapacity are initialized in the function TeslaModelSPrototype. The methods getTyrePressure and getRemCharging are defined on the prototype of the function TeslaModelSPrototype.
The above code is plain JavaScript and so it can run in the browser.
Why use interfaces?
As you have already learned that the interfaces help in defining a concrete plan for the implementation of an entity. Apart from that, the interfaces also help in the performance of JavaScript engines. This section assumes that you’ve some understanding of JavaScript engines. In this section, we’ll dig deeper into the working of JavaScript engines and understand how interfaces help with the performance.
Let’s understand how the Compiler sitting on V8 (JavaScript engine on Chrome) stores Objects.
The interfaces in TypeScript exist only until compile-time. As you can see in the above code that was generated by the TypeScript compiler, there is no mention of interfaces. The properties of TeslaModelS interface (length, width, wheelBase and seatingCapacity) are added in the TeslaModelSPrototype constructor while the function types are attached on the prototype of TeslaModelSPrototype function. The JavaScript engines don’t know anything related to interfaces.
If we instantiate thousands of TeslaModelSPrototype cars, we will have to deal with thousands of objects of type TeslaModelS. Each of these objects will have a structure similar to that of the interface. How does JavaScript engine store these thousands of objects of the same shape? Does it make thousands of copies of these objects? Making thousands of copies of similar shape is definitely a waste of memory. The JavaScript engines make just one shape of type TeslaModelS and each of the objects just stores corresponding values of the properties as defined in TeslaModelS interface.
This is a great performance benefit on the side of JavaScript engines.
If the objects have different shapes, the engines will have to create different shapes for these objects and handle them accordingly. Interfaces help in keeping the shapes of similar objects intact.
How to use interfaces with React
Let’s build a simple use-case of displaying the list of pokemon using React & TypeScript interfaces
Here’s the main App Component that renders the pokemon list in the div container with id root:
import React, { Component, Fragment } from 'react';
import { render } from 'react-dom';
import PokemonList from './pokemon-list';
import './style.css';
const App = () => {
return (
<Fragment>
<h2>Pokemon List</h2>
<PokemonList />
</Fragment>
)
}
render(<App />, document.getElementById('root'));
App Component.
The App component renders PokemonList.
Let’s check the implementation of PokemonList component:
import React, { Component } from 'react';
import { PokemonListModel } from './pokemon-model';
interface PokemonProps {}
interface PokemonState {
pokemonList: PokemonListModel | null;
}
class PokemonList extends Component<PokemonProps, PokemonState> {
constructor (props) {
super(props);
this.state = {
pokemonList: null
}
}
getPokemonList = () => {
fetch ('https://pokeapi.co/api/v2/pokemon/?limit=50')
.then (response => {
return response.json();
})
.then (response => {
this.setState({ pokemonList: response });
})
}
render () {
let { pokemonList } = this.state;
return (
<div className='pokemon-list'>
{
pokemonList && pokemonList.results.map (pokemon => {
return (
<div className='row' key={pokemon.name}>
<span>{pokemon.name}</span>
</div>
)
})
}
</div>
)
}
componentDidMount () {
this.getPokemonList()
}
}
export default PokemonList
PokemonList Component.
The PokemonList component fetches the list of Pokemon using theopen-source Poke API project. It stores the results of pokemon API in the state of the component. The component uses interfaces PokemonProps and PokemonState for defining its props and state. The interface PokemonListModel defines the structure of an object as returned from the Pokemon API.
Here’s the PokemonListModel interface:
export interface PokemonListModel {
count: number;
next: string | null;
previous: string | null;
results: Array<Pokemon>
}
interface Pokemon {
name: string;
url: string;
}
PokemonListModel Interface.
Notice the type of results property. It uses the interface Pokemon to define the structure of results. Here’s the demo of the Pokemon application on Stackblitz.
Conclusion
Interfaces are a powerful way of defining contracts in TypeScript. Let’s recap all that we have learned in this tutorial:
- Interfaces define the specifications of entities and they can be implemented by functions or classes. We can define optional properties on an interface using ? and read-only properties by using the readonly keyword in the property name
- The TypeScript compiler also checks for excess properties on an object and gives an error if an object contains a property that is defined in the interface
- We also learned how to define Indexable properties using interfaces
- Classes can implement an interface. The interface contains the definition for only the instance variables of a class
- Interfaces can be extended to import properties of other interfaces using the extends keyword
- We can use the power of Generics with interfaces and build reusable components
- We also learned how interfaces help with the performance of JavaScript engines * * * ### Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
The post Interfaces in TypeScript: What are they and how do we use them appeared first on LogRocket Blog.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.