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:
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:
{
"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.
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
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.
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.
function sum(x: number, y: number): number {
return x + y
}
The same can be done with arrow functions:
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.
// 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.
const inputEl = document.querySelector('input') as HTMLInputElement
// or
const inputEl = <HTMLInputElement>document.querySelector('input')
Object Type
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
interface User {
firstName: string;
lastName: string;
}
function greet(user: User) {
console.log(`Hello ${user.firstName} ${user.lastName}`)
}
Or we can use a Type
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
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.
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.
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.
function foo<T>(arg: T): T {
return arg
}
The way you could call this function is as follows:
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.
function foo(cb: (a: string) => void) {
cb(`foo`)
}
function bar(msg: string) {
console.log(msg)
}
foo(bar)
This could be made shorter like this
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.
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
.
// 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.
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:
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.
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.
// 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
function firstElement<T>(arr: T[]): T | undefined {
return arr[0]
}
firstElement([1, 2, 3]) // 1
firstElement([]) // undefined
Rest Params
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.
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
.
function foo(x = 10): void {
console.log(x)
}
foo(1)
foo()
Function Param Destructuring
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
.
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
.
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
.
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.
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
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.
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.
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.
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
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
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
.
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
.
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>
.
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.
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.
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.
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.
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()
.
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
.
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
.
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
.
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.
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
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
.
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.
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.
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.
// `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.
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 parameterT
. - Inside of the square brackets we are using the
keyof
operator.keyof T
represents all the property names of typeT
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 propertyP
of typeT
should be transformed toT[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:
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.
type User = {
username: string
age: number
isAdmin: boolean
}
type Age = User['age']
Here are some other ways of using lookup types:
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.
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:
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.
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.
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.
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
.
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.