Typescript tutorial

Introduction

TypeScript is a superset of JavaScript that adds types. Any valid JavaScript code is valid TypeScript code.

JavaScript is "dynamically" typed language, meaning variable types are determined at runtime. The environment (e.g., browser or Node) doesn't know variable types until execution.

Look at an example:

function add(a, b) {
  return a + b
}

JavaScript does not know the data type of the ‍‍‍a‍ and b parameters. You may assume they are numbers and the function returns the sum of both. However, that's an incorrect assumption. The + operator works also with strings, so add("abc", "def") returns "abcdef".

This seems to be "versatile" for small projects, but as they grow and requirements become more complex, type safety becomes essential. Type safety provides predictability and other benefits. TypeScript adds types to JavaScript, making it more useful.

Here's the revised function in TypeScript, ensuring it accepts only number parameters and returns number:

function add(a: number, b: number): number {
  return a + b
}

We've added type annotations to a and b, instructing the TypeScript compiler to accept only number parameters for this function.

Now, if you try to use this function with string parameters, the TypeScript compiler will throw an error:

add('abc', 'def')
// Error: Argument of type 'string' is not assignable
// to parameter of type 'number'.

You see how TypeScript compiler errors help reduce bugs in your code.

TypeScript compiles down to JavaScript

TypeScript code needs to be compiled to plain JavaScript for browser execution. This means the TypeScript types you use will disappear from the compiled JavaScript. They are only useful at compile time to prevent runtime errors with your JavaScript code.

To see compiled JavaScript from TypeScript, use the official TS Playground. Write TypeScript on the left and see the compiled JavaScript on the right.

Types are not accessible at runtime in JavaScript.

Basic primitive types and syntax

When a parameter is typed, we provide the TypeScript compiler with a specific data type. TypeScript ensures the function is called only with arguments that match this type.

One of the primitive data type is number (starting with lowercase n). You can specify a parameter's type as number using the : number syntax.

The : (colon) followed by a type indicates the data type.

A number type represents any JavaScript number, including positive, negative, zero, and decimal numbers (e.g., 4.94).

function double(value: number): number {
  return value * 2
}

console.log(double(3)) // 6
console.log(double(4.5)) // 9

Another primitive data type is string. To specify a function parameter as a string, use the : string syntax:

// before applying type
function sayLouder(text) {
  return text.toUpperCase()
}
// after applying type
function sayLouder(text: string): string {
  return text.toUpperCase()
}

TypeScript ensures sayLouder is called only with a string argument. The following examples will throw errors:

sayLouder(123);
Argument of type 'number' is not assignable to parameter of type 'string'.


sayLouder(true);
Argument of type 'boolean' is not assignable to parameter of type 'string'.

An empty string is still a string, so sayLouder("") is valid.

The boolean type is a primitive type and used for true or false values:

function flipSwitch(value: boolean): boolean {
  return !value // converts true to false, and false to true
}

console.log(flipSwitch(true)) // false
console.log(flipSwitch(false)) // true

any type

If you forget to type a variable, it implicitly has type any. Typescript type-checking will show error. And you have to give it a type.

TypeScript's any type disables type-checking, allowing a variable to be of any type. Avoid using the any type, as it negates TypeScript's benefits and makes your code error-prone.

A variable with the any type can hold any value, making TypeScript unable to distinguish if it's a string, number, or any other type.

example:

function add(a: any, b: any): any {
  return a + b
}

console.log(add('abc', 'def')) // "abcdef"
console.log(add(10, 5)) // 15

The add function accepts any data type because a and b are any, negating TypeScript's benefits and potentially causing unexpected bugs.

Avoid using any as it disables TypeScript's type-checking.

Tsconfig.json

Every TypeScript project has a tsconfig.json file. It configures TypeScript for your project, instructing the compiler on which features to enable or disable and which files to process or ignore.

TypeScript use cases

1) New project

When starting a new project, writing TypeScript from the start is ideal. Most frameworks and starter projects now include a TypeScript option, making setup easier. Writing types is simpler in a new project since you can integrate them as you code.

2) Convert an existing project from JS to TS

Upgrading an existing JavaScript project to TypeScript can be done progressively, one file at a time. Rename .js files to .ts and add types. However, older projects with legacy code may be harder to convert.

3) Library typings

Another use case for TypeScript is to define types for a library you've developed, enhancing the experience for other developers. For instance, if you create a carousel JavaScript library, you can generate typings and publish them on npm. This provides autocomplete and ensures correct usage through TypeScript's code checks.

TypeScript strictness

The tsconfig.json file lets you set TypeScript's strictness.

There are many tsconfig options that you may encounter. Here's a link to the documentation.

For simplicity look at here:

{
  "compilerOptions": {
    "target": "es2022",
    "esModuleInterop": true,
    "allowJs": true,
    "noUncheckedIndexedAccess": true,
    "strict": true
  }
}

The key setting is "strict": true, which enables all strict mode checks. Without it, TypeScript wouldn't be as useful.

target: Specifies the JavaScript version to transpile to. For older browsers, use versions like es2021 or es2022. For the latest browsers, use esnext.

esModuleInterop: Fixes issues with older libraries using require instead of import.

allowJs: Allows importing JavaScript files, useful when not all files are .ts.

noUncheckedIndexedAccess: Warns when accessing an array item that may be undefined.

"include": ["src"] instructs the TypeScript compiler to process all files within the src folder.

"exclude": ["node_modules", "dist"] instructs the TypeScript compiler to ignore the node_modules and dist directories, ensuring it doesn't process any files within these folders.

TypeScript directive comments

A directive comment gives instructions to the compiler. For example, If a piece of TypeScript code has an error—like an implicit any type or incorrect type usage—you can instruct the compiler to ignore it with // @ts-ignore. For example:

function greet(name: string) {
  return `Hello, ${name}`
}
// @ts-ignore
greet(42) // Incorrectly calling with the wrong data type

This comment // @ts-ignore disables type-checking for the following line.

It may be acceptable to have a few // @ts-ignore comments in your code, as long as they're not overused.

You can disable TypeScript's type checking for the entire file by placing // @ts-nocheck at the top of the file:

// @ts-no-check
// The entire file is NOT checked by TypeScript

It can be helpful if you're including a small third-party script in your source code that you don't want to type-check.

@ts-expect-error instructs TypeScript to expect an error, useful in unit tests when you write incorrect code to assert specific errors. If used on valid TypeScript code, it will produce an error because the directive is unused.

function double(value: number) {
  return value * 2
}

// @ts-expect-error
double('a')

The directive comment silences the TypeScript compiler when we incorrectly use the double function.

Silencing TypeScript errors is generally a bad idea, but sometimes necessary in large projects for quick prototyping or deferring type handling.

TypeScript ESLint

TypeScript ESLint is a tool that helps find and fix common code quality issues in TypeScript. Static code analysis examines your code without executing it.

Installing TypeScript ESLint

To install Typescript ESLint check out the official Getting Started guide

If your project doesn't include TypeScript ESLint, open a terminal and install the following packages:

npm install --save-dev
@typescript-eslint/parser
@typescript-eslint/eslint-plugin
eslint
typescript

Then, create a .eslintrc.cjs file at the root of your project with the following code:

/* eslint-env node */
module.exports = {
  extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
  parser: '@typescript-eslint/parser',
  plugins: ['@typescript-eslint'],
  root: true,
}

Then, run the following command to check your code:

npx eslint .

If you're using VSCode, install the ESLint extension to see errors directly in the editor.

Typescript ESLint Rules

TypeScript ESLint offers three useful rules:

  • @typescript-eslint/no-explicit-any
  • @typescript-eslint/ban-ts-comment
  • @typescript-eslint/ban-types

@typescript-eslint is the name of the package and the part after the / is the name of the rule.

@typescript-eslint/no-explicit-any

Enabling the strict option in tsconfig.json makes TypeScript warn you when a variable implicitly has the type any; for example:

function sum(a, b) {
  // Parameters implicitly have 'any' type.
  return a + b
}

It's good that the compiler warns us to provide types for a and b. However, using the any type explicitly will silence those errors:

function sum(a: any, b: any): any {
  return a + b
}

While the code above is valid for the TypeScript compiler, it's not good practice. Using any disables type checking, essentially bypassing TypeScript.

This is where @typescript-eslint/no-explicit-any comes in handy. It disallows explicit uses of the any type, causing the code above to produce an error:

function sum(a: any, b: any): any {
  return a + b // Error: Unexpected any. Specify a different type.
  // (@typescript-eslint/no-explicit-any)
}

That's great because it forces us to provide an appropriate type!

The TypeScript compiler's strict flag helps disable implicit any types, while the TypeScript ESLint plugin helps disable explicit any types.

@typescript-eslint/ban-ts-comment disables directive comments, enhancing your TypeScript experience. When enabled, using a directive comment will cause an error, forcing you to resolve the issue.

To bypass this ESLint rule, add the following to your .eslintrc.cjs file:

/* eslint-env node */
module.exports = {
    extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
    parser: "@typescript-eslint/parser",
    plugins: ["@typescript-eslint"],
    root: true,
    rules: {
        "@typescript-eslint/ban-ts-comment": "off",
    },
};

The @typescript-eslint/ban-types rule enforces that you use : string instead of : String, : number instead of : Number, : boolean instead of : Boolean, etc.

Why avoid using String, Number, and Boolean as types (with capital S, N, and B)?

The explanation is technical, but you should avoid them.

Using : string refers to the primitive data type, while : String refers to the global JavaScript String object, which you shouldn't use it to make a string instance.

Since you likely won't work with global String, Number, and Boolean objects, this ESLint rule ensures you use the correct types.

Primitive types

JavaScript has seven primitive types:

  • string

  • number

  • boolean

  • undefined

  • null

  • bigint

  • symbol

    TypeScript provides a type for each of these JavaScript primitive types. Example:

function doSomething(firstParam: string, secondParam: null) {
  // function implementation
}

null vs. undefined

it's generally recommended to use undefined to represent a variable that has not been assigned a value yet and null to represent a deliberate absence of value.

In another mean:

null is an explicitly assignment value to a variable to indicate that it has no value. It is an object.

undefined is a type itself and means a variable has been declared but not yet assigned a value (not initialized).

let a // a is declared but not initialized, so it is undefined
let b = null // b is explicitly set to null

console.log(a) // Output: undefined
console.log(b) // Output: null

A function that does not return a value explicitly returns undefined. While null can be used to indicate the intentional absence of an object value.

undefined as type and value

  • type: undefined can be used as a type to indicate that a variable might not have been initialized or is expected to hold the value undefined.
  • value: undefined is the value assigned to variables that have been declared but not initialized.

In Union type section there is a good example shows how undefined is used in two roles.

Annotating variable declarations

We can annotate variable declarations using the same syntax as function parameters. The type annotation comes before the assignment = sign.

const username: string = 'sam'
const fullName: string = ''
const isActive: boolean = true
let age: number = 20
age += 1
console.log(age) // 21
let config: null = null
let data: undefined = undefined

Of course, the types must match the values provided. If they don't, you'll get an error:

const isActive: string = true
// Error: Type 'boolean' is not assignable to type 'string'.

For variables that can be reassigned, TypeScript ensures the type is respected. If a variable is typed as a number, it must remain a number when reassigned:

let sum: number = 0
sum += 10
console.log(sum) // 10
sum = ''
// Error: Type 'string' is not assignable to type 'number'.

Type inference

All the types we provided above could have been omitted, and you would still get the benefits of TypeScript. This is because the TypeScript compiler can automatically determine the data type of a variable based on the assigned value, a feature called type inference.

For example, when you write const username = "sam", TypeScript deduces that username is of type string, so specifying the type is unnecessary.

let sum = 0; // sum is expected to be a number (type inference)
sum += 10;
console.log(sum); // 10
sum = "";
Type 'string' is not assignable to type 'number'.

You see that Typescript will still catch the error on the last line.

Inferred types work in many scenarios. For example, if you sum or multiply two numbers, the compiler knows the result is a number. This also enhances editor autocomplete. That means, the editor is able to use that information from the compiler to only show you the methods you can call on a number. Additionally, it provides useful autocomplete, showing the required arguments when calling a function.

Union types

We can instruct TypeScript to expect either a number or a string using the pipe character |, like this:

let value: number | string

This is called a union type. Union types can be used wherever TypeScript expects a type, including function parameter declarations. It specifies that a variable can be one of the two specified types. A union type combines different types into a single type that can represent any of them.

For example, number | string combines number and string, meaning the parameter can be either a number or a string.

let value: number | string | undefined = 0

The code above declares a variable with a union type of string, number, and undefined, initializing it to 0. Read this union type as "number or string or undefined". This means the variable can be a string, number, or undefined. Since it is defined with let, although the variable value starts as a number, but can later be reassigned to a string or undefined. The variable must always be one of these types.

function getUserPlan(hasPaid: boolean | undefined) {
  if (hasPaid === true) {
    return 'Pro'
  }
  return 'Trial'
}

// Sample usage (do not modify)
console.log(getUserPlan(true)) // "Pro"
console.log(getUserPlan(false)) // "Trial"
console.log(getUserPlan(undefined)) // "Trial"

The undefined type here allows the function to accept a third possible state besides true or false, which can be useful in scenarios where the payment status is not determined.

Here, we used undefined in two roles:

  • value: undefined is a primitive value and provides a way to handle scenarios that indicates the absence of a value or not yet set.
  • type: undefined can be used to explicitly declare that a variable can hold the value undefined. Using undefined as a type in union types allows for more flexible and robust function signatures and variable declarations.

Type narrowing

Let's see an incorrect implementation of a function in Typescript:

function orderSummary(customerName: string, count: number | string): string {
  return `${customerName} ordered ${count.toLowerCase()} item(s)`
  // Error: Property 'toLowerCase' does not exist on type 'string | number'.
  // Property 'toLowerCase' does not exist on type 'number'.
}

toLowerCase() is valid for strings but not for numbers. Since count is number | string, you can only use methods common to both types. TypeScript prevents calling toLowerCase() on a number, which avoids runtime errors. The solution is to call toLowerCase() only if count is a string.

Here's the correct implementation of orderSummary:

function orderSummary(customerName: string, count: number | string): string {
  if (typeof count === 'string') {
    count = count.toLowerCase()
  }
  return `${customerName} ordered ${count} item(s)`
}

Type narrowing is when TypeScript deduces a more specific type based on your code. For example, TypeScript doesn't complain about this:

if (typeof count === 'string') {
  count = count.toLowerCase()
}

TypeScript is smart enough to know that inside the if block, count is a string, so you can safely use string methods like .toLowerCase().

Remember, TypeScript types are not available at runtime and they disappear. However, at runtime, you can check a variable's type using JavaScript's typeof operator, which returns a string like "string", "number", "boolean", etc.

The type alias

You can create a custom name for a type using a type alias. This will come in handy when your the same type is used in multiple places (repeated) or the types become more complex. For example, to create an alias for the Union type string | number:

type StringOrNumber = string | number

Use UpperCamelCase (also known as PascalCase) as a naming convention for type aliases and lowerCamelCase for variables.

Example:

type StringOrNumber = string | number

function convertNumber(value: StringOrNumber): number {
  if (typeof value === 'number') {
    return value
  }
  return Number.parseInt(value, 10)
}

You cannot change a type alias once defined; you can only define and assign it once.

Array type literal

The ‍‍[] is called the array type literal. When used after a TypeScript type, it creates a new type representing an array containing elements of that type.

const grades: number[] = [10, 15, 18, 8, 19]
const answers: boolean[] = [true, false, false, true]

function formatNames(names: string[]) {
  //
}
formatNames(['Sam', 'Alex'])

Note: If you use [] as a type (without any other type), it will denote an empty array.

The typescript and consequently the editor understands (Type Inference) that for example; when you have an array of strings (string[]) and you call .forEach(item) on it, the item variable will be of type string. This is because .forEach() iterates over each string in the array.

const names: string[] = ['Alex', 'Sam']

names.forEach((name) => {
  name.toFixed(2) // Error: Property 'toFixed' does not exist on type 'string'.
})

The name.toFixed(2) line causes an error because toFixed is a method for numbers, not strings.

If an array is expected to contain more than one type, you can combine Union types with array type literal. For example:

const values: (number | string)[] = [10, 'Sam', 'Alex', 15, 19, 20, '']

The parentheses around number | string is required.

With type alias:

type GradeOrNameArray = (number | string)[]
const values: GradeOrNameArray = [10, 'Sam', 'Alex', 15, 19, 20, '']
// or a preferred multi purpose type alias like
type GradeOrName = number | string
const values: GradeOrName[] = [10, 'Sam', 'Alex', 15, 19, 20, '']

Tuples

A tuple is a fixed-size, ordered array with known types for each element.

const point: [number, number] = [15.10419, 91.14034]

// or
type Point = [number, number];
const p1: Point = [15.10419, 91.14034];
const p2: Point = [18.85239, -14.89517);

Remember that the array's size is dynamic. On the other hand, tuples are fixed-sized.

Tuples can contain different types. For example, some libraries return database rows in array format.

type UserRow = [number, string, number, boolean]
const row: UserRow = [3, 'Sam', 29, true]

TypeScript knows the types of each individual element.

type UserRow = [number, string, number, boolean];

const row: UserRow = [3, "Sam", 29, true];

row[2]++; // increment the age
console.log(row[2]); // 30

row[3] = false; // change isAdmin to false

row[3] = "no";
Type 'string' is not assignable to type 'boolean'.

You can also easily destructure items from tuples and TypeScript will know the type of each element (Type inference).

Note: [] is a tuple of 0 elements. Hence if you use it as a type for a variable, Typescript, will prevent us from being able to add elements to it.

Example of tuple type:

function doSomething(first: number[] | string[], second: [number, string]) {}

// Sample usage (do not modify)
doSomething([1, 2, 3], [1, 'test'])
doSomething(['high', 'five'], [2, 'hello'])

String literal type

TypeScript checks the value (not the type) when the type is String literal. This means TypeScript expects a specific string value, unlike the usual behavior of checking only the type.

Let's store the value of a traffic light. With the TrafficLight type, TypeScript throws an error when an invalid value is assigned, catching typos and saving debugging time.

type TrafficLight = 'red' | 'orange' | 'green'

let trafficLight: TrafficLight = 'red'
trafficLight = 'orange'
trafficLight = 'green'
trafficLight = 'gren'
// Error: Type '"gren"' is not assignable to type 'TrafficLight'.
// Did you mean '"green"'?

trafficLight = 'rice'
// Error: Type '"rice"' is not assignable to type 'TrafficLight'.

There’s no difference between using double quotes, single quotes, or template strings. The string "red" is equivalent to 'red'.

Example of Union of two String literal type:

function animate(speed: 'slow' | 'fast') {}

animate('slow')
animate('fast')
animate('medium')
// Error: Argument of type '"medium"' is not assignable
// to parameter of type '"slow" | "fast"'.

The "slow" | "fast" type enforces the restriction that the animate function can only be called with one of these two values.

let and const declarations

When you define a const variable, you can't reassign it later.

const message = 'Welcome'

Since message can't change, TypeScript infers its type as 'Welcome' a string literal type, more specific than string type. This is equivalent to (which is not necessary):

const message: 'Welcome' = 'Welcome'

Although with let, values can be reassigned, this is not possible when the type is a string literal type.

let message: 'Welcome' = 'Welcome'
message = 'Morning'
// Error: Type '"Morning"' is not assignable to type '"Welcome"'.

Hence, these two are equivalent:

const message = 'Welcome'
let message: 'Welcome' = 'Welcome'

Number literal types

The same concept applies to numerical values. Here's an example defining a StatusCode type, expecting its value to be either 200, 401, or 404:

type StatusCode = 200 | 401 | 404

let code: StatusCode = 200
code = 404

Example:

function animate(speed: 'slow' | 'fast' | number) {
  // implementation here (leave this empty)
}

animate(200)
animate(300)
animate('slow')
animate('fast')

In fact, the boolean type itself is a literal type of two values, true and false:

// You cannot write this because `boolean` is already defined by TypeScript.
// However, this is how it's defined in TypeScript:
type boolean = true | false

This means true and false are also TypeScript types. Although you won't often need to use them, suppose a function always accepts an argument that is true. In that case, you can use the type true:

function doSomething(value: true) {
  // function implementation
}

doSomething(true)
doSomething(false)
// Error: Argument of type 'false' is not assignable to parameter of type 'true'.

Function return type

TypeScript can often, in many instances, automatically infer the return type of a function. For example, it can infer that the following function returns a string:

function concat(x: string, y: string) {
  return x + y
}

This also applies to functions with if/else statements or when more than one return type is expected.

function getDetails(name: string, isAdmin: boolean) {
  if (isAdmin) {
    return `Details for ${name}`
  }
  return false
}

In the above example, TypeScript can infer that the function getDetails returns either a string or a boolean, (Union type string | false) based on the isAdmin parameter.

For more complex functions, the type can't always be inferred and we need to annotate the return type of a function explicitly like:

function sum(a: number, b: number): number {
  return a + b
}

For arrow functions, you would place before the arrow =>:

const concat = (x: string, y: string): string => {
  return x + y
}
// Or
const concat = (x: string, y: string): string => x + y

If we make a mistake, TypeScript catches the bugs and we have to adjust the return type or the logic (like return empty string '' instead of false):

function getDetails(name: string, isAdmin: boolean): string {
  if (isAdmin) {
    return `Details for ${name}`
  }
  return false // Error: Type 'boolean' is not assignable to type 'string'.
}

More examples:

type Point = [number, number]

function getCoords(location: string): Point {
  if (location === 'start') {
    return [1, 2]
  }
  if (location === 'end') {
    return [14, 23]
  }
  return [0, 0]
}
function getEnvType(env: string): 'dev' | 'prod' {
  if (env === 'development') return 'dev'
  if (env === 'production') return 'prod'
  return 'dev' // default to "dev"
}

The void type

TypeScript infers the void type for functions that do not return a value.

function logName(name: string) {
  console.log('Received name', name)
}

Typescript has void type and you can also use it to explicitly specify the return type:

function logName(name: string): void {
  console.log('Received name', name)
}

The void type is only a TypeScript type that doesn't exist in JavaScript. You can't write return void in a function because void will disappear when TypeScript is compiled to JavaScript.

Using void as a return type on a function means that you should not use its return value in Javascript.

Implicit return

In JavaScript, if you don't write a return statement, then the function will implicitly return undefined.

function logName(name: string) {
    console.log("Received name", name);
}

const result = logName("Sam");
console.log(result); // undefined

So, a function that does not return anything in TypeScript is still a function that will implicitly return undefined in JavaScript.

The reason for using the void data type is to make the intention clear that no return value should be expected or used.

void is used even if a function returns undefined (whether explicitly or implicitly). It sets the expectation that the function's return value does not matter and should not be used or the function does not return anything.

void vs. undefined

undefined represents the absence of a value or an uninitialized variable.

So, a function that has a return type of void can omit the return keyword, explicitly return;, or return undefined;.

function logName1(name: string): void {
  console.log('Received name', name)
}

function logName2(name: string): void {
  console.log('Received name', name)
  return
}

function logName3(name: string): void {
  console.log('Received name', name)
  return undefined
}

A function that returns void, can only return nothing (or undefined). Other types will throw an error:

function logName4(name: string): void {
  console.log('Received name', name)
  return false
  // Error: Type 'boolean' is not assignable to type 'void'.
}

Required parameters

In JavaScript, calling a function without required arguments does not throw an error, and the skipped arguments are assigned undefined:

function logName(name) {
  console.log(name)
}
logName() // logs undefined

In TypeScript, all arguments are required by default, and missing arguments result in a compile-time error:

function logName(name: string) {
  console.log(name)
}
logName('Sam') // logs "Sam"
logName() // Error: Expected 1 argument(s), but got 0.

This feature prevents bugs from missing arguments. Even with a union type including undefined, you must explicitly pass undefined:

function logName(name: string | undefined) {
  console.log(name)
}
logName('Sam') // logs "Sam"
logName(undefined) // logs undefined
logName() // Error: Expected 1 argument(s), but got 0.

A parameter is required even if its value can be undefined.

Optional parameters

In TypeScript, you can use the ? symbol to make a parameter optional:

function logName(name?: string) {
  console.log(name)
}

logName('Sam') // logs "Sam"
logName() // logs undefined

Optional parameters can be skipped when calling the function, defaulting to undefined, just like in JavaScript.

Restriction:

A required parameter cannot follow an optional one in TypeScript. This will cause an error:

function logName(name?: string, age: number) {
  console.log(name, age)
}
// Error: A required parameter cannot follow an optional parameter.

To fix this, either re-order the parameters:

function logName(age: number, name?: string) {
  console.log(name, age)
}

Or make both parameters optional:

function logName(name?: string, age?: number) {
  console.log(name, age)
}

Default parameters

In JavaScript, you can use default parameters. Default parameters allow you to specify a default value for a parameter. This default value is used if no value is provided for the parameter or if undefined is explicitly passed.

function logName(name = '', age = 0) {
  console.log(name, age)
}
logName() // Logs "", 0
function greet(name: string = 'Guest') {
  console.log(`Hello, ${name}`)
}

// Calling the function without any arguments
greet() // Output: Hello, Guest

// Calling the function with `undefined`
greet(undefined) // Output: Hello, Guest

// Calling the function with a specific value
greet('Alice') // Output: Hello, Alice

You can't use default parameters with optional parameters simultaneously. Combining both features can lead to confusion and unexpected results.

You have to choose one. Default parameters can also be used to skip a parameter while also providing it a meaningful value (instead of undefined):

function logName(name: string, age = 0) {
  console.log(name, age)
}
logName('Sam') // Logs "Sam", 0

TypeScript infers age as number due to the default value, but you can also define it explicitly:

function logName(name: string, age: number = 0) {
  console.log(name, age)
}
logName('Sam') // Logs "Sam", 0

Examples:

function orderSummary(
  customerName: string,
  count: string | number = 0,
): string {
  return `${customerName} ordered ${count} item(s)`
}

console.log(orderSummary('Sam', 4))
;('Sam ordered 4 item(s)')
console.log(orderSummary('Alex', 'six'))
;('Alex ordered six item(s)')
console.log(orderSummary('Alex'))
;('Alex ordered 0 item(s)')

With arrow functions:

const welcomeUser = (username: string) => {
  return `Welcome back ${username}.`
}

In Typescript, all parameters are required by default unless you explicitly specify them as optional (either using ? or with a default value).

Modules (import/export)

In TypeScript, types declared in a file are not accessible in other files unless you export and import them. Although global types can be declared, it's generally not recommended.

If you have an index.ts file and a helpers.ts file that exports a showToast method:

// helpers.ts
export function showToast(message: string) {
  //
}

Trying to import it using the .ts extension will cause an error:

// index.ts
import { showToast } from './helpers.ts'
// Error: An import path can only end with a '.ts'
// extension when 'allowImportingTsExtensions' is enabled.

Even though the file is named helpers.ts, TypeScript requires you to import helpers.js to align with ES Modules in native ECMAScript environments (like the browser). The helpers.ts file is the source file that will compile to helpers.js.

So, replacing helpers.ts with helpers.js in the import statement will fix the issue:

import { showToast } from './helpers.js'

However, many developers preferred import paths to match the file on disk. To address this, TypeScript added the allowImportingTsExtensions option.

By adding "allowImportingTsExtensions": true to tsconfig.json under compilerOptions, you can import using the .ts extension:

{
  "compilerOptions": {
    "allowImportingTsExtensions": true
  }
}

and then

import { showToast } from './helpers.ts'

You can also omit the extension when using a module bundler:

// index.ts
import { showToast } from './helpers'

When you import the function, all the type information (typed parameters, return type) is still preserved and used by TypeScript.

Import Types

If a TypeScript type alias is used in multiple files, you don't need to rewrite it each time. Type aliases can be exported and imported like variables.

For example, if you have a Point type in data.ts, you can export it:

// data.ts
export type Point = [number, number]

export function logPoint(point: Point) {
  console.log(point[0], point[1])
}

Then, import it in index.ts:

// index.ts
import { Point, logPoint } from './data.ts'

const amsterdam: Point = [52.37814, 4.90052]
logPoint(amsterdam)

This allows you to use the Point type across different files.

Import as type

While the example above works, there's a better way to distinguish between normal imports and type imports. TypeScript supports the import type syntax for importing types. This enhances readability and maintainability, and helps module bundlers remove type-only imports, reducing the bundled file size.

To use import type, consider the following syntax:

// data.ts
export type Point = [number, number]

export function logPoint(point: Point) {
  console.log(point[0], point[1])
}

In index.ts:

// index.ts
import type { Point } from './data.ts'
import { logPoint } from './data.ts'

const amsterdam: Point = [52.37814, 4.90052]
logPoint(amsterdam)

Using import type is recommended for better readability and to ensure type imports are handled correctly.

Module bundlers can now safely remove import type { Point } from "./data.ts" since it's only importing type information, not JavaScript code.

You can also combine imports on a single line while distinguishing types:

// index.ts
import { logPoint, type Point } from './data.ts'

const amsterdam: Point = [52.37814, 4.90052]
logPoint(amsterdam)

Use type to prefix the type import. Choose the syntax that suits your needs. For example, to import only types:

import type { User, Order, OrderInformation } from './types.ts'

The object type and object type alias

In TypeScript, the object type is written using curly braces {} to describe the shape of an object and its properties' types. The properties' types can be any TypeScript type we already learned about.

For example, given a user object:

const user = {
  firstName: 'Sam',
  lastName: 'Green',
}

The object type is:

{
    firstName: string,
    lastName: string,
}

You can create a type alias for this object type:

type User = {
  firstName: string
  lastName: string
}

const user: User = {
  firstName: 'Sam',
  lastName: 'Green',
}

Here, User is the object type that describes user objects, requiring firstName and lastName to be strings.

Let's use the User type in a function:

type User = {
  firstName: string
  lastName: string
}

function getFullName(user: User) {
  return `${user.firstName} ${user.lastName}`
}

getFullName({ firstName: 'Sam', lastName: 'Green' }) // "Sam Green"

The getFullName function expects a User parameter. TypeScript uses this to suggest (autocomplete in Editor) firstName and lastName when accessing the user parameter.

TypeScript not only provides autocomplete but also checks that arguments match the specified type. Here's an example where an incorrect value as argument throws an error:

type User = {
  firstName: string
  lastName: string
}

function getFullName(user: User) {
  return `${user.firstName} ${user.lastName}`
}

getFullName({ firstName: 'Sam' })
// Error: Argument of type '{ firstName: string; }' is not assignable to parameter of type 'User'.
// Property 'lastName' is missing in type '{ firstName: string; }' but required in type 'User'.

Type aliases act like contracts that must be satisfied, helping catch bugs early.

Object types in TypeScript list properties and their types, separated by commas or semicolons. Both are valid:

type User = {
  firstName: string.; // semicolons
  lastName: string.; // semicolons
}

The last comma or semicolon is optional:

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

You can also use an unnamed object type, but it's usually better to name it for readability:

function getFullName(user: { firstName: string; lastName: string }) {
  return `${user.firstName} ${user.lastName}`
}

Unnamed object types can be used for short, non-reusable types, but naming them is generally recommended.

More examples of object types:

type Order = {
  customerName: string
  amount: number | string
}

const order1: Order = {
  customerName: 'Sam',
  amount: 'ten',
}

const order2: Order = {
  customerName: 'Alex',
  amount: 20,
}

Optional properties

In TypeScript, the ? operator denotes an optional property:

type User = {
  fullName: string
  age?: number
}
const user1: User = {
  fullName: 'Sam Green',
  age: 31,
}
const user2: User = {
  fullName: 'Alex Bern',
}

Here's a function that receives a User type, showcasing TypeScript's type inference for property possible values:

function logUser(user: User) {
  user.age = user.age + 1
  // Error: user.age is possibly 'undefined'.
}

The error occurs because user.age can be undefined, and undefined + 1 results in NaN. This is a common TypeScript error that helps prevent bugs.

You can fix this by narrowing the union type. Use an if condition to check if user.age exists, removing undefined from the number | undefined union type:

function logUser(user: User) {
  console.log(user.fullName)
  if (user.age !== undefined) {
    // user.age's type narrowed to number
    user.age += 1
  }
}

More examples:

type ClassroomReport = {
  name: string
  grades: number[]
  delivered: boolean
}

Structural type system

TypeScript's type system is structural, meaning objects with the same shape are considered the same type, and can be used interchangeably, regardless of their names. This is also known as Duck typing.

For example, these types are equivalent:

type Point = {
  lat: number
  lng: number
}

type MyPoint = {
  lat: number
  lng: number
}

The names don't matter; what matters is the shape. A function accepting Point can accept MyPoint since they have the same shape:

const p1: MyPoint = { lat: 15, lng: 18 }

function showPoint(point: Point) {
  //
}
showPoint(p1) // no errors

You can also call showPoint with an object having an inferred type:

const p2 = { lat: 10, lng: 12 }
showPoint(p2) // no errors

This works because p2 has the compatible shape:

{
    lat: number,
    lng: number
}

Why? TypeScript, being a superset of JavaScript, uses a structural type system to maintain the flexibility of JavaScript. In JavaScript, you can pass an object anywhere as long as it has the required properties.

AnyObject

Notice how any object satisfies the type {}. As {} has no shape (it does not describe the properties)

type AnyObject = {}

const user: AnyObject = { name: 'Sam' }
const copyOfWindow: AnyObject = window
const copyOfDocument: AnyObject = document

Interfaces

Let's say you have the following JavaScript object:

const user = {
  firstName: 'Sam',
  lastName: 'Green',
}

we define an interface:

interface User {
  firstName: string
  lastName: string
}

const user: User = {
  firstName: 'Sam',
  lastName: 'Green',
}

This interface describes the user object by structurally checking the object's shape. Missing or mismatched properties will result in an error.

Interface with optional property

interface User {
  fullName: string
  age?: number
}
const user1: User = {
  fullName: 'Sam Green',
  age: 31,
}
const user2: User = {
  fullName: 'Alex Bern',
}

Also avoid empty interfaces as it will match any object and is not sufficient.

interface AnyObject {}

export and import

// helpers.ts
export interface User {
  firstName: string
  lastName: string
}

command and semicolon is also accepted to separate the properties.

// index.ts
import { type User } from './helpers.ts'

See the difference syntax of type and interface:

type User = {
    ...
}
interface User {
    ...
}

Declaration merging - interface

You can merge two interface declarations with the same name in TypeScript:

interface User {
  fullName: string
}

interface User {
  age: number
}

const user: User = {
  fullName: 'Sam Doe',
  age: 31,
}

TypeScript automatically merges these two separate interface User declarations into:

interface User {
  fullName: string
  age: number
}

So a User object must have both fullName (string) and age (number). If you forget age, you get an error:

const user: User = {
  fullName: 'Sam Doe',
}
// Error: Property 'age' is missing in type '{ fullName: string; }' but required in type 'User'.

Interfaces merge only if they are in the same scope. This means they must be defined in the same file and within the same function, class, or module.

extends - Interface

A TypeScript interface can extend another interface, similar to class inheritance in JavaScript:

interface User {
  id: number
  email: string
}

interface AdminUser extends User {
  permissions: string[]
}

interface GuestUser extends User {
  invitationCode: string
}

The AdminUser interface extends the User interface, so it includes all properties of User plus those specific to AdminUser.

{
    id: number;
    email: string;
    permissions: string[];
}

And GuestUser has the following properties:

{
  id: number
  email: string
  invitationCode: string
}

Type vs. Interface

When working with object type we should consider that, an interface is always extendable, while an object type cannot be reopened to add new properties. You sometimes want to prevent an object's shape from changing or being extended elsewhere in the project; in that case, it's best to use type to define it.

Some examples of real-life complex objects

Nested objects

An object can contain other objects as properties. For example, a product can have a category property that is also an object:

Let's see how you can define Category as a separate type/interface and then use it inside the Product type/interface.

interface Category {
  id: number
  name: string
}

interface Product {
  id: number
  name: string
  price: number
  category: Category
  isInStock: boolean
}

const watermelon: Product = {
  id: 142,
  name: 'Seedless Watermelon',
  price: 9.99,
  category: {
    id: 9,
    name: 'Melons',
  },
  isInStock: true,
}

Another case is when a Product has a Categories property, which is an array of Category objects.

interface Category {
  id: number
  name: string
}

interface Product {
  id: number
  name: string
  price: number
  categories: Category[]
  isInStock: boolean
}

const watermelon = {
  id: 142,
  name: 'Seedless Watermelon',
  price: 9.99,
  categories: [
    {
      id: 9,
      name: 'Melons',
    },
    {
      id: 14,
      name: 'Summer fruits',
    },
  ],
  isInStock: true,
}

Notice the array literal type Category[].

More example:

interface Risk {
  name: string
  tags: string[]
  awardYears: number[]
}

let risk = {
  name: 'Risk',
  tags: ['Strategy', 'War'],
  awardYears: [1957, 1960, 1999],
}

Optional nested properties

The parentCategory nested under category is optional. If omitted, its value in JavaScript will be undefined, when accessed.

interface Product {
  id: number
  name: string
  price: number
  category: {
    id: number
    name: string
    parentCategory?: {
      id: number
      name: string
    }
  }
  isInStock: boolean
}

When accessing parentCategory in an object implementing this interface, TypeScript recognizes it as the following union type:

{
    id: number,
    name: string
} | undefined

Accessing product.category.parentCategory.name might result in JavaScript errors such as Cannot access name on undefined. Hence, type narrowing is needed to handle when the parentCategory is undefined.

  1. Type narrowing using optional chaining:
function logCategory(product: Product) {
  const parentCatName = product.category.parentCategory?.name
}

The parentCatName will be undefined if product.category.parentCategory is undefined, as the expression short-circuits and returns undefined without causing an error.

  1. Type narrowing using nullish coalescing:
function logCategory(product: Product) {
  const parentCatName = product.category.parentCategory?.name ?? ''
}

Here we default the parentCategory to an empty string instead of undefined.

Typing a Callback function

Specifying the expected callback function type in TypeScript involves defining its parameters and return type.

Passing a callback without type.

function deferredWelcome(callback) {
  setTimeout(callback, 1000)
}
deferredWelcome(() => console.log('Welcome, user!')) // Logs "Welcome, user!" after 1 second.

With type:

function deferredWelcome(callback: () => void) {
  setTimeout(callback, 1000)
}
deferredWelcome(() => console.log('Welcome, user!'))

() => void describes a function with no parameters and a return type of void. () => ... is called a function type expression. Since it's a type, it can be refactored using a type alias:

type WelcomeCallback = () => void
function deferredWelcome(callback: WelcomeCallback) {
  setTimeout(callback, 1000)
}

Typing callback function with parameter

type SumCallback = (result: number) => void

function calculateSum(a: number, b: number, callback: SumCallback) {
  const sum = a + b
  callback(sum)
}
// Usage
calculateSum(5, 7, (result) => {
  console.log('Sum is: ' + result)
})

When defining the callback type, you need to specify a parameter name. This name doesn't have to match the argument name used when calling the callback function.

Callback parameters and Substitutability of TypeScript

Implementing a callback in JavaScript involves three steps and you must separate the concepts and stages:

  • Defining the callback function
  • Passing the callback as a parameter to another function
  • Calling the callback within that parent function

A callback is different from a regular function in JavaScript. The main difference is that callbacks can be called with fewer parameters.

In JavaScript, if a function has two parameters, you must call it with two arguments. However, this is not the case for callbacks.

function calculateSum(a, b, callback) {
  const sum = a + b
  callback(sum)
}
// usage
calculateSum(5, 7, () => {
  console.log('Completed sum calculation')
})

Even though the when calling calculateSum callback we pass sum argument, we can safely pass a callback function which is not using any argument, as we may decide to not read them.

Since TypeScript is a superset of JavaScript, it mimics JavaScript's behavior, including its handling of callbacks. Thus, TypeScript behaves exactly like JavaScript in this regard.

As an example of a native JavaScript method, consider the following code. All of the examples below are valid JavaScript and, consequently, also valid TypeScript:

const data = [1, 4, 2]

data.forEach(() => {
  console.log('An iteration')
})

data.forEach((item) => {
  console.log(item)
})

data.forEach((item, index) => {
  console.log(item, index)
})

The .forEach method is a "parent" method (like calculateSum) that accepts a callback as parameters and its implementation is inside the JavaScript core.

TypeScript mimics this behavior and lets you safely ignore (not use) extra parameters for callbacks (only for callbacks, not for normal function calls).

Example:

type SumCallback = (result: number) => void

function calculateSum(a: number, b: number, callback: SumCallback) {
  const sum = a + b
  callback(sum)
}

calculateSum(5, 7, () => {
  console.log('Completed sum calculation')
})

As you can see, even though the calculateSum callback requires a result: number parameter, we can safely pass a callback function without using any parameters.

You might be wondering, isn't this similar to optional parameters? It's not the same. Only define a callback parameter as optional if you intend to call that function without that parameter sometimes.

The callback in the parent function is always called with these three parameters, but you can choose whether to read them or not.

In JavaScript, even if we don't read the index in the defined callback body:

;[1, 4, 2].forEach((item) => {
  console.log(item)
})

JavaScript still passes item, index, and array parameters to call it. It's just that our passed and defined callback function ignores the index and array.

In both JavaScript and TypeScript, callback functions are always called with all their parameters, but you can choose to ignore some of them.

using a non-void returning function as a void returning callback, Substitutability of TypeScript

We define a callback function that explicitly returns a string, and can still safely (without Typescript error) use it as a callback function where a void return type is expected:

function getName(): string {
  return 'John'
}

function initApp(callback: () => void) {
  console.log('Starting app')
  callback()
}

initApp(getName)

This behavior means, whether the function returns a value or not, we're not going to look at it.

How come callback: () => void accepts getName which explicitly returns a string and not void?

The meaning of void in TypeScript is that the return value does not matter and will not be observed or used.

function getName(): string {
  return 'John'
}

function initApp(callback: () => void) {
  console.log('Starting app')
  const name = callback()
  name.toUpperString()

  // Error Property 'toUpperString' does not exist on type 'void'.
}

initApp(getName)

We get Typescript error, as we try to read the return value of a void returning callback and call something on it.

Hence now you know why () => data.push(5), which actually returns the new length of the array, is a valid callback for initApp function. However, Typescript will make sure that you don't try and use that return value later on.

Now you know why () => data.push(5), which returns the new array length, is a valid callback for the initApp function. However, TypeScript ensures you don't use that return value later.

const data = [1, 4, 2]

function initApp(callback: () => void) {
  console.log('Starting app')
  callback()
}

initApp(() => data.push(5))

Also, remember that void is not a JavaScript type; it's a TypeScript type.

Types in Classes

While in Javascript this is a valid class:

class User {
  constructor(firstName, lastName) {
    this.firstName = firstName
    this.lastName = lastName
  }

  getFullName() {
    return `${this.firstName} ${this.lastName}`
  }
}

The Typescript valid version would be.

class User {
  firstName: string
  lastName: string

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName
    this.lastName = lastName
  }

  getFullName(): string {
    return `${this.firstName} ${this.lastName}`
  }
}

You see how Typescript demands declaring the class properties (fields) before using/accessing them.

However, this is also a valid Typescript class:

class User {
  firstName
  lastName

  constructor(firstName: string, lastName: string) {
    this.firstName = firstName
    this.lastName = lastName
  }

  getFullName() {
    return `${this.firstName} ${this.lastName}`
  }
}

TypeScript doesn't need explicit type declarations for the properties as it automatically infers the types from the constructor signature.

Typing Class in Typescript with strict mode

If we enable the strict option of TypeScript in the tsconfig.json it automatically enables TypeScript's strictPropertyInitialization setting. That means, this setting requires you to initialize properties ( default value for every property) either using the class field syntax or in the constructor. Otherwise, if a property is undefined and you make a mathematical operation on it, you will get NaN.

class Point {
  //It's valid to skip the explicit number type
  lat = 0
  lng = 0
}

// or in the constructor
class Point {
  lat: number
  lng: number

  constructor() {
    this.lat = 0
    this.lng = 0
  }
}

Typing optional class properties in Typescript with restrict mode

If a property cannot be defined yet because it's optional, that is okay but it has to be explicitly marked with ?.

class Point {
  lat = 0
  lng = 0
  elevation?: number
}
const p1 = new Point()
console.log(p1) // {lat: 0, lng: 0} // It won't return an elevation property
console.log(p1.elevation) // undefined
p1.elevation = 10 // we are able to assign it later on to a number or undefined
console.log(p1) // {lat: 0, lng: 0, elevation: 10}

If we want to have the elevation property from the start, we can then default it to undefined.

class Point {
  lat = 0
  lng = 0
  elevation?: number = undefined

  constructor(lat: number, lng: number) {
    this.lat = lat
    this.lng = lng
  }
}

const p1 = new Point(10, 20)
console.log(p1) // {lat: 10, lng: 20, elevation: undefined}

Another complex example of a valid Typescript class:

interface Todo {
  title: string
  category: string
}

class Todos {
  todos: Todo[]

  constructor() {
    this.todos = [
      {
        title: 'Learn JavaScript',
        category: 'work',
      },
      {
        title: 'Meditate',
        category: 'personal',
      },
    ]
  }
}

Class visibility in Typescript

TypeScript supports member visibility using the public and private properties. All properties and methods are public by default, thus the public keyword is optional. You can make a property or method private using the private keyword and TypeScript will check – at compile time – that you're not accessing this property/method from outside of the class. When the Typescript code is converted to JavaScript code, there is no sign of private keyword. That means, the check only happens on compile time. Hence, it is recommended you use the JavaScript private field approach as it offers true private behavior.

The JavaScript's private fields, which still works in TypeScript and also offers compile-time checks by TypeScript, as well as browser checks at runtime:

class User {
  email: string

  constructor(email: string) {
    this.email = email
  }

  logDetails() {
    console.log(this)
  }

  #sendWelcomeEmail() {
    //
  }
}

const user = new User('[email protected]')
user.logDetails()
user.#sendWelcomeEmail()
// Error: Property '#sendWelcomeEmail'
// is not accessible outside class 'User' because it has a private identifier.

For public fields there is no such concern. While the public keyword is optional as public is the default visibility, you can still use it if you want to explicitly show the visibility of this property or method.

class User {
  public email: string

  constructor(email: string) {
    this.email = email
  }
}

Remember, that this is not necessary as a class field is already public by default.

capture parameter properties in Typescript

TypeScript offers a feature called parameter properties, which lets us capture a constructor parameter into a class property with the same name. That means, we are explicitly instructing TypeScript that this constructor parameter is a class field that we need to capture.

The code above can be refactored to:

class User {
  // no need to define the class field here anymore!
  // TypeScript will generate it behind the scene.
  constructor(public email: string) {
    // no need to manually capture the constructor params anymore!
    // TypeScript will generate it behind the scene.
  }
}

Example: While this is a valid Typescript class

class Person {
  constructor(public age: number) {
    console.log(`You are ${this.age} years old`)
  }
}

const person = new Person(30) // Logs 'You are 30 years old'
console.log(person.age) // 30

It's compiled Javascript version would be:

class Person {
  constructor(age) {
    this.age = age
    console.log(`You are ${this.age} years old`)
  }
}
const person = new Person(30) // Logs 'You are 30 years old'
console.log(person.age) // 30

More example:

class User {
  constructor(
    public name: string,
    public email?: string,
  ) {}
}
const user1 = new User('Sam', '[email protected]')
console.log(user1.email) // "[email protected]"
const user2 = new User('Alex')
console.log(user2.email) // undefined

Readonly properties in Typescript

If you set a class property as readonly, TypeScript will throw an error if you try to reassign it, both inside and outside the class.

class User {
  readonly isAdmin = false
}
// or
class User {
  public readonly isAdmin = false
}

Note that using TypeScript's parameter properties syntax, the constructor is the only place you can change the property. That's because, TypeScript generates and assigns the readonly property behind the scenes, and allows reassignment immediately.

class User {
  constructor(public readonly age: number) {
    // we can still increase the age in the constructor. But, not outside
    this.age = this.age + 1
  }
}

Making object properties readonly in Typescript

A class is a blueprint for creating an instance, called an object. Similarly, we can make the object properties readonly. The readonly property prevents an object property from being changed:

interface Product {
  readonly id: number
  name: string
  price: number
}

function changeId(product: Product) {
  product.id += 1
  // Error: Cannot assign to 'id' because it is a read-only property.
}

Structural typing (Duck typing) for classes in Typescript

Substitutability of TypeScript says, If things have the expected shape or behavior, they are compatible and can be substituted. In another word, TypeScript's type system isn't literal.

If two objects have the same shape, they are considered the same type, regardless of the type or interface name.

Similarly, if two classes have the same shape and no private members, TypeScript considers them the same, ignoring the class name.

class Point {
  constructor(
    public x: number,
    public y: number,
  ) {}
}

class Coordinates {
  constructor(
    public x: number,
    public y: number,
  ) {}
}

function logPoint(point: Point) {
  console.log(point.x, point.y)
}

const point = new Point(10, 20)
const coord = new Coordinates(30, 14)

logPoint(point) // This works, as expected.
logPoint(coord) // Also works!
// while the function expects an instance of Point,
// an instance of the class Coordinates can be used

Typing an object instance of a class

An object instance of a class can have methods in addition to properties.

Here's an interface that describes a sample object with three methods annotated with this interface:

interface UserVoteDriveWithLicense {
  age: number
  canVote(): boolean
  canDrive: () => boolean
  canHaveInternationalLicense(min: number, wheels: number): boolean
}
const obj: UserVoteDriveWithLicense = {
  age: 32,
  canVote: function () {
    return this.age >= 18
  },
  canDrive: function () {
    return this.age >= 15
  },
  canHaveInternationalLicense: function (minAge: number, wheelsSize: number) {
    return this.age >= minAge && this.wheelsSize < 18
  },
}

Both types canVote(): boolean and canDrive: () => boolean are valid.

The canHaveInternationalLicense method accepts multiple parameters. The parameter names in the interface do not need to match the actual method's parameter names, but their types and order must match.

class implements interface

When a class implements an interface/type, that interface acts as a contract. That means, the class must implement at least all the properties and methods of that interface with the correct types. However, the class can implements more methods and have more properties.

Example:

The implemented interface represents the instance object created when we call the constructor method, new User().

interface UserVoting {
  age: number
  canVote(): boolean
}

class User implements UserVoting {
  age: number

  constructor(age: number) {
    this.age = age
  }

  canVote() {
    return this.age >= 18
  }
}

Sometimes we need a class satisfies multiple contracts to prevent bugs when we develop our code, for example:

interface Runnable {
  run(): void
}

interface Stoppable {
  stop(): void
}

class Process implements Runnable, Stoppable {
  run() {
    console.log('Process running')
  }

  stop() {
    console.log('Process stopped')
  }
}

extends class in Typescript

Once a subclass extends from a base class, the subclass will automatically inherit all the properties and methods.

While the extends syntax works exactly the same as that of JavaScript, it adds additional checks and is stricter than that of JavaScript.

TypeScript will enforce that a subclass is always a subtype of its base class, meaning that the subclass should always follow the same contract as that of the class.

This ensures that whenever an instance of the base class is expected, you can safely use an instance of the subclass too.

It is obvious that the subclass can have additional methods.

However, when overriding a method or property of the base class, you cannot break the contract defined in the base class, including types and return types. That means, if a property was a string in the base class, it cannot become a number in the subclass.

Example of overriding a method:

class BaseClass {
  isAvailable(): boolean {
    return true
  }
}

class SubClass extends BaseClass {
  isAvailable(): boolean {
    // additional logic or different implementation
    return false
  }
}

You can override a method and add or remove parameters as long as you follow the contract. If a method was callable with no parameters, it should remain callable with no parameters. It is valid to override it with a method that accepts an optional parameter:

class FormElement {
  constructor(public name: string) {}
  onSubmit() {
    console.log(`The form ${this.name} has been submitted`)
  }
}
class PaymentForm extends FormElement {
  onSubmit(successCode?: number) {
    console.log(`The form ${this.name} has been submitted`, successCode)
  }
}
const paymentForm = new PaymentForm()

This restriction ensures that your classes and their derived versions can be used interchangeably without causing type errors or unexpected behaviors.

The Liskov Substitution Principle (LSP) is a fundamental principle in object-oriented programming that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program.

Example:

class Rectangle {
  constructor(
    public width: number,
    public height: number,
  ) {}
  getArea() {
    return this.width * this.height
  }
  render() {
    return `Rectangle [${this.width} x ${this.height}]`
  }
}
class Square extends Rectangle {
  constructor(size: number) {
    super(size, size)
  }
  render() {
    return `Square [${this.width}]`
  }
}

function describeShape(shape: Rectangle) {
  return `${shape.render()} with an area of ${shape.getArea()}`
}

// Sample usage
const rect = new Rectangle(10, 20)
const square = new Square(5)

describeShape(rect) // "Rectangle [10 x 20] with an area of 200"
describeShape(square) // "Square [5] with an area of 25"

You see how super(size, size) overrides the base class constructor. Still, the square instance object has two properties width and height but with the same value.

You also see how the describeShape method also accepts square object as the objects can be used interchangeably.

Both objects has the same shape:

{
    width: number,
    height: number,
    getArea(): number,
    render(): string
}

How come a Square is accepted in place of a Rectangle? It is due to TypeScript's type system is structural rather than literal (substitutability). Meaning that it doesn't look at the object's name but rather its shape.

Notice how the Rectangle class name is used as a type shape: Rectangle.

Substitutability in Javascript tells us that we can still pass an instance of a class (with or without additional properties), even if that class does not extend the base class, as long as the object’s satisfy the shape described by the accepted instance of the base class.

Example:

class Parallelogram {
  constructor(
    public width: number,
    public height: number,
  ) {}

  getArea() {
    return this.width * this.height
  }

  render() {
    return `Parallellogram [${this.width} x ${this.height}]`
  }
}

function describeShape(shape: Rectangle) {
  return `${shape.render()} with an area of ${shape.getArea()}`
}

const p = new Parallelogram(10, 20)
describeShape(p) // works without errors

More example:

class Book {
  constructor(
    public title: string,
    public price: number,
  ) {}

  getPrice() {
    return this.price
  }
}

class UsedBook extends Book {
  constructor(title: string, price: number) {
    super(title, price)
  }
  getPrice() {
    return this.price - (this.price * 25) / 100
  }
}

function getTotalPrice(books: Book[]) {
  return books.reduce((total, book) => total + book.getPrice(), 0)
}

// Sample usage
const book1 = new Book('A Brief History of Time', 20)
const book2 = new UsedBook("The Hitchhiker's Guide to the Galaxy", 10)
const books = [book1, book2]
console.log(getTotalPrice(books))

Notice how the parameter books is annotated with type Book[].

protected property in Typescript

A protected property or method is available for the current class and its subclasses, but not outside of the class.

class Book {
  constructor(
    protected title: string,
    protected price: number,
  ) {}
  getPrice() {
    return this.price
  }
}
class UsedBook extends Book {
  constructor(title: string, price: number) {
    super(title, price)
  }
  getPrice() {
    return this.price - (this.price * 25) / 100
  }
}

Polymorphism

Polymorphism is that objects of different types can be treated as objects of a common base type. There are many cases where a certain behavior has been described by two or more different classes extends a base class.

what makes polymorphism possible is that TypeScript forces subclasses to follow their parent's base class contract. That means the instance objects are already satisfying the shape/type of the instance object of that base class.

Example:

class Post {
  constructor(public author: string) {}

  render() {}
}

class TextPost extends Post {
  constructor(
    author: string,
    public content: string,
  ) {
    super(author)
  }

  render() {
    return `<p>${this.content} by ${this.author}</p>`
  }
}

class ImagePost extends Post {
  constructor(
    author: string,
    public imageUrl: string,
  ) {
    super(author)
  }

  render() {
    return `<img src="${this.imageUrl}" alt="Image alt text here">
        <p>Posted by by ${this.author}</p>`
  }
}

function renderFeedPost(post: Post) {
  document.querySelector('#feed').insertAdjacentHTML('beforeend', post.render())
}

const textPost = new TextPost('Sam', 'Welcome to my profile')
renderFeedPost(textPost)

Note that when we extend a class, it doesn't necessarily mean that all the methods of base class must do operations. We can override a method and set it as a no-op (no operation). Example:

class PayPalMethod extends PaymentMethod {
  pay() {
    // specific PayPal payment method logic
  }
  refund() {} // empty
}

While we define the refund method to ensure it follows the contract, we can keep it empty. Assume as noop just for PayPal case.

Abstract classes

We can make PaymentMethod abstract to prevent creating instances of the class. This class is now a contract that you can use for the subclasses.

abstract class PaymentMethod {
  pay() {}
  refund() {}
}

A subclass can inherit a base class as it is without need to implementing it. When a method is abstract, it cannot have an implementation in the base class. It forces the subclasses to implement them.

abstract class PaymentMethod {
  abstract pay(): void
  abstract refund(): void
}

Polymorphism can be achieved not only by extending a base or abstract class but also by implementing a common interface.

We know an interface can be a type of a instance object of a class.

interface PaymentMethod {
  pay(): void
  refund(): void
}

class CardMethod implements PaymentMethod {
  pay() {
    // specific card payment method logic
  }

  refund() {
    // specific card refund method logic
  }
}

class PayPalMethod implements PaymentMethod {
  pay() {
    // specific PayPal payment method logic
  }

  refund() {
    // specific PayPal refund method logic
  }
}

unknown type

TypeScript has a type unknown. A type that is not known yet.

function logToConsole(input: unknown) {
  //
}

The input parameter can be of any type. Without using unknown, we'd need an impractically large and incomplete union type.

unknown and type narrowing example:

function logToConsole(input: unknown) {
    if (typeof input === "string") {
        return `<p class="console-string">${input}</p>`;
    }
    if (typeof input === "number") {
        return `<p class="console-number">${input}</p>`;
    }
    if (Array.isArray(input) {
        return `<p class="console-array">[${input.join(", ")}]</p>`
    }
}

Although unknown also represents any data type, it is safer than any because TypeScript does not allow us to call anything on unknown. With type unknown, we must narrow it before use. It's a temporary state where the variable's type is unknown. TypeScript allows you to clarify the type through type narrowing.

On the other hand, You can call anything on any without TypeScript complaining because any turns off (disables) TypeScript's type checking.

Example:

function isPositiveNumber(input: unknown): boolean {
  return typeof input === 'number' && input > 0
}

// Sample usage
isPositiveNumber(10) // true
isPositiveNumber(-5) // false
isPositiveNumber('5') // false

In JavaScript, for legacy reasons, typeof null is object, typeof input === "object". This is why we sometimes need more specific type narrowing.

function getKeys(input: unknown) {
  if (typeof input === 'object' && input !== null) {
    return Object.keys(input)
  }
  return []
}

Otherwise Typescript will warn us.

function getKeys(input: unknown) {
  if (typeof input === 'object') {
    return Object.keys(input)
    // Error: Argument of type 'object | null' is not assignable to parameter of type 'object'.
  }
  return []
}

Function overloads

?

Generics, generic function

Generics enable type-safe code when a function works with various types.

We can make a function generic that means works for any data type, but we define the type when we call it. So, a generic function creates a template to work with different types.

function getFirst<Type>(array: Type[]) {
  return array[0]
}

Type is the placeholder and is determined when we call it. It captures the type information and uses it to make a relationship between the inputs and outputs!

getFirst<string>(['a', 'b'])
getFirst<number>([1, 2])

When we call getFirst<string>(...), we provide string as a value for the Type type parameter.

So, this getFirst function not only receives an array parameter, but it also receives a TypeScript type (string, or number, or ...) that we can use to make a relationship between the inputs and the outputs of the function.

<string> is a type argument passed to the getFirst<Type> generic function. Similarly, <number> is a type argument passed to the getFirst<Type> generic function.

A single character T is conventionally accepted instead of a descriptive name Type.

function getFirst<T>(array: T[]) {
  return array[0]
}

Return type of the function is automatically inferred by Typescript. However, we can define it explicitly.

function getFirst<T>(array: T[]): T {
  return array[0]
}

Now it is possible to have such general usage/implementations easily otherwise it would be verbose to implement this.

getFirst<number>([1, 2, 3]).toFixed(2); // "1.00"
getFirst<string>(["a", "b"]).toUpperCase(); // "A"
getFirst<{name:string, age:number}>([{name: "Sam", age: 43}).name; // "Sam"

However, in this specific case, we can completely drop the type argument and let TypeScript infer it!:

getFirst([1, 2, 3]).toFixed(2); // "1.00"
getFirst(["a", "b"]).toUpperCase(); // "A"
getFirst([{name: "Sam", age: 43}).name; // "Sam"

Note that we cannot drop the type parameter (for example, <T>) from the function definition. The function is generic just because of this <T> syntax.

generic Set class

In Javascript we have and API which is set:

const set = new Set()

set.add(10)
set.add(5)
console.log(set) // Set(2) {10, 5}

const anotherSet = new Set()
anotherSet.add('abc')
console.log(anotherSet) // Set(1) {"abc"}

However, this JavaScript API is implemented in TypeScript as a generic for type safety. When you create a Set, you can tell TypeScript the data type it accepts: This Set can now only accept number elements. So, take a look at the error message you'll get if you try to add another data type:

In TypeScript, this JavaScript API is implemented as a generic for type safety. When you create a Set, you specify the data type it accepts. For example, a Set of numbers only accepts number elements. An error occurs if you try to add a different type:

const set = new Set<number>();
set.add(10);
set.add("abc");
Argument of type 'string' is not assignable to parameter of type 'number'.

More example:

function makeTuple<T>(element: T): [T, T] {
  return [element, element]
}

makeTuple('abc') // ["abc", "abc"]
makeTuple(10) // [10, 10]

Notice the return type is a tuple type.

More example:

Consider the the return type T[] is optional as Typescript infers it.

function ensureArray<T>(value: T | T[]): T[] {
  return Array.isArray(value) ? value : [value]
}

ensureArray(['Sam', 'Alex']) // ["Sam", "Alex"]
ensureArray([123]) // [123]
ensureArray(512) // [512]
ensureArray('Sam') // ["Sam"]
ensureArray(true) // [true]
ensureArray([123, 'Sam']) // [123, "Sam"]

interface User {
  name: string
  age: number
}

ensureArray<User>({ name: 'Sam', age: 31 }) // [{name: "Sam", age: 31}]

Example:

function duplicate<T>(items: T[]) {
  return [...items, ...items]
}

duplicate(['abc', 123])

Behind the scene Typescripts creates a template with such signature.

function duplicate<string | number>(items: (string | number)[]): (string | number)[]

Array generics

The Array<T> generic type (where T is the array item type) is an alternative way to define an array type in TypeScript. We still can use array literal type.

const grades1: number[] = [15, 16, 18, 14]
const grades2: Array<number> = [15, 16, 18, 14]

Both variables are exactly the same.

const names1: string[] = ['John', 'Alex']
const names2: Array<string> = ['John', 'Alex']

Since T in Array<T> can be any type we'd like, this can also be a union type, interface or type.

const data1: (number | string)[] = [1, 'hello']
const data2: Array<number | string> = [1, 'Hello']

interface User {
  name: string
  age: number
}

const users1: User[] = [{ name: 'Alex', age: 31 }]
const users2: Array<User> = [{ name: 'Alex', age: 31 }]

Object Generics

This is how we convert an interface to a a generic interface.

interface ApiResponse<T> {
  status: number
  data: T
}

Where it describes a template of an object where the status is always a number and the data is T (type argument), which we can pass User or Product as type argument for example.

The ApiResponse<T> describes a generic object that accepts a type T.

Then we can annotate different objects using this generic interface.

interface User {
  id: number
  name: string
}

interface Product {
  id: number
  title: string
  price: number
}

const user1: ApiResponse<User> = {
  status: 200,
  data: {
    id: 1,
    name: 'Sam Green',
  },
}

const product1: ApiResponse<Product> = {
  status: 200,
  data: {
    id: 1,
    title: 'Oranges',
    price: 10,
  },
}

Lets see for example products object where T is Product[].

const products: ApiResponse<Product[]> = {
  status: 200,
  data: [
    {
      id: 1,
      title: 'Oranges',
      price: 10,
    },
    {
      id: 2,
      title: 'Pen',
      price: 5,
    },
  ],
}

A single generic interface serves different objects, preventing repetition.

Note that both of these are equal but with slight syntax differences:

type ApiResponse<T> = {
  status: number
  data: T
}
// or
interface ApiResponse<T> {
  status: number
  data: T
}

Look at how we use the type keyword to create new types out of existing generic object types:

interface ApiResponse<T> {
  status: number
  data: T
}

interface User {
  id: number
  name: string
}

interface Product {
  id: number
  title: string
  price: number
}

type UserApiResponse = ApiResponse<User>
type ProductApiResponse = ApiResponse<Product>

With this knowledge, we see that the Array<T> generic type is implemented int he Typescript core as a generic object using a generic interface.

Now let's see a complex example:

interface User {
  id: number
  name: string
}

interface SuccessResponse<T> {
  status: number
  data: T
}

interface ErrorResponse {
  status: number
  message: string
}

type ApiResponse<T> = SuccessResponse<T> | ErrorResponse

// Sample usage
// Successful response
const response1: ApiResponse<User> = {
  status: 200,
  data: {
    id: 1,
    name: 'Sam Green',
  },
}

// Error response
const response2: ApiResponse<User> = {
  status: 404,
  message: 'Could not find user with id 2',
}

interface PaginatedResponse<T> {
  currentPage: number
  totalPages: number
  totalCount: number
  data: T[]
}

// Sample usage
const response: PaginatedResponse<User> = {
  currentPage: 1,
  totalPages: 4,
  totalCount: 38,
  data: [
    {
      id: 1,
      name: 'Sam Green',
    },
    {
      id: 2,
      name: 'Alex Blue',
    },
  ],
}

Let's try to implement our custom array generic type.

interface MyArray<T> {
  length: number
  toString(): string
  // the "more" correct version would be push(...items: T[]) because .push() can receive multiple arguments
  push(item: T): number
}

// Sample usage
const data1: MyArray<number> = [1, 14, 2]
console.log(data1.length) // 3
console.log(data1.toString()) // "3"
console.log(data1.push(10)) // 4 (the new length of the array)

const data2: MyArray<string> = ['abc']
console.log(data2.length) // 1
console.log(data2.toString()) // "abc"
console.log(data2.push('def')) // 2 (the new length of the array)

Generic class

This is a type-safe generic class which is used in two different scenarios:

class DataWrapper<T> {
  data

  constructor(value: T) {
    this.data = value
  }
}

const first = new DataWrapper('This one contains a string')
first.data.toUpperCase() // TypeScript knows that data is a string in this example

const second = new DataWrapper(14)
second.data.toFixed(2) // TypeScript knows that data is a number in this example

You see how Typescript was able to infer the T type argument. However, it is also possible to explicitly specify it.

new DataWrapper<string>('This one contains a string')
new DataWrapper<number>(14)

Remember in the similar way for generic function, we didn't pass the type argument, and typescript could infer the types.

See how this generic class type is useful in more complex data types:

interface User {
  id: number
  name: string
  age: number
}

const users = [
  {
    id: 1,
    name: 'Sam Green',
    age: 41,
  },
]

const wrapper = new DataWrapper<User[]>(users)

// Benefit from type-safe code (and autocomplete)
wrapper.data.forEach((user) => {
  console.log(user.name)
})

Remember that how we used new Set<number>().

Map is a key-value data structure in JavaScript that provides an efficient way to add, edit, and delete data.

const map = new Map()
map.set('abc', 'My value here')
map.set(19427, 'Another value')
map.get('abc') // "My value here"
map.get(19427) // "Another value"

More example:

class CacheService<T> {
  store

  constructor() {
    this.store = new Map()
  }
  set(key: T, value: number) {
    this.store.set(key, value)
  }
  get(key: T) {
    return this.store.get(key)
  }
}

// usage
const cityCaches = new CacheService<string>()
cityCaches.set('London', 4)
cityCaches.get('London') // 4

type LatLng = [number, number]

const locationCaches = new CacheService<LatLng>()
locationCaches.set([52.3676, 4.9041], -3)
locationCaches.get([52.3676, 4.9041]) // -3

Another Example:

class Stack<T> {
  elements: Array<T> = []
  push(input: T): void {
    this.elements.push(input)
  }
  pop(): void {
    this.elements.pop()
  }
}

// Sample usage
const numberStack = new Stack<number>()
numberStack.push(1)
numberStack.push(2)
numberStack.pop()
console.log(numberStack.elements) // [1]

const stringStack = new Stack<string>()
stringStack.push('Apple')
stringStack.push('Banana')
stringStack.pop()
console.log(stringStack.elements) // ["Apple"]

Generics with default type value.

for all sorts of generics, such as generic functions, arrays, objects, interface and classes we can specify a default value for the generic type.

function wrapInArray<T = string>(value: T) {
  return [value]
}

The T = string syntax in TypeScript sets the default type of T to string when no other type is specified or inferred.

class Stack<T = string | number> {
  elements: T[] = []

  push(element: T) {
    this.elements.push(element)
  }

  pop() {
    return this.elements.pop()
  }
}

Multiple generic types

This is how Map is defined in the TypeScript source code but with different type names. TypeScript uses K and V (short for Key and Value):

interface Map<K, V> {
  /* ... */
}

const map1 = new Map<number, string>()
map1.set(4, 'value here')

If we don't not specify the K and V types our code is not type-safe. In fact, the result of get() has any type because TypeScript does not know what is being returned from the Map since it's not type-safe.

Similarly, new Set() is not type-safe while new Set<string>() is type-safe.

Type inference example:

function createPair<T>(first: T, second: T): [T, T] {
  return [first, second]
}
createPair('abc', 'def') // ["abc", "def"]

There is no need to pass the type argument as typescript can infer the type.

createPair<string>('abc', 'def')

More complex example:

function createPair<T1, T2>(first: T1, second: T2) {
  return [first, second]
}

createPair<number, boolean>(10, true) // [10, true]
// or
createPair(10, true) // using type inference

Generic constraint

Look at this how Typescript complains about the item.price because at the moment of function definition. As T is just a generic type; it can be anything! It can also be a number or a string. So, TypeScript is not letting us treat item as an object with a price key.

function displayItemPrice<T>(item: T) {
  return `This item costs ${item.price} dollars.`
  // Error: Property 'price' does not exist on type 'T'.
}

This is different from type inference where at the call time it can infers the type of the item.

displayItemPrice({ id: 1, name: 'Online course', price: 10, taxable: true })

See how we constraint the T and Typescript knows that the input item is object which can have other properties, but it must satisfy an interface PricedItem, meaning that it must be an object with at least the price key and number value.

interface PricedItem {
  price: number
}

function displayItemPrice<T extends PricedItem>(item: T) {
  return `This item costs ${item.price} dollars.`
}

Having this, TypeScript will only allow us to call displayItemPrice() with objects that follow the PricedItem shape.

See how we combined constraint with default type value.

interface UnlabledItem {
  id: number
  price: number
}

interface PricedItem {
  price: number
}

function displayItemPrice<T extends PricedItem = UnlabledItem>(item: T) {
  return `This item costs ${item.price} dollars.`
}

keyof operator

keyof is always placed before an object type (interface type), and it will give you a union type of its keys.

interface Point {
  lat: number
  lng: number
  elevation: number
}

type ValidKeys = keyof Point

ValidKeys evaluate to "lat" | "lng" | "elevation".

Example of how we pass safely argument to the getProperty function.

interface Point {
    lat: number;
    lng: number;
    elevation: number;
}

function getProperty(point: Point, key: keyof Point) {
    return point[key];
}

const p1 = {
    lat: 10,
    lng: 20,
    elevation: 3
}

getProperty(p1, "lat"); // 10
getProperty(p1, "lng"); // 20
getProperty(p1, "elevation"); // 3
getProperty(p1, "latitude");
Argument of type '"latitude"' is not assignable to parameter of type 'keyof Point'.

Generic extends keyof

In this code, Typescript complains

type Person = {
  id: number
  firstName: string
  lastName: string
  age: number
}

function getValue(person: Person, prop: keyof Person) {
  return person[prop]
}

const person: Person = {
  id: 1,
  firstName: 'Alex',
  lastName: 'Blue',
  age: 30,
}

const firstName = getValue(person, 'firstName')
firstName.toUpperCase()
// Error: Property 'toUpperCase' does not exist on type 'string | number'.
// Error: Property 'toUpperCase' does not exist on type 'number'.

The return type of getValue is inferred to be Person[keyof Person], which is a union of all property types in Person, that means (number | string).

It would be better if TypeScript infers the type correctly as string (since the firstName property has the type string). But, this type information is lost when we rely on the keyof operator.

keyof is not able to remember the types of the keys.

The correct type-safe version is making the function generic using Generic extends keyof.

type Person = {
  id: number
  firstName: string
  lastName: string
  age: number
}

function getValue<T, K extends keyof T>(person: T, prop: K) {
  return person[prop]
}

// Sample usage
const person: Person = {
  id: 1,
  firstName: 'Alex',
  lastName: 'Blue',
  age: 30,
}

const firstName = getValue(person, 'firstName')
firstName.toUpperCase()

Key extends keyof Type means that Key must be a valid key of Type.

TypeScript is now able to map the return type to T[K] where T is person and K is "firstName" and can thus infer the correct return type, which is string.

With this extends keyof T we determine exactly what is K type and make it specific at the time of calling.

Note 1: generics are generic at the moment when the function, class, object, interface are defined. but they are specific when they are called.

Note 2: string literal is a value that a variable can take and is not a type.

Another example:

function updateObj<O, K extends keyof O> (obj: O, key: K, value: O[K]):void {
    obj[key] = value;

    console.log(obj)
}

// Sample usage
const user = { name: "Sam", age: 30 };

updateObj(user, "name", "Alex");
updateObj(user, "age", 31);

Notice to O[K] which introduces a new type.

Required<Type>

Required<Type> introduce a new type depending on the provided argument Type. The new type is all the properties of the Type we provide are set to required. It works with object types and interface type.

This is useful for avoiding the creation of a new interface type when an existing one can be repurposed and used as an argument to introduce a new type.

The benefits are:

  • Your code remains DRY (Don't Repeat Yourself)
  • Your new type updates with the one it's based on
interface User {
  id?: number
  name: string
  age: number
  verified?: boolean
}

type DBUser = Required<User>

function sendEmail(user: DBUser) {
  console.log(`Email sent to user id ${user.id.toString()}`)
}

Hence, what object we provide as argument for sendEmail function must have all the properties of User interface type, even if there are multiple optional properties.

Otherwise, it would not be possible to ensure the calling of user.id within the function body.

Partial<Type>

The Partial<Type> utility type is the opposite of the Required<Type> utility type. Partial<Type> will create a new type where all the properties are set to optional.

Pick<Type, Keys>

The Pick<Type, Keys> utility type creates a new type by extracting (picking) a subset of properties (Keys) from an existing type (Type). We'll need to provide Keys as a union string includes those keys we are interested to keep. Since we use Pick which is a utility type, Typescript expects that what we provide as Keys must be in Type.

interface Product {
  id: number
  name: string
  price: number
  description: string
  category: string
  inStock: boolean
  dimensions: {
    width: number
    height: number
    depth: number
  }
  tags: string[]
}
type ProductEssentials = Pick<Product, 'name' | 'price' | 'inStock'>

TypeScript is checking that the Keys you're passing satisfies the constraint keyof User, meaning that it should be a valid key of User.

Omit<Type, Keys>

The Omit<Type, Keys> Utility Type is the opposite of Pick<Type, Keys>.

typeof

First of all, this is different from typeof in Javascript like typeof count === "string" used for type narrowing.

Typescript has a typeof type operator that can be used in type context. The typeof type operator automatically created a new type based on a value at compile time and is thus erased from the generated JavaScript code.

The input value can be variable, object, or even a function.

const config = {
  server: 'http://localhost:8080',
  devMode: true,
}

type ConfigType = typeof config

And the ConfigType type evaluates to the following:

{
  server: string
  devMode: boolean
}

Another example:

const formatDate = (date: Date) => {
  return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
}
type FormatDateType = typeof formatDate

FormatDateType evaluates to (date: Date) => string;

ReturnType<Type> Utility Type

The ReturnType<Type> utility type is used to get the return type of a function.

type FormatDateFunction = (date: Date) => string
type FormatDateReturnType = ReturnType<FormatDateFunction>

The value of FormatDateReturnType is a string in this example. Or:

type FormatDateReturnType = ReturnType<typeof formatDate>

Example:

const appConfig = {
  apiBaseUrl: 'https://api.example.com',
  enableLogging: true,
  // theme: "dark"
}

function getConfigValue(key: keyof typeof appConfig) {
  return appConfig[key]
}

// Sample usage
console.log(getConfigValue('apiBaseUrl'))
console.log(getConfigValue('enableLogging'))

Parameters<T>, NonNullable<T>

?

Record<Keys, Type>

The Record<Keys, Type> creates an object type with Keys as keys and Type as values. It helps to describe nested and complex objects. Note that all keys are of the same type (Keys), and all values are of the same type (Types) as well.

const grades: Record<string, number> = {
  sam: 10,
  alex: 15,
}

In JavaScript, it is possible to omit the quotation marks around object keys as long as the keys are valid JavaScript identifiers. A valid identifier can contain letters, digits, underscores (_), and dollar signs ($), and it cannot start with a digit. Here is an example of an object with keys that do not require quotation marks:

const obj = {
  name: 'John',
  age: 30,
  isActive: true,
}

However, if your keys include spaces, special characters, or start with a digit, you must use quotation marks:

const obj = {
  'first name': 'John',
  age: 30,
  'is-active': true,
  '123key': 'value',
}

In this example, first name, is-active, and 123key require quotation marks because they contain spaces, special characters, or start with a digit, respectively.

To summarize:

  • You can omit quotation marks for keys that are valid JavaScript identifiers.
  • You must use quotation marks for keys that are not valid JavaScript identifiers.
const users: Record<number, string> = {
  1: 'Sam Green',
  2: 'Alex Brane',
}

Another example:

interface UserInfo {
  name: string
  age: number
  city: string
}

const users: Record<string, UserInfo> = {
  sam: {
    name: 'Sam',
    age: 30,
    city: 'Amsterdam',
  },
  alex: {
    name: 'Alex',
    age: 25,
    city: 'Berlin',
  },
}

More examples:

const appConfig: Record<string, number | string> = {
  appName: 'MyApp',
  version: 1.5,
  environment: 'production',
  maxUsers: 1000,
}

const errorCodes: Record<number, string> = {
  404: 'Not Found',
  500: 'Internal Server Error',
  401: 'Unauthorized',
  403: 'Forbidden',
}

// Add extra properties
errorCodes[405] = 'Method Not Allowed'

function createConfig(): Record<string, boolean> {
  return {
    isEnabled: true,
    useSSL: false,
    shouldRetry: true,
  }
}

const config = createConfig()

// Add extra config properties
config.powerSaveMode = true

I in this syntax Record<Keys, Type> we use a union string literal type then it enforces that each one of the types in the union string literal type is used as a key, Not more and not less.

You get an error if you add a key that doesn't exist in the union string literal type.

Example:

type Status = 'new' | 'pending' | 'complete'

const statuses: Record<Status, string> = {
  new: 'Not started',
  pending: 'Underway',
  complete: 'Finished',
}

The object must then represents each and every item in the union string literal type.

type UserRole = 'admin' | 'user' | 'guest'

const permissions: Record<UserRole, Array<string | null>> = {
  admin: ['create', 'read', 'update', 'delete'],
  user: ['read'],
  guest: [],
}

More example: This is a valid code:

type UserProfile = Record<string, string>

const user1: UserProfile = {}

An empty object {} has no keys, so it implicitly satisfies the Record<string, string> type. Since there are no keys to contradict the type constraint.

index signature

This interface has a required property. Additionally, with an index signature (a pattern) describing dynamic properties that can exist.

interface UserProfile {
  username: string
  [dynamicProps: string]: string
}

dynamicProps is just a name. The square brackets around the key name (dynamicProps) and type signify that it's a dynamic key with type string and the value has string type.

const user1: UserProfile = {
  username: 'sam',
  bio: 'I am new here',
  personalWebsite: 'example.com',
}

const user2: UserProfile = {
  username: 'alex',
}

As you see, all objects of type UserProfile must have the username: string property. It is a must.

When defining a type with index signatures, the fixed properties type have to the same type of the dynamic properties. For example using [key: string]: string has a value type of string, TypeScript enforces that all the keys have a value of type string.

To define a property of another type of dynamic property, we must first consider that type as one of the possible dynamic property's types. This is only feasible if the index signature accepts a union type.

interface UserProfile {
  id: number
  [key: string]: string | number
}

const user1: UserProfile = {
  id: 1,
  bio: 'I am new here',
  personalWebsite: 'example.com',
  age: 30,
}

const user2: UserProfile = {
  id: 4,
}
interface UserProfile {
  id: number
  [key: string]: string | number
}

const user1: UserProfile = {
  id: 1,
  bio: 'I am new here',
  personalWebsite: 'example.com',
  age: 30,
}

const user2: UserProfile = {
  id: 4,
}

This has a side effect that now other dynamic properties can also accept number values, which is why we were able to add age: 30.

Record vs. Index signatures

If you don't have fixed properties the index signature becomes:

type Config = {
  [key: string]: string
}
// or
type Config = { [key: string]: string }

The Record<string, string> also do the same job! Hence a good practice is to avoid index signature if we don't need to fixed properties.

Access type of a specific property

It's possible in TypeScript to access the type of a specific property of an object type.

interface User {
  name: string
  age: number
}

type NameType = User['name'] // string
type AgeType = User['age'] // number

intersection

The intersection type (&) combines multiple types into one by merging all the properties of all the types into one. That's mean an object or a value must satisfy all the combined types' requirements.

interface Coordinates {
  lat: number
  lon: number
}

interface Info {
  label: string
}

function merge(coords: Coordinates, info: Info): Coordinates & Info {
  return { ...coords, ...info }
}

For example we can write this type as a result of intersection of two types

interface UserProfile {
  username: string
  [key: string]: string
}

// or
interface UserProfileRequired {
  username: string
}

interface DynamicProperties {
  [key: string]: string
}

type UserProfile = UserProfileRequired & DynamicProperties

Example:

interface Printable {
  print: () => void
}

// TODO: explicitly specify return type
function withPrint<T>(object: T): T & Printable {
  return {
    ...object,
    print: function () {
      console.log(`Printing...`)
    },
  }
}

// Sample usage
interface User {
  id: number
  name: string
}

const printableUser = withPrint<User>({ id: 1, name: 'Alex' })
printableUser.print()

If both types have a property with the same name and the same type, the resulting type will, of course, include that same property and type.

What if the two types have a property with the same name but different types? The resulting type must satisfy both types if possible. That means it finds the intersect the types if exists.

interface Product {
  id: string | number
  title: string
}

interface ProductWithPrice {
  id: number
  price: number
}

type ProductInfo = Product & ProductWithPrice

const oranges: ProductInfo = {
  id: 1,
  title: 'Oranges',
  price: 10,
}

So, (string | number) & number results in number.

interface User {
  id: string
  name: string
}

interface Admin {
  id: number
}

type AdminUser = User & Admin

string & number results in never. That means is the any type that satisfy both? no. Thus, AdminUser is equivalent to:

{
  id: never
  name: string
}

Which is not a useful type.

While type cannot be re-opened, you can define new type with intersection.

type User = {
  name: string
  age: number
}

type Admin = User & {
  role: string
}

Which is equal to:

interface User {
  name: string
  age: number
}

interface Admin extends User {
  role: string
}

However, there is a significant difference: the first approach may result in a type with never, whereas the second approach, which uses interface, will immediately raise an error if there is no intersection between the two types.

interface User {
  id: string
  name: string
}

interface Admin extends User {
  // Error: Interface 'Admin' incorrectly extends interface 'User'.
  // Error: Types of property 'id' are incompatible.
  // Error:  Type 'number' is not assignable to type 'string'.
  id: number
}