An Intro to TypeScript for React Developers
The aim of this post is to help you, as a React developer, acquire the base knowledge about the TypeScript language, and to provide a few tips along the way.
Probably you already know that TypeScript is a modern flavour of JavaScript, actually TypeScript too is a programming language. But why it is given so much importance in the development of front-end apps with React? The aim of this post is to give an answer to this and a few other questions, and introduce the basic notions you need to try your hand at writing code with TypeScript as a React developer — together with providing a few tips along the way.
Why Do We Use TypeScript?
The added value offered by TypeScript lays in giving us the ability to add type information on the data being passed around in our code. At first glance, this may not sound like a significant step forward over vanilla JavaScript, but actually it is.
The main advantage coming with adding type information to variables, functions and eventually to classes, is that type mismatches which can cause runtime errors can be spotted in advance, even before the browser runs our app. In fact, because TypeScript can perform type checking at compilation time, when we introduce a build step based on TypeScript into our development process, type checking takes place as soon as we bundle our code into a ready-to-run JavaScript app — such a build step is mandatory when we develop with TypeScript, since nowadays browsers cannot natively understand this language, yet.
From a practical perspective, what the type-checking ability offered by TypeScript actually gives us, is a faster development process, since with TypeScript we don't have to wait to run our app in a browser to spot type-related troubles. Something which is even more tangible when we write TypeScript code using advanced code editors like Visual Studio Code, because their built-in support to TypeScript can show us type-mismatch errors and help us through code hinting while we are still writing the code:
As React developers, we can benefit from the live type-checking and code-hinting features seamlessly integrated into editors like Visual Studio Code also when we write JSX code, that's one of the main reasons why TypeScript is so popular in the React world. And there is more, the TypeScript compiler supports JSX transpilation — the process of translating JSX code into plain JavaScript — and the conversion of ES6 code into backward-compatible JavaScript out-of-the-box, freeing us from the need of keeping a lookout for an ad-hoc transpiler.
Given these premises, let's see what, in practice, adding type annotations actually means.
Type Annotations: Variables
As a general rule, type annotations in TypeScript always come after a colon mark (:
). In the following instruction for instance, we are suggesting to TypeScript that isOpened
is a boolean variable:
1let isOpened: boolean = true;
Primitive values in JavaScript have homonymous primitive type counterparts in TypeScript, and they are string
, number
, boolean
, bigint
and symbol
. TypeScript offers also built-in types for undefined
and null
values, again named after the value they characterise: undefined
and null
.
Primitive types are always written in lowercase.
When TypeScript can deduct the type of a variable from the flow of the code, like in the above example, we can omit altogether the type annotation and simplify the code by resorting to a plain JavaScript instruction:
1let isOpened = true;
In general, when we write TypeScript code, a good habit to acquire is to reduce type annotations to the bare minimum. I say this because a lot of redundant type annotations can worsen the readability of the code. There is no need, however, to reverse engineer your code to understand if a type annotation is needed or not, because if you choose to write your TypeScript with Visual Studio Code or a similar editor, it will be the code editor itself to notify you when a type annotation is required.
The 'any' Type
Apart from the primitive types just mentioned, TypeScript offers a special type named any
. As the name suggests, a variable of type any
can have any valid JavaScript value. In this respect, the any
type can be considered as a wild-card type, and we usually use it when we don't want a specific value to throw type-checking errors.
Broadly speaking, when we add type information to a variable, but also to a function or a class, we have to be as specific as the context requires us to be, by falling back on the any
type only when we have no other alternative. In fact, if we happen to overuse the any
type, our good intentions on using TypeScript would be utterly defeated.
Type Annotations: Arrays
Type information on arrays can be specified using the following two syntaxes, which are completely interchangeable:
1type[]
2
3// or
4
5Array<type>
We usually choose between the two by prioritising code readability. In the above patterns, type
is the type of the array's elements we are describing. Take for example the following block of code, the first two instructions are perfectly equivalent:
1let codesFirstCopy: string[];
2let codesSecondCopy: Array<string>;
3
4const langCodes = [ "en", "it", "es" ];
5
6codesFirstCopy = [ ...langCodes ];
7codesSecondCopy = [ ...langCodes ];
We haven't specified type information for the langCodes
array because, being an array literal, its type is unambiguous, therefore TypeScript will be able to infer without any troubles that langCodes
is an array of strings.
Type Annotations on Functions
TypeScript allows type annotations on both the input parameters of a function and its return value. The syntax follows the same pattern we have just seen for variables, namely the type annotation always goes on the right side of a colon mark. Anyway, I think that in this case an example can easily replace the best of descriptions:
1function numberToPixels( theNumber: number ): string {
2 return theNumber + "px";
3}
Something to note from the code above, is that the return type always follows the parameters list. And although it can be usually omitted — since TypeScript is able to understand from the return statement what the type of the return value is — adding a return type annotation to a function can help other developers who read the code for the first time. So, even if many times the type annotation on the return value can be left out, is anyway useful to include it for documentation purposes.
Another occasion where type annotations can usually, but not always, be omitted is when a function's parameter has a default value. In fact, in this instance, the type is most of the time inferred automatically from the default value, like in the following example:
1function getOddValues( values: number[], maxLength = 10 ): number[] {
2 const oddValues = values.filter( value => ( value % 2 !== 0 ) );
3
4 if ( oddValues.length > maxLength ) {
5 return oddValues.slice( 0, maxLength );
6 }
7
8 return oddValues;
9}
For a moment, I would like to take your attention on the anonymous function used as callback of the Array.filter()
function, namely this callback:
1value => ( value % 2 !== 0 )
Here too, as you can notice, the function has no type annotations. The reason is that TypeScript is able to understand from the context how the function is being called, so as to automatically infer the type of the function's parameters — TypeScript can do it thanks to the type annotations coming with the Array.filter()
function. And this, is usually true for most of the anonymous functions used as callbacks.
One more thing I would like to let you know about type annotations on functions, is that TypeScript provides us with a way to mark which parameters of a function are optional: we just have to prepend a question mark (?
) before the colon mark. The second argument of the following function, for example, is optional:
1const getGreeting = ( firstName: string, lastName?: string ): string => {
2 let greeting = "Hello " + firstName;
3
4 if ( lastName ) {
5 greeting += " " + lastName;
6 }
7
8 return greeting + "!";
9};
In respect to optional parameters, must be said that when we call a function, TypeScript takes for granted that function's parameters are all required. Optional are only those parameters which have a default value or those which are explicitly marked as optional. So once more, writing TypeScript code with an editor like Visual Studio Code can be a plus, because if we happen to call a function by forgetting any of its mandatory parameters, the editor would promptly warn us about our omission.
Type Annotations: Objects
The most immediate way of adding type annotations to objects is doing it inline, like we have already done with variables. The syntax is very familiar, a list of key-type pairs enclosed in curly brackets, as shown in this snippet:
1const theApple: {
2 name: string;
3 color: string;
4 inStock: boolean;
5 notes?: string;
6} = {
7 name: "apple",
8 color: "yellow",
9 inStock: false
10};
Two things that stand out from the type annotation above, and that we can regard as general rules in this context, are the following:
- Each key-type pair must be terminated by a semicolon
- Optional properties are marked as such by placing a question mark before the colon mark
Annotating type information inline for objects is convenient, but not a recommended approach, because it is both difficult to maintain and not reusable, in fact the same object, like its type annotation, could be needed in other points of our app. These drawbacks can be addressed by using Type Alias Declarations.
A Type Alias is a TypeScript feature which allows us to give a name to a type annotation. The syntax is characterised by the type
keyword, and can be easily deducted from the following code sample:
1type Fruit = {
2 name: string;
3 color: string;
4 inStock: boolean;
5 notes?: string;
6};
7
8const theApple: Fruit = {
9 name: "apple",
10 color: "yellow",
11 inStock: false
12};
As you can notice, a Type Alias is written separately from the data it describes, and it's usually placed into a separate file, so as to maximise reusability.
Type Aliases aren't the only mean through which we can write stand-alone type annotations for objects. There is another syntax, named Interface Declaration, and characterised this time by the interface
keyword, that in most of the cases can be used as an alternative to a Type Alias.
Going back to out Fruit
type, we could rewrite it using an Interface like so:
1interface Fruit {
2 name: string;
3 color: string;
4 inStock: boolean;
5 notes?: string;
6}
Apart from the keyword, the two syntaxes are very much alike. With Interfaces, we just drop the equality sign between the name of the type and the opening curly bracket, and the final semicolon after the closing curly bracket. The rest keeps being the same, even the way we use it:
1const fruits: Fruit[] = [
2 {
3 name: "apple",
4 color: "yellow",
5 inStock: false
6 },
7 {
8 name: "avocado",
9 color: "green",
10 inStock: true,
11 notes: "Expiring soon"
12 }
13];
Interfaces and Type Aliases have a few characterising features which set one apart from the other, and which hinder in some occasions their interchangeability. However, we'll come back to these details in a few paragraphs, because now the time is ripe for introducing how what you have read so far fits into the development of custom React components.
Type Annotations on the 'props' Object
As you can imagine, adding type annotations to a custom component's props
is nothing we don't already know how to do. In fact, I'm sure to tell you nothing new when I write that the props of a custom component are the properties of the object the component receives in input, thus, all boils down to adding type information to an object, after all. A question may arise however: «What should we use for the props
object, a Type Alias or an Interface?»
The truth is that there is no correct answer. Because the two syntaxes, as we'll see very soon, both offer all the features we need to deal with type annotations in the context of component's props
. There are however a couple of buts that we will address shortly.
To put what we have just said into practice, this is what it does mean to add type annotations to a component's props
:
1interface FruitsListItemProps {
2 fruit: Fruit;
3 isOdd?: boolean;
4}
5
6function FruitsListItem({
7 fruit,
8 isOdd
9}: FruitsListItemProps ) {
10 // Eventually, does something
11
12 return (
13 // Returns some JSX
14 );
15}
The name of the Interface can be anything of our own invention, but since we are describing the props
object of the FruitsListItem
component, your fellow developers, and your future self, will be thankful to find such information in the Interface's name.
As we were saying at the beginning of the post, one immediate benefit of adding type information to props is that when we'll have to call the FruitsListItem
component in the JSX of some other custom component, the code-hinting feature of Visual Studio Code will provide us with inline code documentation as soon as we start typing with our keyboard the props of our component's call:
Another invaluable benefit we mentioned earlier on, is that the live type-checking ability of our editor will let us know, again, while we are still typing the code, if by mistake we happen to pass the string "false"
, for instance, to the isOdd
prop of FruitsListItem
, by warning us with a notice similar to this:
Type 'string' is not assignable to type 'boolean | undefined'.
In a boolean condition, a non-empty string always evaluates to true
, thus, in the event a string is assigned to the isOdd
prop, a functionality of the FruitsListItem
component would inevitably break.
Type Annotations: Function Type Expressions
As you learned from developing your React apps, event handler functions too, can be passed as props to a custom component. And like any other prop, they need type annotations. The syntax we use in this case is named Function Type Expression, a feature generally used whenever we have to add type annotations to the methods of an object, and to callbacks passed as function parameters.
Let's go back to the FruitsListItem
component. If we add an onClick
handler prop to the component, in TypeScript this would also mean that we have to add to the FruitsListItemProps
Interface a Function Type Expression to describe such a prop, like so:
1interface FruitsListItemProps {
2 fruit: Fruit;
3 isOdd?: boolean;
4 onClick: ( fruitId: number ) => void;
5}
6
7function FruitsListItem({
8 fruit,
9 isOdd,
10 onClick
11}: FruitsListItemProps ) {
12 // Eventually, does something
13
14 return (
15 // Returns some JSX
16 );
17}
The syntax of a Function Type Expression is very similar to that of an arrow function, with the exception that on the right side of the arrow, instead of a block of code, must appear the type of the value returned by our handler. In this case we have specified that the function returns void
, a special type which says to TypeScript that the function returns nothing, no value at all.
Usually, more complex type annotations, like Function Type Expressions, face the same reusability problem we have encountered for objects: it's not rare to need the same Function Type Expression in different places of our app. And as we did for objects, we can address the issue by using Type Aliases. Actually, using a Type Alias to isolate a Function Type Expression isn't only very common, but also a highly recommended approach:
1type FruitListItemOnClickCallback = ( fruitId: number ) => void;
2
3interface FruitsListItemProps {
4 fruit: Fruit;
5 isOdd?: boolean;
6 onClick: FruitListItemOnClickCallback
7}
Union Types
In writing a custom component, something which may happen, is that a prop could need to accept more than one type of data. For example, it might easily happen that a selectedFruit
prop of our FruitsListItem
component has to accept a null
value as well as a Fruit
object. To deal with such an eventuality, TypeScript is provvided with a feature named Union Types.
A Union Type describes a value whose type may be any one of a list of types separated by a pipe character (|
):
1interface FruitsListItemProps {
2 fruit: Fruit;
3 selectedFruit: Fruit | null;
4 onClick: FruitListItemOnClickCallback
5}
Something to be aware of about Union Types, is that the operations allowed by TypeScript on those variables whose type is described by an Union Type, are only those operations which are valid for every type of the Union. For example, if we have a variable of type string | number
, on that variable we can use only the methods which are common to both strings and numbers. In practice, what this actually means, is that whenever we need to apply a string-specific, or number-specific method to our variable, we have to perform a type cast at runtime like in the following example, by eventually falling back to using the typeof
JavaScript operator when a type cast isn't a viable solution:
1const isSecondDigitFive = ( value: string | number ): boolean => {
2 return String( value ).indexOf( "5" ) === 1;
3};
Often Union Types too, need to be reusable. And when it happens, Type Aliases come again to the rescue. Like in this refactoring of the FruitsListItemProps
Interface:
1type FruitListItemSelectedFruit = Fruit | null;
2
3interface FruitsListItemProps {
4 fruit: Fruit;
5 selectedFruit: FruitListItemSelectedFruit;
6 onClick: FruitListItemOnClickCallback
7}
Making Type Information Reusable
In TypeScript it's very common to write Type Aliases and Interfaces in their own files, because it allows the developer to leverage the export feature of JavaScript modules to make type information reusable across the app.
Let's suppose we want to move the Fruit
Interface to a separate file, fruit.ts
. We can export our Interface by using a Named Export by placing the export
keyword just before the word interface
, like shown below:
1export interface Fruit {
2 // List of properties
3}
Then in a given file, before using the Interface, and usually in the uppermost part of it, we can import the Interface in the same fashion we are used to do with React components or, more generally, with any other importable resource external to that specific file:
1import { Fruit } from "path/to/Fruit.ts";
2import { ReactNode } from "react";
3
4
5export default function DetailsView({
6 fruit: Fruit,
7 children: ReactNode
8}) {
9 // Component's body
10}
To export a Type Alias, the steps to follow are exactly the same.
Type Alias or Interface?
The real question is: «Does such a dilemma actually exist in the first place?» The short answer is that probably it doesn't. But let's understand why.
Something we still haven't said about the two syntaxes, is that both of them offer inheritance capabilities. In other words, TypeScript allows an Interface to extend another Interface, so as the first Interface inherits the properties of the second. And Type Aliases can do the same. What changes between the two is the syntax.
Extending an Interface can be achieved through the extends
keyword, like so:
1interface Animal {
2 name: string;
3 species: string;
4}
5
6interface Pet extends Animal {
7 hasVaccine: boolean;
8}
In the above example Pet
inherits all the properties of the Animal
interface. The same thing can be done with Type Aliases:
1type Animal = {
2 name: string;
3 species: string;
4};
5
6type Pet = Animal & {
7 hasVaccine: boolean;
8};
As anticipated, the outcome doesn't change, but the syntax does: Type Aliases are combined together through the Intersection Operator, the &
symbol.
Now, when we talk about adding type information to objects, there are three main advantages of using an Interface over a Type Alias:
- The syntax is more intuitive, as you can evaluate first hand from the examples above.
- When we create families of Interfaces, and so when we have Interfaces extending many other Interfaces, the performance of the TypeScript compiler is slightly better compared to that resulting from the use of Type Aliases.
- The compiler generally returns better error messages.
But even though Interfaces have their strengths in respect to adding type information to objects, they are not at all suited to alias a primitive type, or a Union Type. In fact, the following Type Aliases have no equivalent syntax in the realm of Interfaces:
1type Word = string;
2type Digits = number;
3
4type Collection = Array<Digits | Word>;
Therefore, we can conclude that in broad terms an Interface is preferable over a Type Alias when we want to describe the shape of an object, while a Type Alias is the tool of choice whenever we need to tag with a name any other type annotation.
Conclusion
If you asked me for a hint on the next step to take after reading this intro, my suggestion would surely be to learn about Generics, a feature you'll find yourself using pretty much every day. Needless to tell you that what just learned scratches only the surface, so I encourage you to check out the official TypeScript Handbook to deepen your knowledge on the subject.
Happy typing!