TypeScript Basics

If you are a beginner, using typescript might not make a whole lot of sense and chances are you don't need it. But as you improve and start working on larger projects, it can become difficult to remember how your own code works, and it is an even larger challenge to ensure that others understand your code.

If you were to write a large module, let's say a module that is a slideable ui modal, you could pass in a configurable object that changes the behavior, but if this module had a lot of options, it might make more sense to create a config object type and that way other people who use your code can see quickly what options are available to be passed in.

Getting Started

It is always better to learn by doing so go create a new folder and in your vs code terminal run:

Terminal
npm init -y
npm i --save-dev typescript

Note that --save-dev is used because this is a dev dependency, typescript allows us to catch errors during development so that we can hopefully avoid running into bugs once our code is live.

Once this is done we need to create an app.ts file and add some basic code to it. Add the code for each example below and try changing a string to a number and watch how typescript warns us that the type is wrong.

Next, at the root of the project create a file tsconfig.json and enter these options:

tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist",
    "noImplicitAny": false,
    "module": "esnext",
    "target": "esnext",
    "allowJs": true,
    "moduleResolution": "node",
    "sourceMap": true,
    "removeComments": false
  }
}

The next thing you can do to check if everything is working is to enter this code in the app.ts file.

app.ts
const a: string = 1

You should notice a red squiggly line underneath a saying that Type 'number' is not assignable to type 'string'. To output the js file simply run

Terminal
npx tsc

Now we can started learning the basics of typescript.

Type Annotations

This is the most basic concept in typescript, in const name: string ..., string is the type annotation.

app.ts
const name: string = 'david'

Function Return Type Annotation

Function return type annotations let us know what a function returns. The below example let's us know that the function must return a number, if a string was returned, we would get an error.

app.ts
function sum(x: number, y: number): number {
  return x + y
}

The same can be done with arrow functions:

app.ts
const sum = (x: number, y: number): number => {
  return x + y
}

Note that with arrow functions, you must use parenthesis around the parameter even if only one parameter is present.

app.ts
// this will throw an error
const greet = name: string => {
  return `hi ${name}`
}

// this is good
const greet = (name: string): string => {
  return `hi ${name}`
}

Type Assertions

Type assertions allow you to tell the typescript compiler that you know better and have information it doesn't. It allows you to infer the type yourself rather than having the compiler infer the type.

Here, typescript will return HTMLElement but it doesn't know what kind of element it is. You are basically telling typescript you have more specific information that it can't know about.

app.ts
const inputEl = document.querySelector('input') as HTMLInputElement
// or
const inputEl = <HTMLInputElement>document.querySelector('input')

Object Type

app.ts
function greet(user: { firstName: string, lastName: string }) {
  console.log(`hello ${user.firstName} ${user.lastName}`)
}

The above example can become a bit too verbose, so we can use an Interface

app.ts
interface User {
  firstName: string;
  lastName: string;
}

function greet(user: User) {
  console.log(`Hello ${user.firstName} ${user.lastName}`)
}

Or we can use a Type

app.ts
type User = {
  firstName: string;
  lastName: string;
}

function greet(user: User) {
  console.log(`Hello ${user.firstName} ${user.lastName}`)
}

interface can be extended, type cannot

Here will will compare a Type with an Interface

app.ts
type User = {
  name: string;
}

type Admin = {
  name: string;
  password: string
}

// now with an interface

interface User {
  name: string;
}

interface Admin extends User {
  password: string;
}

Union Types

This is very common and it works just like javascript's logical or || operator. In the below example notice that we used the ternary operator, you need to make sure to handle certain cases when using union types, it would be easy to write id.toUpperCase() and forget that number does not have a toUpperCase() method.

app.ts
function getUser(id: string | number) {
  return typeof id === 'string' 
    ? id.toUpperCase()
    : id
}

getUser(1) // 1
getUser('abc') // `ABC`

Intersection Types

Intersection types allow us to combine multiple types into one.

app.ts
type User = {
  username: string
}

type Admin = User & {
  isAdmin: boolean
} 

const admin: Admin = {
  username: 'john',
  isAdmin: true
}

Type Variables

Many times, if you are looking at documentation, like the Vue docs for example, you will see <T> all over the place. This is a type variable and it is a powerful part of typescript.

What this example is doing is it is allowing us to make our function more versatile.

app.ts
function foo<T>(arg: T): T {
  return arg
}

The way you could call this function is as follows:

app.ts
foo<number>(1)

// this would not work
// Argument of type 'number' is not assignable to parameter of type 'string'
foo<string>(1)

Functions

The most basic way to describe a function is with a function type expression. They look just like arrow functions.

app.ts
function foo(cb: (a: string) => void) {
  cb(`foo`)
}

function bar(msg: string) {
  console.log(msg)
}

foo(bar)

This could be made shorter like this

app.ts
type SomeFunction = (a: string) => void

function foo(cb: SomeFunction) {
  cb(`foo`)
}

function bar(msg: string) {
  console.log(msg)
}

foo(bar)

Call Signatures

Functions in javascript are objects, but they are a special type of object known as a callable object. For this reason we can add properties to functions with dot or bracket notation.

app.js
function foo() {
  console.log('foo')
}

foo.bar = 'bar'

// bracket notation can also be used
foo['baz'] = 'baz'

console.log(foo.bar) // 'bar'
console.log(foo.baz) // 'baz'

If we have a function as shown below, we can easily describe this function with a function type expression.

app.ts
// function type expression
type GreetFn = (name: string) => string

const greet: GreetFn = name => `hi ${name}`

This is what you will be using most of the time, just regular function type expressions as shown above. But remember we said that functions are objects, so we can also describe a function with a call signature. Since a function is an object we start by defining the type with {} as we would any other normal object.

The outlined code is what is specifically referred to as a call signature.

app.ts
type GreetFn = {
  (name: string): string
}

const greet: GreetFn = name => `hi ${name}`

If we wanted to declare a property on the greet function, we could do it like this:

app.ts
type GreetFn = {
  foo: string,
  (name: string): string
}

const greet: GreetFn = name => `hi ${name}`
greet.foo = 'foo'

The function type expression syntax does not allow for declaring properties, that is why we need to use call signatures. In the above example, if we had used a function type expression, type GreetFn = (name: string) => string, we can't declare foo here. That is the whole point of using a call signature.

Another thing to note is that call signatures allow for function overloading and function type expressions do not.

app.ts
type SomeType = {
  (x: number, y: number): number;
  (x: number): number;
}

Here is a quick example comparing both function type expressions and call signatures, note the syntax differences. Since functions are objects, we can use a call signature even if the function object does not have any declared properties.

app.ts
// function type expression
type Callback = (name: string) => void

// call signature
type Callback = {
  (name: string): void
}

function greet(name: string, callbackFn: Callback) {
  callbackFn(name)
}

greet('john', (name) => {
  console.log(`hi ${name}`)
})

Generic Functions

app.ts
function firstElement<T>(arr: T[]): T | undefined {
  return arr[0]
}

firstElement([1, 2, 3]) // 1
firstElement([]) // undefined

Rest Params

app.ts
function foo(x: number, ...y: number[]) {
  return y.map(i => i * x)
}

foo(10, 1, 2, 3)

Default Params

In the below snippet, since x is optional, it actually has a value of number | undefined because it is possible it might not be provided.

app.ts
function foo(x?: number): void {
  console.log(x)
}

foo(1)
foo() // undefined

We can provide a default value though and then x will have a value of number.

app.ts
function foo(x = 10): void {
  console.log(x)
}

foo(1)
foo()

Function Param Destructuring

app.ts
function sum({x, y}: {x: number, y: number}): number {
  return x + y
}

sum({ x: 1, y: 2})

If we wanted we could make this a bit more clean and move a: number, b: number into its own type Nums.

app.ts
type Nums = {
  x: number
  y: number
}

function sum({x, y}: Nums): number {
  return x + y
}

sum({ x: 1, y: 2})

We could also make use of default parameters in two different ways. The first way is with a type, if you use default parameters, you need to make sure that the options are optional, i.e. a?: number.

app.ts
type Nums = {
  x?: number
  y?: number
}

function sum({x = 1, y = 2}: Nums): number {
  return x + y
}

sum({ x: 1 }) // 3
sum({ x: 1, y: 2}) // 3

The other way is without using a Type.

app.ts
function sum({x = 1, y = 2}: {x?: number, y?: number}): number {
  return x + y
}

sum({ x: 1 }) // 3
sum({ x: 1, y: 2}) // 3

Building off of the above example let's look at a more in depth example, we will make it verbose on purpose, then we will start to refactor it to make it more readable.

app.ts
const printUserName: ({ firstName, lastName }: { firstName: string, lastName: string }) => string = ({ firstName, lastName }) => `${firstName} ${lastName}`

// this is too verbose so lets break it down

type User = {
  firstName: string;
  lastName: string;
}

const printUserName: (user: User) => string = (user) => `${user.firstName} ${user.lastName}`

// let's make a type for the function type annotation
type printNameFunction = (user: User) => string

// refactor again
const printUsername: printNameFunction = (user) => `${user.firstName} ${user.lastName}`

Promises

app.ts
type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
}

async function getPost<T>(id: string): Promise<T> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`)
  return response.json()
}

const data = await getPost<Post[]>('1')

export {}

Generics

Generics are extremely useful because they allow us to make functions, types, and interfaces reusable.

app.ts
function identity<T>(arg: T): T {
  return arg
}

identity(1)

There are two ways to call this function, one way is with type inferrence, which is letting typescript simply figure the type out which is also the most common way and the other way is to pass in the type argument.

app.ts
function identity<T>(arg: T): T {
  return arg
}

// with type inferrence
identity(1)

// pass in the type
identity<number>(1)

Generics with Arrays

Here is another example utilizing the Array type. When working with arrays, there are two ways we can write this.

app.ts
function foo<T>(arr: T[]): T[] {
  console.log(arr.length)
  return arr
}

// or

function foo<T>(arr: Array<T>): Array<T> {
  console.log(arr.length)
  return arr
}

foo([1, 2, 3])

Generic type

Here is another example of how you could use generics

app.ts
function foo<T>(arg: T): T {
  return arg
}

const bar: <T>(arg: T) => T = foo

It is also possible to use a call signature instead of a function type expression

app.ts
function foo<T>(arg: T): T {
  return arg
}

const bar: {<T>(arg: T): T} = foo

In the case this gets too verbose, we can abstract our call signature or function type expression into its own type.

app.ts
type SomeType = {
  <T>(arg: T): T
}

function foo<T>(arg: T): T {
  return arg
}

const bar: SomeType = foo

If we wanted we could also move the <T> type parameter to be a parameter of the whole type.

app.ts
type SomeType<T> = {
  (arg: T): T
}

// also valid syntax
type SomeType<T> = (arg: T) => T

function foo<T>(arg: T): T {
  return arg
}

// Here the type variable must be passed in
const bar: SomeType<number> = foo
bar(1)

Here is another way we can use a generic type. We could use an interface as well, but we will use type since we aren't going to extend anything. Notice how the type gets locked in with Foo<number>.

app.ts
type Foo<T> = {
  someKey: T
}

const bar: Foo<number> = {
  someKey: 1
}

Let's drive the point home with one last example that is a bit more realistic.

app.ts
type User = {
  firstName: string;
  lastName: string;
}

// getUsers returns an array of users, i.e. `[{firstName: 'john', lastName: 'smith' }, {...}]`
function getUsers<User>(arg: User[]): User[] {
  console.log(arg.length);
  return arg;
}

const users: User[] = [{
  firstName: 'david',
  lastName: 'smith'
}, {
  firstName: 'john',
  lastName: 'smith'
}]

getUsers(users)

Generic Helpers

Since type aliases can describe more than just object types, unlike interfaces, they can be used to create generic helpers. There is a good example in the official typescript docs that we will just use here.

TypeScript
type OrNull<Type> = Type | null
type OneOrMany<Type> = Type | Type[]
type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>

If this is confusing just remember that in javascript functions get evaluated from the innermost function to the outermost one.

JavaScript
function sum(x, y) {
  return x + y
}

function multiply(x, y) {
  return x * y
}

// sum(2, 3) is evalutated first
multiply(2, sum(2, 3)) // 10

Index Signatures

Index signatures are useful when we know the shape of an object, but when we don't know how many keys it will have.

app.ts
interface User {
  [key: string]: string | number
}

const user1: User = {
  name: 'john'
}

const user2: User = {
  name: 'john',
  age: 25
}

NOTE Javascript invokes the Object.prototype.toString() method wherever a primitive value is expected.

For this reason, when you write the following code in typescript it is important to use .toString().

app.js
const obj = {
  toString() {
    return 'some_key'
  }
}

const foo = {}

// this throws an error to keep you from shooting yourself in the foot
foo[obj] = 'bar'

// FIX
foo[obj.toString()] = 'bar'
console.log(foo) // {'some_key': 'bar'}

Record<Keys, Type> Utility Type

This typescript utility type creates an object whose property keys are Keys and whose property values are Type.

app.ts
type User = {
  firstName: string;
  lastName: string;
}

type UserPermissions = "admin" | "user"

const users: Record<UserPermissions, User> = {
  admin: { firstName: 'john', lastName: 'smith' },
  user: { firstName: 'james', lastName: 'smith' }
}

Generic Constraints

Sometimes we may want to restrict a generic type, below we have a function foo and it takes in two type variables T and K. What extends is saying here is that K may only be a key of the object T that is passed in to foo.

app.ts
type User = {
  firstName: string;
  lastName: string;
}

// keyof User = "firstName" | "lastName"

function foo<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}

const user: User = { firstName: 'john', lastName: 'smith' }

foo(user, 'firstName') // works
foo(user, 'age') // error

This is doing the same thing again, the extends keyof simply says that the Key can only be a property on the object that gets passed in which is numsObj.

app.ts
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key): T[K] {
  return obj[key];
}
 
const numsObj = { a: 1, b: 2, c: 3, d: 4 }
 
getProperty(numsObj, "a")
getProperty(numsObj, "m")

void

void is the inferred type any time a function doesn’t have any return statements, or doesn’t return any explicit value from those return statements such as with using console.log.

Notice below on line 1 that callbackFn returns a value of undefined and on line 10 we get an error. The error is coming from the face that the function does not have a return value, so the type void is inferred, but on line 1 we said the return type should be undefined. The problem here is that void and undefined are not the same thing.

app.ts
function forEach<T>(arr: T[], callbackFn: (arg: T, idx: number) => undefined): void {
  for (let i = 0; i < arr.length; i++) {
    callbackFn(arr[i], i)
  }
}

// error Type 'void' is not assignable to type 'undefined'
forEach([1, 2, 3], (arg, idx) => console.log({arg, idx}))

If we change the return value to void then the error goes away

app.ts
function forEach<T>(arr: T[], callbackFn: (arg: T, idx: number) => void): void {
// ...
}

There is another interesting use case for void, what would happen if we wanted to push a value into an array? The return value of Array.prototype.push() is the new length property of the array it was called upon, which is a number.

app.ts
const nums = []

function forEach<T>(arr: T[], callbackFn: (arg: T, idx: number) => undefined): void {
  for (let i = 0; i < arr.length; i++) {
    callbackFn(arr[i], i)
  }
}

// Type 'number' is not assignable to type 'undefined'
forEach([1, 2, 3], (arg, idx) => nums.push(arg))

The right way to write this would be to use void. The reason for this is because when a function type has void as a return type, any value can be returned but it will be ignored.

app.ts
const nums = []

function forEach<T>(arr: T[], callbackFn: (arg: T, idx: number) => void): void {
  for (let i = 0; i < arr.length; i++) {
    callbackFn(arr[i], i)
  }
}

forEach([1, 2, 3], (arg, idx) => nums.push(arg))

in keyof

Mapped types in typescript allow us to create new types by transforming the properties of existing types. Whenever you see the in operator used with keyof just know that this is the syntax for working with a mapped type.

First lets look at what the keyof operator does.

app.ts
type User = {
  firstName: string;
  age: number;
}

type userKeys = keyof User // 'firstName' | 'age'

Now lets look at how we can use a mapped type to create a new type based off of an existing type. We created a generic type OptionalUserDetails and now we can pass a type User into OptionalUserDetails<T> and we will get a new type that has all of its keys made optional.

app.ts
// `firstName` and `age` are required
type User = {
  firstName: string;
  age: number;
}

// now all keys are optional
type OptionalUserDetails<T> = {
  [K in keyof T]?: T[K] 
}

const user: OptionalUserDetails<User> = {
  firstName: 'John'
 // `age` is no longer required
}

Let's use the same example and do something a bit different. We will make it so that the object's values are readonly.

app.ts
type User = {
  firstName: string;
  age: number;
}

type ReadOnlyUserDetails<T> = {
  readonly [K in keyof T]: T[K] 
}

const user: ReadOnlyUserDetails<User> = {
  firstName: 'John',
  age: 30
}

// Cannot assign to 'firstName' because it is a read-only property.
user.firstName = 'smith'

In the above code

  • We create a generic ReadOnlyUserDetails type with a single type parameter T.
  • Inside of the square brackets we are using the keyof operator. keyof T represents all the property names of type T as a union of string literal types, i.e. keyof User = "firstName" | "age".
  • The in keyword inside of the square brackets let's typescript know we're dealing with a mapped type. [P in keyof T]: T[P] says that for each property P of type T should be transformed to T[P].
  • The readonly modifier specifies that each property should be readonly.

Note that in typescript, there is a Readonly Utility Type already. We just showed you how that might work under the hood. The way typescript defines Readonly is:

TypeScript
type Readonly<T> = {
  readonly [P in keyof T]: T[P]
}

// here is how it is used
type User = {
  name: string
}

const user: Readonly<User> = {
  name: 'david'
}

user.name = 'john'
// Cannot assign to 'name' because it is a read-only property.

Indexed Access Types

Indexed access types, also known as lookup types are used to lookup a specific property on anther type.

app.ts
type User = {
  username: string
  age: number
  isAdmin: boolean
}

type Age = User['age']

Here are some other ways of using lookup types:

app.ts
type User = {
  username: string
  age: number
  isAdmin: boolean
}

// with a union type
type T1 = User['username' | 'age'] // string | number

// get all the types with keyof
type T2 = User[keyof User] // string | number | boolean

// use a dynamic key like in javascript
type IsAdmin = 'isAdmin'
type T3 = User[IsAdmin] // boolean

type UsernameOrIsAdmin = 'username' | 'isAdmin'
type T4 = User[UsernameOrIsAdmin] // string | boolean

Conditional Types

This article was supposed to cover the basics of typescript and this is an advanced concept, but we will cover it anyways since it is often seen out in the wild.

app.ts
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

// Extracts out the element type.
type Str = Flatten<string[]> // string
 
// Leaves the type alone.
type Num = Flatten<number> // number

Here is another example that may be difficult to grasp:

app.ts
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
  ? Return
  : never;

Before trying to understand the above code it's important to understand a certain concept called covariance and contravariance.

Covariance and Contravariance

When reading about covariance and contravariance, usually complex articles come up regarding type theory and mathematical formulas. The first thing we need to understand is this has to do with types and typescript, not javascript.

In typescript, covariance and contravariance refer to how type relationships are maintained when working with function parameter types and return types.

Here are the two things to remember and we will break it down step by step.

  • Functions types are covariant regarding their return types.
  • Function types are contravariant regarding their parameter types.

Covariance

In typescript, function types are covariant with their return types, because if you replace a function's return type with a subtype, then you get a subtype of the original function type.

app.ts
interface User {
  username: string
}

interface Admin extends User {
  isAdmin: boolean
}

const user: User = { 
  username: 'john'
}

const admin: Admin = { 
  username: 'david', 
  isAdmin: true 
}

// using a subtype (Admin) where a supertype (User) is expected
const expectUser: User = admin

// all admins are users, but not all users are admins
// this throws an error because we expect an admin, but a user
// does not guarantee that
const expectAdmin: Admin = user // error

That was a simple example to illustrate a point, but let's look at a more specific example since we said that function types are covariant in regard to their return types.

app.ts
interface User {
  username: string
}

interface Admin extends User {
  isAdmin: boolean
}

type GetUser = () => User
type GetAdmin = () => Admin

type IsSubtype<U, T> = U extends T ? true : false
type T1 = IsSubtype<GetUser, GetAdmin> // true

const user: User = { 
  username: 'john'
}

const admin: Admin = { 
  username: 'david', 
  isAdmin: true 
}

GetUser can be called to get a User, and GetAdmin can be called to get a User because an Admin is also a user, therefore GetAdmin is a subtype of GetUser. For this reason, function types are covariant in regard to their return types, because if you replace a function's return type with a subtype, then you get a subtype of the original function type. Conversely, if you replace a function's return type with a supertype, then you get a supertype of the original function type.

Contravariance

In typescript, function types are contravariant with their parameter types, because if you replace a function's parameter type with a supertype, then you get a subtype of the original function type. Conversely, if you replace a function's parameter type with a subtype, you get a supertype of the original function type.

app.ts
interface User {
  username: string
}

interface Admin extends User {
  isAdmin: boolean
}

// create a helper to illustrate our point
type IsSubtype<U, T> = U extends T ? true : false

type T1 = IsSubtype<Admin, User> // true
type T2 = IsSubtype<User, Admin> // false

// contravariance
type GetUser = (arg: User) => void
type GetAdmin = (arg: Admin) => void

type T3 = IsSubtype<GetUser, GetAdmin> // true
type T4 = IsSubtype<GetAdmin, GetUser> // false

The reason this happens is because GetAdmin is a function which cannot necessarily accept any User, so you can not use GetAdmin anywhere a GetUser is required, which means GetAdmin is not a subtype of GetUser. However, GetUser is a function which can accept any type of user, including an Admin, so it is a function that accepts an Admin and therefore GetUser is a subtype of GetAdmin.

This means function types are contravariant in regard to their parameter types, because if you replace a function's parameter type with a supertype, then you get a subtype of the original function type. Conversely, if you replace a function's parameter type with a subtype, then you get a supertype of the original function type.

If the above description was a bit hard to understand, below is a more technical explantion so we can compare the differences side by side.

Back to the example

What is happening in the below code block is that never is a bottom type, that means it is a subtype of all other types. Since never appears in contravariant position, this means that (...args: never[]) => infer Return is a supertype of all other types that return Return. What this means is that <Type> can have any parameter types at all. It could have been written like this and it would have been easier to read (...args: any[]) => infer Return. The conditional part Type extends (...args: never[]) => infer Return means if <Type> extends any function with x number of args, create a new type called Return with the infer keyword, and set GetReturnType to Return value, otherwise in the case we don't extend a function, there is nothing to return so set the type of GetReturnType to never.

app.ts
type GetReturnType<Type> = Type extends (...args: never[]) => infer Return
  ? Return
  : never;

// usage
type Num = GetReturnType<() => number> // number
type Str = GetReturnType<(x: string) => string> // string
type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]> // boolean
type Foo = GetReturnType<number> // never

Review

Typescript is very useful as you start to work on larger projects. If you find yourself forgetting how to pass parameters into functions that have a lot of params, or you find yourself leaving comments everywhere to try to document your code and how functions should be called, this is maybe a good time to start using typescript.