Typescript: what you didn't know you knew you didn't know

Written by Jack Standbridge

If you've ever known anything, it's that you knew that you didn't know you knew what you don't know about TypeScript. Or at least that's how confusing it feels when you start reading about this stuff for the first time. Let's break things down in this semi-introductory, semi-practical exploration of what I've come to know about TypeScript.

TypeScript

If you're like me, you may have heard of TypeScript and all the supposed benefits for a while - maybe you've taken a look into it a little, read some docs, done half a tutorial and then got busy with other things. There are some compelling reasons to use TypeScript, but for me the most compelling one is that once you know how to use it it makes writing JavaScript both easier and safer. But you do have to learn how to use it, and sometimes it will trip you up along the way.

This post assumes a decent level of familiarity with JavaScript.

So what is the deal?

TypeScript allows you to specify the shape of your data, the type of things in variables, the signature of a function, it allows you to make your code more predictable while you're writing it. It will protect you from yourself more than anything. It means that you don't need to run your code to see the errors. The errors are displayed to you at compile time instead of at run time. Have you ever fetched at attribute from the DOM that was intended to be a number, and you forgot to use parseInt, parseFloat, a unary +, or some way of making sure that the type of data you were working with didn't just look like a number, but actually was of JavaScript's Number type? The code you might have written where that oversight could have led to a subtle bug in some cases, wouldn't have passed in TypeScript. Let's take a look at a couple of code snippets to compare:

1
2 const myNumberInput = document.getElementById('input');
3 const myNumberValue = myNumberInput.value;
4
5 const mySmallerValue = myNumberValue - 1;
6 const myLargerValue = myNumberValue + 1;
7

Let's imagine that the user has typed 2 into this number input previously. What will we end up with in the variables mySmallerValue and myBiggerValue? We would probably want 1 and 3, but what we end up with is 1 and 21, because JavaScript will cast numbers to strings not strings to numbers when encountering them both in conjunction with a + operator;

If we make a file with these contents with a .js extension, everything runs, but we have a bug. What we would like is to turn it into a .ts extension file and see what TypeScript can do for us.

1
2 const myNumberInput = document.getElementById('input');
3 const myNumberValue = myNumberInput.value;
/*                      ^^^^^^^^^^^^^
 * We have an error here:
 * Property 'value' does not exist on type 'HTMLElement'
 */
4
5 const mySmallerValue = myNumberValue - 1;
6 const myLargerValue = myNumberValue + 1;
7

First of all, the issue doesn't even seem to arise where we thought it would! TypeScript is complaining that the "value" property doesn't exist, which is reasonable, in fact. Nothing has specified that the HTML element that was fetched from the DOM actually was an input element; it could have been a div or a section or something else without a value property. We as the developer know that the element exists in the HTML, and that it is an input element, but our script doesn't know that, and can't be sure. Let's add an update to inform TypeScript of the element type.

const myNumberInput = document.getElementById('input') as HTMLInputElement;

We could also use this syntax:

const myNumberInput = <HTMLInputElement> document.getElementById('input');

This would achieve the same thing, but if we're going to deal with React in the future, it might be a good habit to use as X instead of <x>, since the latter can conflict with JSX.

The whole thing updated looks like this, and we have a new error from TypeScript to help us figure out how to avoid this concatenation bug:

1
2 const myNumberInput = document.getElementById('input') as HTMLInputElement;
3 const myNumberValue = myNumberInput.value;
4
5 const mySmallerValue = myNumberValue - 1;
/*                       ^^^^^^^^^^^^^
 * We have an error here:
 * The left-hand side of an arithmetic operation must
 * be of type 'any', 'number', 'bigint' or an enum
 */
6 const myLargerValue = myNumberValue + 1;
7

VS Code will put a squiggly red line under the problem code, in this case under the myNumberValue on line 5. If we hover over this line, we get the above error in a popup box, but it also gives us some information about the value itself. In this case, it shows us this: const myNumberValue: string, which is a hint about what's going wrong. TypeScript is expecting something of the types any, number, bigint or an enum, but what we're giving it is of type string. To resolve this, let's make sure to convert our value to a number first.

1
2 const myNumberInput = document.getElementById('input') as HTMLInputElement;
3 const myNumberValue = parseFloat(myNumberInput.value);
4
5 const mySmallerValue = myNumberValue - 1;
6 const myLargerValue = myNumberValue + 1;
7

Now TypeScript is happy, and hopefully we are too, because the bug should have gone away. Running this code and inspecting the values for mySmallerValue and myLargerValue should show us that they are 1 and 3 as we expected them to be if the user had typed a 2 in the input field, instead of the 1 and 21.

Interfaces & Records

This example was quite simple, so let's take a look at one that's a bit more complicated: you receive a lot of data back from an API and you want to store it in your state. The data is structured like this:

{
	"data": {
		"atoms": {
			"hydrogen": {
				"id": "hydrogen",
				"atomicWeight": 1,
				"electronsPerShell": [1]
			},
			"carbon": {
				"id": "carbon",
				"atomicWeight": 6,
				"electronsPerShell": [2, 4]
			},
			"oxygen": {
				"id": "oxygen",
				"atomicWeight": 8,
				"electronsPerShell": [2, 6]
			}
		},
		"molecules": {
			"o2": {
				"id": "o2",
				"atoms": ["oxygen"],
				"composition": {
					"oxygen": 2
				},
				"name": "oxygen molecule"
			},
			"h2o": {
				"id": "h2o",
				"atoms": ["hydrogen", "oxygen"],
				"composition": {
					"hydrogen": 2,
					"oxygen": 1
				},
				"name": "water"
			},
			"h2o2": {
				"id": "h2o2",
				"atoms": ["hydrogen", "oxygen"],
				"composition": {
					"hydrogen": 2,
					"oxygen": 2
				},
				"name": "hydrogen peroxide"
			},
			"ch4": {
				"id": "ch4",
				"atoms": ["carbon", "hydrogen"],
				"composition": {
					"carbon": 1,
					"hydrogen": 4
				},
				"name": "methane"
			}
		}
	}
}

We can imagine that this kind of data structure could end up getting very big, with lots of properties. For the time being, I've kept it brief to save for all the typing, but let's imagine that in addition to the properties of id, atomicWeight and electronsPerShell for atoms, and id, atoms, composition and name for molecules, there are also tens of other properties, some of them nested. If you were new to working on an app that contained such data, you might find it nice to have Typescript protect you from trying to process things in the wrong way. Let's write some interfaces that will specify the shape of this data:


interface iAtom {
	id: string,
	atomicWeight: number,
	electronsPerShell: number[]
};

interface iMolecule {
	id: string,
	atoms: string[],
	composition: Record<string, number>
	name: string
};

interface iState {
	atoms: Record<string, iAtom>,
	molecules: Record<string, iMolecule>
};

But wait, what is an interface?

Interfaces allow us to specify the shape of an object when we know the names of the keys of that object. Here we have created 3 interfaces, which we could then use around our codebase to make sure that the data we're working with conforms to the right shape. We'll take a look at how to do that in a moment.

In an interface, we can define all sorts of types, including strings, numbers, typed arrays (number[], or string[] are number arrays and string arrays, and (number | string)[] would be an array of numbers and strings) and more. We could add other interfaces in here, or built in JavaScript types, like Date, or some types that we've imported from a library like React, for example MouseEventHandler for the type of function that should be used for handling mouse events in React.

So interfaces are for describing the shape of an object. What if I don't know the shape of the object but want to describe its contents?

Records allow us to specify information about an object when we don't know the names of the keys. In the iState interface, we have objects stored in the atoms and molecules properties, but we don't know what the keys of those objects are. The keys in the atoms object could be oxygen, hydrogen, potassium etc. or any other element, and similarly for the molecules object. We can specify the types of things that are associated with those keys, however, with the Record syntax: Record<string, iAtom>. This enforces that the key must be a string and the value must conform to the iAtom interface. This way, the iState interface describes the data we get back from the API in the data property.

Let's write a function that is meant to receive a molecule and return the atomic weights of the atoms associated with that molecule:

const getMolecularWeight = (
	molecule: iMolecule,
	state: Record<string, iAtom>
) => {
	return molecule.atoms.map(atom => {
		const atomicCount = molecule.composition[atom];
		const { atomicWeight } = state.atoms[atom];

		return atomicCount * atomicWeight;
	}).reduce((a, b) => a + b, 0);
};

Here we can specify that this function should be passed 2 arguments, the first of which should conform to the shape of the iMolecule interface, and the second to the iState interface. If we tried to pass an atom in as the first argument, we would get an error:

getMolecularWeight(state.atoms.hydrogen, state.atoms);
/* We get an error here:
 * Argument of type 'iAtom' is not assignable to
 * parameter of type 'iMolecule'.
 * Type 'iAtom' is missing the following properties
 * from type 'iMolecule': atoms, composition, name
 */

This is showing us that we have tried to pass in something that conforms to the iAtom interface, but we should have passed in something conforming to the iMolecule interface. It even shows us which of the properties required by iAtom are missing from the thing we've tried to pass in.

If we hover over the function call to getMolecularWeight, we see this popup in VS Code:

const getMolecularWeight: (molecule: iMolecule, state: Record<string, iAtom>) => number

It shows us the signature for the function, and it has even inferred the return type! It can tell that this should be returning a number. That's because it understands the shape of all of the data that's going into the function and can determine that, as a result of the map and the reduce that take place in the function body, the return value will be a number. In other cases, you may want to specify the return type of a function manually, and you can do so yourself with this syntax:

const returnsNumberOne = (): number => {
	return 1;
};

const returnsNumberOrStringOne = (): number | string => {
	return Math.random() > 0.5 ? 1 : '1';
};

const returnsNothing = (): void => {
	return;
};

const returnsAtom = (): iAtom => {
	return state.atoms.hydrogen;
}

This can be useful for when for some reason TypeScript can't determine the type of something, or if you just want to keep yourself and anyone else who works on the function in check, and ensure that you or they don't accidentally return something of the wrong type.

There are lots more fundamentals to TypeScript, but let's move onto a practical example of how you might use some TypeScript in your projects.

React

You might want to use TypeScript in a React project, in which case you can use interfaces to type the props of your components. Let's imagine we want to output some simple information about an atom in a component. We could write it like this:

import { iAtom } from '../../interfaces';

interface Props {
	atom: iAtom,
};

const Atom = ({ atom }: Props): JSX.Element => {
	const title = atom.id;
	const weight = atom.atomicWeight;
	const electrons = atom.electronsPerShell;

	return (
		<section>
			<h2>{ title }</h2>
			<p>Name: { title }</p>
			<p>Atomic Weight: { weight }</p>
			<p>
				Electrons Per Shell:
				{ electrons.map(electron => (
					<span>{ electron }</span>)
				) }
			</p>
		</section>
	);
};

export default Atom;

We would need to have a .tsx extension instead of .ts, but other than that, we can do pretty much what we've learnt from above in terms of TypeScript. We can make use of the iAtom interface to ensure that this component can only be passed an atom prop that conforms to the shape of our data. If we tried to use this component somewhere and pass it some other data, we would get an error:


<Atom />
/* Property 'atom' is missing in type
 * '{}' but required in type 'Props'
 */

<Atom atom={ 1 } />
/* Type 'number' is not assignable to type 'iAtom' */

<Atom atom={ state.atoms.hydrogen } title='hydrogen' />
/* Type '{ atom: iAtom; title: string; }'
 * is not assignable to type 'Props'.
 */

<Atom atom={ state.atoms.hydrogen } />
/* no error here */

This is much like using PropTypes in React, but it means that we can more easily ensure we know the type and shape of our data across the entire app instead of just in single components. This iAtom interface can be used not only to enforce the type of props that get passed into this component, but in a reducer, a thunk, a utility function, and anywhere else you can imagine. PropTypes were a good start, but they were verbose and still only showed errors at runtime instead of compile time. If you found you liked working with PropTypes, however, and the security they afford, you will probably enjoy TypeScript a whole lot more.

In my experience, TypeScript has been invaluable in ensuring that I respect my own decisions about data. It also makes me consider my data structures a lot more carefully before I start writing code, and the process of planning out a Redux initial state, for example, ends up with a much more carefully crafted end result.

There is a lot to TypeScript, and that's because there's a lot to JavaScript, so TypeScript has a lot of ground to cover. This is just a little of what I've seen in TypeScript, and there's plenty more that I haven't learnt yet, but hopefully it's enough to get you started with using it a little in some side projects.

Have at it!

If you want to quickly and easily get a TypeScript project up and running with React, I would suggest using Next. Using create-next-app and running one of these commands will hopefully get you going in no time:

npx create-next-app --ts
# OR
yarn create next-app --typescript

Get in touch

Drop us a line to discuss your project.

Call us on

0117 463 0820