Getting Started with TypeScript in React

by Dominic van Almsick on 14th Sep 2023

⚠️ this article is a work in progress

function BlogPost({ title }: { title: string }) {
  return <h1>{title}</h1>;
}
 
<BlogPost title="Working on it..." />;

Introduction

We cover the essentials you need to start using TypeScript effectively with React. This is not a comprehensive TypeScript guide, and in most (surely all) cases, the TypeScript docs provide better and more in-depth explanations.

The goal is to get you using simple TypeScript in your React projects as painlessly as possible.

Pre-requisites

You should already have a solid understanding of JavaScript and React. Some familiarity with basic TypeScript will also be helpful.

What is TypeScript?

TypeScript is a superset of JavaScript. The implication is that any valid JavaScript is valid TypeScript.

Venn diagram of a superset
TypeScript superset of JavaScript

TypeScript is JavaScript plus the ability to specify types for variables, function parameters, and more. To quote the TypeScript docs:

JavaScript provides language primitives like string and number, but it doesn’t check that you’ve consistently assigned these. TypeScript does.

When I first came across TypeScript, I was confused about its exact relationship to JavaScript. Is it a completely separate programming language? Does it have its own runtime? If you're similarly unsure, the following points might shed some light:

  1. TypeScript is a static analysis tool, which means that it runs before your code.
  2. The analysis results in warnings in your IDE or terminal if types in the program are incorrect.
  3. Before execution, TypeScript is transpiled into JavaScript.
  4. Since your code is just JavaScript once it runs, TypeScript has no impact on the runtime behaviour of your code. It will not cause more or different errors to be thrown. It only warns you about potential errors while you are authoring your code.

The basics

Typing variables

Let's take the simplest case. We have a variable month, to which we want to assign the appropriate integer value for a given month. E.g. 9 for September.

let month = 9; // September

We can imagine that another developer, or our future selves, might be tempted to re-assign this variable like so

month = 'October';

If we intended for month to be used in a mathematical operation, we've now got a bug. For example, if we increment month:

month++; // month is now NaN
Number.isNaN(month) === true; // yep

This is legal JavaScript; we'll get no clues as to which code introduced the bug. We have to execute code in our heads until we identify where we went wrong. JavaScript alone doesn't surface the source of the problem.

TypeScript's purpose is to catch bugs like these during the code authoring process, reducing time and cognitive load spent on debugging,

Let's re-introduce our month variable with TypeScript specific syntax:

let month: number = 9;
month = 'October';

Adding : number to the left of the assignment operator explicitly tells TypeScript that month should be a number. When we then try to assign a string to month we will immediately get a warning in our IDE. You can experiment with this example in the TypeScript playground.

For this example, we didn't need to add extra syntax to get the benefits of type checking. Whenever possible, TypeScript gets out of our way by using what is called type inference, inferring the type from the initial assignment. Best-practice is to avoid adding type annotations when inference is straightforward.

Getting interesting with objects

As we saw above, TypeScript has no trouble inferring types for simple variables without our help. However, in JavaScript we spend much of our time working with objects.

We usually have a specific interface in mind for objects we create, even if we are not explicit about it. What properties should the object have? What types are their values? What operations are permitted on them? Take the following objects, user1 and user2:

const user1 = {
  name: 'Dom',
  isAdmin: true,
  greeting: 'hello!',
  birthday: 694224000000,
};
 
const user2 = {
  name: 'Mick',
  greeting(message) {
    console.log(`Hi, ${message}`);
  },
  birthday: '1999-02-04',
};

Now reflect on what the following lines of code evaluate to. Which ones crash our program? Which won't? Are these helpful outcomes?

user1.isAdmin; // true
user2.isAdmin; // undefined
user1.isAdmin(); // TypeError: user1.isAdmin is not a function
 
user1.greeting; // 'hello!'
user2.greeting; // f greeting()
 
user1.birthday - user2.birthday; // NaN
  • user2.isAdmin: accessing properties that don't exist is legal JavaScript. You get undefined. Have we forgotten to assign a value? Is the value intentionally undefined? It seems harmless, but what if you're passing this data to a database? What type of data does the isAdmin column expect? Getting this wrong will cause bugs.

  • user2.isAdmin(): it would be reasonable if .isAdmin() were a method, particularly if it involves reading from a database or user session. The error message is descriptive, making it easier to identify the source of the issue. Nevertheless, it would be nice to avoid mistakes like this altogether.

  • user2.greeting: we get a reference to the .greeting() method. Let's say we tried this:

    // assuming this exists on our web page
    const h2 = document.querySelector('h2');
     
    if (user2.greeting) {
      h2.textContent = greeting;
    } else {
      h2.textContent = 'Hi there!';
    }

    Now we're displaying a function signature in our UI! I actually saw this happen on a payment confirmation page recently.

  • user1.birthday - user2.birthday: calculating the time between dates is a common task. Without knowing their types we have to search our code for the source of NaN.

Specifying types for the user object

Here is the syntax for declaring a custom type and declaring that an object instance should conform to it:

type UserProps = {
  name: string;
  greeting: string;
  birthday: number;
  isAdmin: boolean;
};
 
const user1: UserProps = {
  name: 'Dom',
  isAdmin: true,
  greeting: 'hello!',
  birthday: 694224000000,
};

You can experiment with this example in the TypeScript playground. The syntax looks a lot like a regular JavaScript object, except types take the place of values. Note the semi-colons separating properties. This is common practice, but commas are also valid.

The workflow of declaring a custom type and specifying that a variable should conform to it is exactly how we will type component props in React.

Typing arrays

We want to add a posts property to our user object. posts should be an array where each element is an object containing data related to a user's blog post. Let's expand our example to reflect this. You can experiment with this example in the TypeScript playground.

type PostProps = {
  title: string;
  author: string;
  publishedOn: string;
};
 
type UserProps = {
  name: string;
  isAdmin: boolean;
  greeting: string;
  birthday: number;
  posts: PostProps[];
};
 
const user1: UserProps = {
  name: 'Dom',
  isAdmin: true,
  greeting: 'hello!',
  birthday: 694224000000,
  posts: [
    {
      title: 'Getting started with TypeScript in React',
      author: 'Dominic van Almsick',
      publishedOn: '2023-09-14',
    },
    {
      title: 'Build a conditionally formatted calendar in React',
      author: 'Dominic van Almsick',
      publishedOn: '2023-10-12',
    },
  ],
};

What have we added here?

  • A custom type PostProps specifying the properties and types that post objects should have
  • We have added a posts field to UserProps
  • the syntax posts: PostProps[]; should be read as:

    the value for the posts property should be an array where each element is of type PostProps

The syntax for an array is the [] square brackets after the type name. In this example we used a custom type. If we were specifying arrays of primitives the syntax would be:

const numbers: number[] = [1, 2, 3];
const strings: string[] = ['a', 'b', 'c'];
const bools: boolean[] = [true, false, false];
// etc.

Optional properties

We might want to specify that an object property is optional. Sometimes it will be there, other times not. We can achieve this by including a question mark ? before the colon :. in the type declaration. For example

type Person = {
  name: string;
  nickname?: string;
};
 
const person1: Person = {
  name: 'Dom',
};
 
const person2: Person = {
  name: 'Michael',
  nickname: 'Mickey',
};

TypeScript will give us an error if we try to access a method on the nickname property unless we guard against it possibly being undefined.

// TypeScript won't like this
person1.nickname.toUpperCase();
 
// This will make it happy
person1.nickname?.toUpperCase();

This is really helpful for avoiding the dreaded Cannot read properties of undefined error!

Typing functions

When it comes to functions there are two pieces of type information to consider

  1. the types of the function's parameters
  2. the type of the function's return value

Let's get straight to an example, incorporating the concepts we have covered so far. Consider the following function, getWordsFromString, which accepts a string of text as input, and returns an array where each element is a word from the original string. For simplicity we will only cover separating on a single space.

function getWordsFromString(text: string): string[] {
  return text.split(' ');
}
  • for parameters, we include the type information within the parentheses.
  • for the return value, we include the type information after the parentheses, preceded by a colon.

This level of detail will get us a long way in terms of typing React components. Typing functions does go much deeper though. Here's a link to the relevant section of the docs if you're keen to learn more.

Our mini project

Let's get ready to write some React.

We are going to refactor some existing JavaScript React to use TypeScript features. The page should display a list of countries and data associated with them. Each country has its own card in the UI. Clone this repo to get the starter code. The repo has two branches: main and solution. You'll be working through the exercise on the main branch.

Inspecting the project

Once you've opened up the repo in VSCode and run npm install, run npm run dev to start the local dev server.

We've got the usual suspects in a boilerplate React app. For simplicity I've kept all of the code inside App.tsx, and you won't have to worry about styling at all. Here's the contents of App.tsx

import './App.css';
import countries from './countries.json';
 
function CountryCard({ name, continents, population, capital, flags }) {
  return (
    <article className="country">
      <img src={flags.svg} alt={flags.alt} className="country__flag" />
      <div className="country__text-content">
        <hgroup>
          <h2 className="country__name">{name}</h2>
          <h3 className="country__continent">{continents[0]}</h3>
        </hgroup>
        <div className="country__info-container">
          <p className="country__info">
            <span className="country__label">Capital city</span>
            <span className="country__value">{capital}</span>
          </p>
          <p className="country__info">
            <span className="country__label">Population</span>
            <span className="country__value">
              {population.toLocaleString()}
            </span>
          </p>
        </div>
      </div>
    </article>
  );
}
 
function CountriesList({ countries }) {
  return (
    <ul className="countries">
      {countries.map(country => (
        <li key={country.name}>
          <CountryCard {...country} />
        </li>
      ))}
    </ul>
  );
}
 
function App() {
  return (
    <main>
      <h1 className="main__heading">Countries Of the World</h1>
      <CountriesList countries={countries} />
    </main>
  );
}
 
export default App;

This is pure JavaScript. For the exercise, we will refactor to add TypeScript-specific features.

Also for your reference here's the json data that we're importing on line 2. The data is based on a response from the REST countries API.

[
  {
    "flags": {
      "png": "https://flagcdn.com/w320/pf.png",
      "svg": "https://flagcdn.com/pf.svg",
      "alt": ""
    },
    "name": "French Polynesia",
    "capital": "Papeetē",
    "landlocked": false,
    "population": 280904,
    "continents": ["Oceania"]
  },
  {
    "flags": {
      "png": "https://flagcdn.com/w320/mf.png",
      "svg": "https://flagcdn.com/mf.svg",
      "alt": ""
    },
    "name": "Saint Martin",
    "capital": "Marigot",
    "landlocked": false,
    "population": 38659,
    "continents": ["North America"]
  },
  {
    "flags": {
      "png": "https://flagcdn.com/w320/ve.png",
      "svg": "https://flagcdn.com/ve.svg",
      "alt": "The flag of Venezuela is composed of three equal horizontal bands of yellow, blue and red. At the center of the blue band are eight five-pointed white stars arranged in a horizontal arc."
    },
    "name": "Venezuela",
    "capital": "Caracas",
    "landlocked": false,
    "population": 28435943,
    "continents": ["South America"]
  },
  {
    "flags": {
      "png": "https://flagcdn.com/w320/re.png",
      "svg": "https://flagcdn.com/re.svg",
      "alt": ""
    },
    "name": "Réunion",
    "capital": "Saint-Denis",
    "landlocked": false,
    "population": 840974,
    "continents": ["Africa"]
  },
  {
    "flags": {
      "png": "https://flagcdn.com/w320/sv.png",
      "svg": "https://flagcdn.com/sv.svg",
      "alt": "The flag of El Salvador is composed of three equal horizontal bands of cobalt blue, white and cobalt blue, with the national coat of arms centered in the white band."
    },
    "name": "El Salvador",
    "capital": "San Salvador",
    "landlocked": false,
    "population": 6486201,
    "continents": ["North America"]
  },
  {
    "flags": {
      "png": "https://flagcdn.com/w320/dm.png",
      "svg": "https://flagcdn.com/dm.svg",
      "alt": "The flag of Dominica has a green field with a large centered tricolor cross. The vertical and horizontal parts of the cross each comprise three bands of yellow, black and white. A red circle, bearing a hoist-side facing purple Sisserou parrot standing on a twig and encircled by ten five-pointed yellow-edged green stars, is superimposed at the center of the cross."
    },
    "name": "Dominica",
    "capital": "Roseau",
    "landlocked": false,
    "population": 71991,
    "continents": ["North America"]
  },
  {
    "flags": {
      "png": "https://flagcdn.com/w320/ke.png",
      "svg": "https://flagcdn.com/ke.svg",
      "alt": "The flag of Kenya is composed of three equal horizontal bands of black, red with white top and bottom edges, and green. An emblem comprising a red, black and white Maasai shield covering two crossed white spears is superimposed at the center of the field."
    },
    "name": "Kenya",
    "capital": "Nairobi",
    "landlocked": false,
    "population": 53771300,
    "continents": ["Africa"]
  },
  {
    "flags": {
      "png": "https://flagcdn.com/w320/mv.png",
      "svg": "https://flagcdn.com/mv.svg",
      "alt": "The flag of Maldives has a red field, at the center of which is a large green rectangle bearing a fly-side facing white crescent."
    },
    "name": "Maldives",
    "capital": "Malé",
    "landlocked": false,
    "continents": ["Asia"]
  }
]

Now open the app in a browser window and see what we get... blank screen? Check the console.

Uncaught TypeError: Cannot read properties of undefined (reading 'toLocaleString')

Classic.

Your tasks are to:

  1. Fix the bug (hint: think about optional properties)
  2. Define a type for a given country and specify how CountryCard and CountriesList should use it

Conclusion

We have introduced some essential TypeScript basics:

  • typing variables
  • typing objects
  • optional properties
  • typing arrays
  • typing functions

We have explored how we can apply these basic principles, and refactor React components to leverage their benefits.