A Crash Course in TypeScript

Strong typing for strong engineers 🦾

James Collerton
10 min readApr 13, 2023
The Sisyphean task of learning all languages

Audience

This article is aimed at engineers with a solid understanding of JavaScript and core programming concepts, who are looking for a brief overview of TypeScript.

It will be helpful to have at least an understanding of some other languages, as they may be used for illustrative purposes. However, you’ll probably be able to get by without.

Argument

Initially, TypeScript is a strongly, statically typed language, whereas JavaScript is a weakly, dynamically typed language.

Strong/ Weak/ Static/ Dynamic Languages

What do we mean by a ‘strongly typed language’? Apparently this is a bit of a hot topic, and people can’t decide its definition, but here’s my two cents.

  • Strongly typed: Data has explicitly or implicitly declared types. For example, if we have data = 1, then we know data is an int and the language we are using will employ this to check if our program makes sense. For example, it may complain if we do data + "string". Examples include Java, Scala and (of course) TypeScript.
  • Weakly typed: Data does not necessarily have a type. It might, but the language won’t limit behaviour based on it. We could carry out data + "string". Examples include C and JavaScript.

It also helps to define the below.

  • Statically typed: We check our types at compile time. Examples include Java, Scala and TypeScript.
  • Dynamically typed: We check our types at run time. Examples include JavaScript and Python.

So now we know why TypeScript is strongly, statically typed and JavaScript is weakly, dynamically typed.

The main benefit we can leverage is the compiler helping us decide if our program will work or not.

For example, in Typescript if we try and compile a file with x.method() in, and x does not have a method function defined, it will error. However, in JavaScript the only way of finding out if this will work is running it and seeing what happens!

Creating An Example Project

Next we create an example project. We’re going to be running our TypeScript program through Node (version 16). If you’re too lost you can find the example repository here.

We use the following commands:

# Initialise a node project
npm init

# Add TypeScript as a development dependency. We don't need it as a
# production dependency as we will compile into pure JavaScript.
npm install typescript --save-dev

# Check we've installed the compiler correctly by printing the version!
npx tsc --version

# Install some basic TypeScript types we will need later. Note, we
# will need to provide types for any other libraries we are using.
npm install @types/node --save-dev

# Install the tsconfig defaults for Node 16 (explained later)
npm install @tsconfig/node16 --save-dev

Now we’ve got our basic project running we need to add a tsconfig.json file to provide some TypeScript compiler options. We can do this using the tsc --init command or just copy paste an existing one. We’ll do the latter and use the below (notice how we’re extending the Node 16 defaults we just installed).

{
"extends": "@tsconfig/node16/tsconfig.json",
"compilerOptions": {
"outDir": "build",
"rootDir": "src"
},
"exclude": ["node_modules"]
}

From here we create our first Typescript file:

mkdir src
touch src/index.ts

Then dump the following into your new index.ts file using your favourite text editor.

// We'll explain this later!
function sayHello(name: String): string {
return `Hello ${name}`;
}

console.log(sayHello("James"))

We compile it using npx tsc, and we get our brand new .js file in our /build directory, which we can use with node build/index.js.

That’s our first TypeScript program compiled and running! We must compile into JavaScript as types aren’t part of ECMAScript, and so aren’t supported by most browsers or runtimes.

For extra points we can also do the below to run TypeScript directly, without the need to compile.

npm install ts-node --save-dev
npx ts-node src/index.ts

As well as introducing a linter with ESLint.

# Add ESLint to our project
npm install eslint --save-dev

# Install TypeScript related dependencies
npm install @typescript-eslint/parser @typescript-eslint/eslint-plugin --save-dev

We also need to create a config file for the linter, .eslintrc.js in the base directory.

module.exports = {
parser: "@typescript-eslint/parser",
extends: ["plugin:@typescript-eslint/recommended"],
env: {
node: true,
},
rules: {
'no-console': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
},
};

And an .eslintignore file with build in, to ignore our compiled files. We run it all with npx eslint . --fix.

Writing Our First Scripts

Let’s circle back to the contents of our TypeScript file.

// Notice how we now provide a type for the argument! The return type is
// inferred, we don't need to put it explicitly, but have for completeness
function sayHello(name: String): string {
return `Hello ${name}`;
}

console.log(sayHello("James"))

The argument has a type specified. If we tried to call the function with an integer argument (e.g. sayHello(1)) we would get an error.

We can also assign types to variables, either explicitly or implicitly.

// Here we explicitly tell the compiler it's a string
const explicitString: String = "String One"
// Here we let the compiler infer the type
const implicitString = "String Two"

We’ve made use of one of the three primitives: string, number and boolean. There is also the special any type, which does exactly what you’d expect.

However, we’re unlikely to only want to use primitives. We can expand to use objects as below.

// Here we accept any object with x and y values, but z is considered
// optional (hence the question mark).
function convertToString(coords: {x: number; y: number; z ?: number}) {
return `${coords.x}.${coords.y}${coords.z === undefined ? "" : `.${coords.z}`}`
}

// Prints 1.2
console.log(convertToString({x: 1, y: 2}))

// Prints 1.2.3
console.log(convertToString({x: 1, y: 2, z: 3}))

In the function argument we have outlined the shape of the object we expect to receive. In plain English it has an x field, a y field and an optional z field. As the z field is optional we need to check it has been defined before we use it!

Union Types

As someone who is used to working with Java, something I found very interesting about TypeScript was union types. We include an example below to demonstrate.

function weirdArrayFunction(a: string[] | string) {
return Array.isArray(a) ? "This is an array" : `In upper case: ${a.toUpperCase()}`
}

// Prints 'This is an array'
console.log(weirdArrayFunction(["one", "two", "three"]))

// Prints 'In upper case: TEST STRING'
console.log(weirdArrayFunction("test string"))

We’ve said we can take an array of strings, or just a string. Notice, the array of strings will have no toUpperCase function, so should this compile?!

As we’ve checked if the input is an array, the compiler is clever enough to know it is a string, and so this is fine! This is called narrowing.

Another example of narrowing is type guards, which are used to decide the type of a variable (demonstrated below).

// Here we can receive a string or number, then
// depending on the type print a different result
function typeGuardFunction(x: string | number) {
typeof x === "string" ? console.log("x is a string!") : console.log("x is a number!")
}

// Prints 'x is a string!'
typeGuardFunction("1")

// Prints 'x is a number!'
typeGuardFunction(1)

What Puts the Type in TypeScript?

Most people will have thought the syntax:

convertToString(coords: {x: number; y: number; z ?: number})

Looks a little clumsy, and rightly so. Wouldn’t it be easier to have a coordinate type?

// Here we define our coordinates type
type Coordinates = {
x: number;
y: number;
z ?: number
}

// Same function as previously using the type
function convertCoordinatesToString(coords: Coordinates) {
return `${coords.x}.${coords.y}${coords.z === undefined ? "" : `.${coords.z}`}`
}

// Prints 1.2
console.log(convertCoordinatesToString({x: 1, y: 2}))

// Prints 1.2.3
console.log(convertCoordinatesToString({x: 1, y: 2, z: 3}))

// Defining a constant of that type
const coords: Coordinates = {x: 5, y: 6}

// Prints 5.6
console.log(convertCoordinatesToString(coords))

Structurally vs Nominally Typed

Another interesting factor is how TypeScript is typed.

  • Structurally Typed: An object is of a type if it has the correct structure.
  • Nominally Typed: An object is only of the type it is assigned (bar inheritance, implementing interfaces etc.)

This may seem a bit abstract, so let’s concrete it with some examples.

// Here we have two structurally equivalent types
type TypeOne = {
a: string;
}
type TypeTwo = {
a: string;
}

// We can assign one to the other, seeing as they
// have the same structure
const variableOne: TypeOne = {a: "A"};
const variableTwo: TypeTwo = variableOne;

If we tried to do something similar in Java we would get an error telling us that variableTwo is TypeTwo, which cannot have a value of TypeOne assigned to it, even though they have the same structure.

readonly Properties

Doing exactly what they say on the tin, readonly fields can’t be modified once they’re written to.

// Creating a type with two read only fields. One
// is a string, the other an object with a single
// value
type ReadOnlyType = {
readonly unchangeable: string,
readonly changeable: {key: string}
}

// We can initialise a variable of the
// type here
const readOnly: ReadOnlyType = {
unchangeable: "a",
changeable: {key: "value one"}
}

// This wouldn't work as it's read only
// readOnly.unchangeable = "b"

// However, this is fine as we're not reassigning
// the type itself
readOnly.changeable.key = "value two"

Interfaces vs Types

In the same way we can declare a type, we can also declare an interface. The difference between the two is in their extension and modification. In both types and interfaces we can create extensions, however types are immutable, we can’t add more fields!

However, something I find interesting is the use of extending interfaces. In a nominal typing system it seems more conventional, as we may want to program to interfaces to allow more flexibility. However in a structural paradigm it seems odd, as we don’t care about hierarchies, only structures!

Regardless, an example using interfaces is below.

// Here we declare our first vehicle interface.
interface Vehicle {
name: string
}

// Now we add a size field to the interface, keeping
// the existing ones
interface Vehicle {
size: string
}

// Now we create a new interface with all of the old
// fields, but an extra one on top.
interface Car extends Vehicle {
numberWheels: number
}

// Here car expects all three fields
const car: Car = {name: "Car", size: "M", numberWheels: 4}

// Vehicle can needs only two
const vehicle: Vehicle = {name: "Plane", size: "L"}

Literal Types

If you look at the example above you’ll notice the size field is a string. Technically we can put anything in there. It would be much cleaner to limit it to a range of values. Say S,M,L. We can do this using literal types.

// This is the syntax for limiting our size variable to only
// one of three values
const size: 'S' | 'M' | 'L' = 'M'

Type Assertions

Sometimes it is useful to be able to provide more information on which type a variable belongs to. Imagine the case where we know our string will be a valid size, but the compiler can’t infer it.

// Here we have a function that only accepts
// our S, M, L arguments
function printSize(size: 'S' | 'M' | 'L') {
console.log(size)
}

// Here we create a string with a valid value
const sizeString: string = 'M'

// Now we try and print the string. Unless we
// assert it is of the correct type the compiler
// will recognise it as a string and throw an error!
printSize(sizeString as 'M')

Functions

Let’s take a closer look at functions.

// Here we declare a type which maps to a function taking
// in a string and returning nothing
type ExampleFnType = (a: string) => void

// Here we use the type and declare a function which takes
// in a string and prints it
const exampleFn: ExampleFnType = a => console.log(a)

// Now we pass to something expecting an argument which is a
// function taking in a string and returning void. It then executes
// the function with the string 'Hello everyone!'
function executeFunction(fn: (a: string) => void) {
fn("Hello everyone!");
}

// This prints 'Hello everyone!'. Notice how the structure of the
// ExampleFnType is the same as the method signature, and so this
// is a valid call
executeFunction(exampleFn)

Notice, functions are just like any other data. We can declare types representing them, and they follow the same structural typing as non-function data.

There is also the space to do some interesting things with parameters. This includes optional, default and dereferenced parameters.

// Here we define a function which can (or can not) take 
// a parameter
function functionOptionalParam(x?: number) {
if(x) {
console.log("x is defined")
} else {
console.log("x is not defined")
}
}

// Prints 'x is not defined'
functionOptionalParam()

// Prints 'x is defined'
functionOptionalParam(7)

// Here we define a function which can (or can not) take
// a parameter, but if one isn't supplied we'll use 10
function functionDefaultParam(x = 10) {
console.log("x + 10 is " + (x + 10))
}

// Prints 'x + 10 is 20'
functionDefaultParam()

// Prints 'x + 10 is 17'
functionDefaultParam(7)

// Here we define a type
type Coords = { x: number; y: number; };

// Now we take in an object of type Coords, but
// use the destructuring syntax to be able to
// directly reference its parts
function sum({ x, y }: Coords) {
console.log("Sum of coords is " + (x + y));
}

// Prints 'Sum of coords is 3'
sum({x: 1,y: 2})

Intersection Types

This is how we combine types to form new ones.

The eagle-eyed amongst you will notice that this is quite similar to extending an interface. Why you would choose one over the other is outside the scope of this article, but you can read about it here.

// Create one type
type NameType = {
name: string
}

// Create another type
type AgeType = {
age: number
}

// Combine them! Note, we're not covering
// the case where both types had the same
// field!
type NameAndAgeType = NameType & AgeType

Conclusion

In conclusion we have covered some of the things I find most interesting about the TypeScript language. Notice we haven’t touched on lots of other great things, specific things like tuple types, overloading and generics, or wider ideas like how to use it with NextJS and React. You can explore that yourself!

--

--

James Collerton

Senior Software Engineer at Spotify, Ex-Principal Engineer at the BBC