November 17, 2020

Learn TypeScript: A Pocketguide Tutorial

Type safety is important for building scalable and maintainable applications. TypeScript is a superset of JavaScript that makes it type-safe. Learn about TypeScript's most important features in this guide.

What is TypeScript?

TypeScript is a language created by Microsoft that offers developers a way to write JavaScript with type information. It's a superset of JavaScript, meaning that it has all of JavaScript's features but also brings its own.

Why Use TypeScript?

The purpose of TypeScript is to give developers the benefits of type safety when authoring code while also being able to produce valid JavaScript from it. Type safety gives us more confidence when refactoring or making changes to our code and can also help us reason about how a codebase works.

In this guide, we'll look at some of TypeScript's most important features and how you can use them to your advantage.

Table of Contents

Types

Types are the heart of TypeScript. Types give our code editors insights about our code. They inform us about the ways our code is either valid or invalid well before we even run it.

Read the official TypeScript docs on basic types

To take advantage of the type system, we need to provide it some information about our code. At the simplest, this involves using type annotations.

To apply a type annotation to a variable, we put a colon after the variable name followed by the type.

let companyName: string = 'Prisma'
Copy

In this example, we're telling the type system that companyName has a type of string. If we were to later try to assign something to companyName other than a string, we would get a type error.

companyName = 12
// Type 'number' is not assignable to type 'string'
Copy

In this case, type-hinting our variable has prevented us from misusing it. This is a trivial example, but the benefits are clear. As codebases and teams grow and become more disparate, the type system becomes invaluable as a way to ensure code is used in the way it should be.

Where to Apply Types

In the example above, we applied a type to a variable. There are, however, many other places in a codebase that we might want to apply types.

The most common spots we might see types applied include:

  • Variables
  • Function parameters
  • Function returns

Let's consider the following function for getting the length of a string:

function getWordLength(word: string): number {
  return word.length
}

const wordLength: number = getWordLength('hello world')
Copy

We're applying types to all three of the spots listed above. The word parameter is typed as a string, meaning that we'll get a type error if we try to pass in anything but a string. Type-hinting this parameter is done much like how we type-hint a variable. We put a colon next to the parameter followed by the type.

The same kind of thing can be done after the closing parenthesis. In this case, we know that the function should return a number (the length of the word passed in), so we type hint the function return to number.

Finally, the variable we're declaring called wordLength is typed as a number as well. That's because we know the return type of getWordLength is number. Setting the wordLength type to anything else (besides any) will result in a type error.

We can see the type system at work if try to use this function in a way that isn't valid. For example, if we try to pass a number as the argument, we are immediately stopped with a type error.

getWordLength(42)
// Argument of type 'number' is not assignable to parameter of type 'string'.
Copy

We can also see issues if we make changes to the body of the function that throws off the return type. For example, if we try to return a string instead of a number, we'll get a type error.

function getWordLength(word: string): number {
  return word[0]
}
// Type 'string' is not assignable to type 'number'.
Copy

In both cases, TypeScript is protecting us from code misuse.

Type Inference

TypeScript does a good job of inferring type information from the code we write. There are many cases where we don't explicitly need to tell the compiler what type something should have. Instead, the type information can instead be safely assumed by TypeScript.

Read the official TypeScript docs on type inference

Let's take the example of the getWordLength function from above.

const word = 42

function getWordLength(word: string): number {
  return word.length
}

getWordLength(word)
// Argument of type 'number' is not assignable to parameter of type 'string'.
Copy

When we set a variable to hold a value of 42 and then try to pass that variable to the getWordLength function, we get a type error. This makes sense since 42 is a number and the function expects a string. But we didn't explicitly set the variable word to be of type number, so how is this being caught?

It's because TypeScript can see that the value assigned to word is indeed a number. Without explicitly telling the compiler about the type, our mistake is caught before our code runs.

Type inference is a great feature of TypeScript that can help us out with little effort on our part. It does, however, sometimes need to be worked around with type assertions which we'll look at a bit later.

Union Types

There are many cases where assigning a single type to parts of our code would be too inflexible. For example, we might have a function that should accept either a string or a number. The function itself would then be responsible for properly handling both data types.

Read the official TypeScript docs on union types

Let's take the example of a function that is responsible for converting a number value into a formatted currency string. If we were to just accept number types as input, this would be fairly straight-forward.

function formatAsCurrency(value: number): string {
  return `$${value.toFixed(2).toString()}`
}

formatAsCurrency(1000) // "$1000.00"
Copy

This function gives us strong type safety for its input and output.

However, what if we wanted to also accept string types as well? Since number values are sometimes stored as strings (for a variety of reasons), it would be ideal if we could make this function more flexible.

To do so, we can use a union literal with the pipe character |.

The union literal is a bit like the OR operator (||) in JavaScript. It denotes that the type for something can be one of several.

We can allow the formatAsCurrency function to accept a number or a string as input but we'll need to be sure to do some work in the function itself to handle these types appropriately.

function formatAsCurrency(value: number | string): string {
  if (typeof value === 'string') {
    const parsed = parseFloat(value)
    if (isNaN(parsed)) {
      throw new Error('Invalid value')
    }
    return `$${parsed.toFixed(2).toString()}`
  }
  return `$${value.toFixed(2).toString()}`
}
Copy

Now that the function accepts either a number or a string, we need to be careful that any strings passed in are actually representable as a number. If someone were to pass in a string with letters instead of just numbers, the result of parseFloat would be NaN which is almost certainly not what we want.

Running a check on the argument type is a good way to get us on a path where we can do the appropriate checks for strings validity.

formatAsCurrency(1000) // "$1000.00"
formatAsCurrency('2000') // "$2000.00"
formatAsCurrency('foo') // ERR: Invalid value
Copy

Intersection Types

Intersection types are useful for composing together two or more different types into a single type.

Read the official TypeScript docs on intersection types

Let's say we have a User type and an associated user object.

type User = {
  id: string
  firstName: string
  lastName: string
  email: string
}

const user: User = {
  id: '123',
  firstName: 'Ryan',
  lastName: 'Chenkie',
  email: 'chenkie@prisma.io',
}
Copy

Let's also say we have a function for accessing this data. However, instead of simply returning the user directly, we want to add an additional role property to it.

function getUserInfo() {
  return {
    ...user,
    role: 'admin',
  }
}
Copy

To make our function type-safe, we should apply a return type to it. But how should we go about doing so? Our User type lacks the role property that we're returning with the user object.

We could add role to the User type, but this may not be what we want. In fact, there are many instances where that would be a deal-breaker as it would throw off our type hints in other places. In other words, there are legitimate cases where the User type should not be touched.

We might then think about creating a new type which would represent the data as we want to.

type UserWithRole = {
  id: string
  firstName: string
  lastName: string
  email: string
  role: 'user' | 'admin' | 'superadmin'
}

function getUserInfo(): UserWithRole {
  return {
    ...user,
    role: 'admin',
  }
}
Copy

This approach works but we're creating a whole new type that is quite specific to this one task. We may not be able to reuse it in other places.

How Intersection Types Work

A more flexible approach is to use intersection types. This allows us to use different types together which means we can define types that are more generalized and reusable.

An intersection is done by using & in-between multiple types. We can think of it a little bit like the && operator in JavaScript.

type User = {
  id: string
  firstName: string
  lastName: string
  email: string
}

type UserAccess = {
  role: 'user' | 'admin' | 'superadmin'
}

const user: User = {
  id: '123',
  firstName: 'Ryan',
  lastName: 'Chenkie',
  email: 'chenkie@prisma.io',
}

function getUserInfo(): User & UserAccess {
  return {
    ...user,
    role: 'admin',
  }
}
Copy

We aren't limited to just two types for intersections. We can intersect together as many types as we like.

type User = {
  id: string
  firstName: string
  lastName: string
  email: string
}

type UserAccess = {
  role: 'user' | 'admin' | 'superadmin'
}

type SocialMedia = {
  socialAccounts: string[]
}

function getUserInfo(): User & UserAccess & SocialMedia {
  return {
    ...user,
    role: 'admin',
    socialAccounts: ['Twitter', 'LinkedIn'],
  }
}
Copy

Generics

TypeScript Generics allow us to write code that can work with a variety of different types, depending on the need. Instead of hard-coding type information, we can write our code such that the type information is applied when the code is called.

Read the official TypeScript docs on generics

Let's say we have a function that returns an array of the first three items from an array passed in as an argument.

function getFirstThree(items) {
  return items.slice(0, 3)
}

getFirstThree(['one', 'two', 'three', 'four', 'five'])
// ['one', 'two', 'three']
Copy

We give the getFirstThree function an array of items and it gives us back the first three.

Since we want to be type-safe with this code, we should apply type hints. We're dealing with strings here so we can tell the function that it should expect strings as input and give back strings as output.

function getFirstThree(items: string[]): string[] {
  return items.slice(0, 3)
}
Copy

Our function now expects the items argument to be an array of strings and that it should also return an array of strings.

This works great for arrays of strings, but what if we want to also use it for an array of numbers?

We could make two separate functions to deal with these two cases.

function getFirstThreeStrings(items: string[]): string[] {
  return items.slice(0, 3)
}

function getFirstThreeNumbers(items: number[]): number[] {
  return items.slice(0, 3)
}
Copy

It's a shame that we're duplicating the function just to suit the two different type-safety cases. Also, what happens if we wanted the function to also work for other types of arrays?

This is a great use case for generics.

What are Generics?

Generics are a feature found in many type-safe languages that allow us to make type-hinting flexible. In our examples above, we have "hard-coded" the type that the function expects. To make it more adaptable to different inputs, we can make the type information for this function generic.

With generics, we can define our function with a "placeholder" type. Most often, this is done with a single character: T. However, the character can be anything we want.

Here's what our function looks like with a generic type:

function getFirstThree<T>(items: T[]): T[] {
  return items.slice(0, 3)
}
Copy

The generic type T is a placeholder for a type that we supply when we call the function. It's like a parameter for us to supply an argument to.

getFirstThree<string>(['one', 'two', 'three', 4, 5])
// Type 'number' is not assignable to type 'string'
Copy

Since we're passing string as the type to apply to this function at call-time, the getFirstThree function expects an array of strings as the items argument and also expects to return an array of strings as well. If we pass an array of mixed types, we'll get a type error.

We can now reuse this function with other types, including number.

getFirstThree<string>(['one', 'two', 'three', 'four', 'five'])
// ['one', 'two', 'three']

getFirstThree<number>([1, 2, 3, 4, 5])
// [1, 2, 3]
Copy

Type Assertions

TypeScript is great at catching our mistakes before we even run our code. A common refrain from TypeScript developers is that getting past the type checker can sometimes be frustrating and require a lot of time but that it's well worth it.

There are, however, cases where our TypeScript code won't compile because it doesn't know as much as we do about our code. There are legitimate cases where we need to override the way TypeScript checks for types so that type checking can happen properly. For these cases, we can use type assertions.

Read the official TypeScript docs on type assertions

A type assertion is an instruction we give to the TypeScript compiler for it to side-step its default behavior.

Let's say we have two types: Person and Contact and a variable that is type-hinted as Person.

type Person = {
  firstName: string
  lastName: string
}

type Contact = {
  firstName: string
  lastName: string
  email: string
  phone: string
}

const person: Person = {
  firstName: 'Ryan',
  lastName: 'Chenkie',
}
Copy

Let's also say that somewhere later in our code we have a function that is responsbile for returning an object shaped as a Contact. We know that we want the result of this function call to be shaped as a Contact because we need to access certain properties such as email and phone later on.

If we tried to return person from this function, we'd get a type error.

function getContact(): Contact {
  return person
}
// Type 'Person' is missing the following properties from type 'Contact': email, phone
Copy

We could adjust the Contact type to say that email and phone are nullable and then include these properties in the funtion return:

type Contact = {
  firstName: string
  lastName: string
  email: string | null
  phone: string | null
}

function getContact(): Contact {
  return {
    ...person,
    email: null,
    phone: null,
  }
}
Copy

This case is rather trivial. For real-world cases where our data and code get more complex, this approach may not be feasible.

To get around this, we can tell the TypeScript compiler to treat person as a Contact just this one time. To do so, we assert that person is a Contact.

function getContact(): Contact {
  return person as Contact
}
Copy

The syntax here is fairly straight-forward: we use the as keyword such that the thing on the left should be "treated as" the thing on the right.

The result of doing this is that we'll get undefined for email and phone and thus this operation is unsound.

const contact = getContact()

console.log(contact.email) // undefined
Copy

However, this might be fine for our needs. Perhaps we're rendering a list of contacts and they happen to not have an email or phone defined, we just deal with that in the render.

Reserved Type Names and Keywords

any

The value that TypeScript provides centers around its ability to catch our mistakes before we run our code. Since many JavaScript errors are produced by mixing types (ie: passing a number to a function when it really should be passed a string), TypeScript can be used to remove a whole class of errors before they even occur.

There are, however, times when we don't know (or can't know) what type something should have. For these cases, we can use the any type.

Read the official TypeScript docs on the any type

The any type works a lot like you'd expect from its name. Type-hinting something as any means that it becomes usable anywhere.

This can be useful if we can't know the type for something. It should, however, be used sparingly. It often becomes an escape hatch for developers if they don't want to spend time applying the appropriate type to something but this also means that we lose the benefits of type safety.

Let's consider a function called addOne which takes in a number and adds 1 to it.

function addOne(num: number): number {
  return num + 1
}

addOne(1) // 2
Copy

If we were to declare a variable that holds a string that should be passed to addOne, type inference would prevent us from doing so.

const companyName = 'Prisma'

function addOne(num: number): number {
  return num + 1
}

addOne(companyName)
// Argument of type 'string' is not assignable to parameter of type 'number'.
Copy

However, if we were to apply any as the type for the companyName variable, we would get passed the type checker.

const companyName: any = 'Prisma'

function addOne(num: number): number {
  return num + 1
}

addOne(companyName) // "Prisma1"
Copy

The result from the function here is obviously not what we intend and is indeed a cause for concern since it produces a bug.

The any type should be used sparingly, if at all. It negates the purpose of TypeScript and will lead to moore brittle code.

unknown

The unknown type is similar to any in some ways but with a key distinction: the unknown type can only be assigned to itself and to the any type whereas any can be assigned to anything. You can, however, assign any value to something with a type of unknown.

Read the official TypeScript docs on the unknown type

The unknown type would make a good alternative for our contrived example above where we don't want to type-hint companyName with its proper type.

const companyName: unknown = 'Prisma'

function addOne(num: number): number {
  return num + 1
}

addOne(companyName) // Argument of type 'unknown' is not assignable to parameter of type 'number'.
Copy

In this example, we are able to assign a string value to a variable with type unknown, but we are not able to use that variable in a function that expects a number.

The value of the unknown type is that it gives us an escape hatch for cases where we don't or can't know the type of something while still being type-safe.

Aside: Type-Safe Database Access with Prisma

Type safety is invaluable for catching bugs before our code runs. Having a type-safe codebase means we can making sweeping changes and refactor our code with confidence.

Type safety is also of great benefit when it comes to database access.

Instead of writing raw SQL statements, it's beneficial to use an ORM (Object Relational Mapper) to query our database. What's even better is if this ORM is type-safe.

Prisma is a next-generation ORM and database toolkit for TypeScript and Node.js which makes it easy to apply type-safety to our databases. We can start with a simple database model and get an automatically-generated type-safe client to access our databases in minutes.

Use Prisma in a Node.js Project

Let's see how to wire up Prisma in a Node.js project.

Assuming you already have a TypeScript-based Node.js project, you can get started by installing the Prisma CLI.

npm install -D @prisma/cli
Copy

The CLI is installed as a dev dependency since we won't need it for production.

With the Prisma CLI insatlled, initialize Prisma in your project.

npx prisma init
Copy

This will create a prisma directory in your project. Inside, you'll find a schema.prisma file. This is where you define the model for your database.

Let's start with a simple SQLite database and table to hold some blog posts.

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

generator client {
  provider  = "prisma-client-js"
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  body     String
}
Copy

In this schema, we're telling Prisma that we want to use SQLite as our database. SQLite is a filesystem-based database that may not be suitable for production but is great for development.

Note: Prisma currently supports PostgreSQL, MySQL, MS SQL Server, and SQLite.

We're defining a model called Post and giving it three fields: id, title, and body. This model will produce a table called Post in your database with these fields and will take the type information that you provide on the right side of the field name as their data types.

The id field has a type of Int which will map to the INTEGER type in SQLite. Likewise for the String type on title and body, the database will get a TEXT type. The id field is also defaulting to an autoincremented value. This means that each subsequent record will take on a value that is one higher than the last.

With the model in place, run the command to create the database and wire up the table.

npx prisma db push --preview-feature
Copy

The prisma db push command does two things: it creates the dev.db database in the prisma directory and it also creates the Post table within.

You can see this database and table working with Prisma studio.

npx prisma studio
Copy

Prisma studio entry screenPrisma studio entry screen Prisma studio Post tablePrisma studio Post table

Prisma Studio is a fully-featured database client that is useful for debugging and development.

With the database and table in place, install Prisma Client and generate it to get access to the database.

npm install @prisma/client
Copy

After installing the @prisma/client package, the Prisma Client will automatically be generated using prisma generate. This command is responsible for looking into the Prisma schema file and creating TypeScript types which allow for type-safe database access.

In a TypeScript file within your Node.js project, import the Prisma Client and create an instance.

import { PrismaClient, Post } from '@prisma/client'

const prisma = new PrismaClient()

const createPost = async (): Promise<Post> => {
  return await prisma.post.create({
    data: {
      title: 'Prisma gives you easy database type safety!',
      body: '...',
    },
  })
}
Copy

The Post type that is imported from @prisma/client was generated when the npx prisma generate ran. It can now be used in various places across the application. In this case, it's being applied as the type for the Promise that the createPost function returns.

The value of a type-safe database client can be seen if you try to input something to the Post table that shouldn't be there. For example, if you try to include an author name, a type error would be raised:

const createPost = async (): Promise<Post> => {
  return await prisma.post.create({
    data: {
      title: 'Prisma gives you easy database type safety!',
      body: '...',
      author: 'Ryan Chenkie',
      // Type '{ title: string; body: string; author: string; }' is not
      // assignable to type 'PostCreateInput'. Object literal may
      // only specify known properties, and 'author' does not exist
      // in type 'PostCreateInput'.
    },
  })
}
Copy

Since mistakes like this are caught before the code runs, it becomes difficult to make mistakes when it comes to data input. This is very helpful because it eliminates a whole class of potential bugs before the code is even shipped.

Conclusion

Type safety is increasingly becoming a primary measure of defence for writing durable code. Making the switch from JavaScript to TypeScript can come with some stumbling blocks and points of frustration. However, using TypeScript pays dividends in the long run, especially when there's a need to make sweeping changes to a codebase and when there are many developers working within it.

If you have any questions on how TypeScript can fit into your project or how you can benefit from using Prisma for type-safe database access, please feel free to reach out to us on Twitter.

Join the discussion

Follow @prisma on Twitter

Don’t miss the next post!

Sign up for the Prisma newsletter