TypeScript tutorial
Table of Contents
- Introduction
- Basic primitive types and syntax
- any type
- Tsconfig.json
- TypeScript directive comments
- TypeScript ESLint
- Primitive types
- undefined as type and value
- Annotating variable declarations
- Type inference
- Union types
- Type narrowing
- The type alias
- Array type literal
- Tuples
- String literal type
- Number literal types
- Function return type
- The void type
- Required parameters
- Optional parameters
- Default parameters
- Modules (import/export)
- Import Types
- Import as type
- The object type and object type alias
- Optional properties
- Structural type system
- Interfaces
- Declaration merging - interface
- Type vs. Interface
- Some examples of real-life complex objects
- Typing a Callback function
- Typing callback function with parameter
- Callback parameters and Substitutability of TypeScript
- using a non-void returning function as a void returning callback, Substitutability of TypeScript
- Types in Classes
- Typing Class in TypeScript with strict mode
- Typing optional class properties in TypeScript with restrict mode
- Class visibility in TypeScript
- capture parameter properties in TypeScript
- Readonly properties in TypeScript
- Making object properties readonly in TypeScript
- Structural typing (Duck typing) for classes in TypeScript
- Typing an object instance of a class
- class implements interface
- extends class in TypeScript
- protected property in TypeScript
- Polymorphism
- Abstract classes
- unknown type
- Function overloads
- Generics, generic function
- generic Set class
- Array generics
- Object Generics
- Generic class
- Generics with default type value.
- Multiple generic types
- Generic constraint
keyofoperator- Generic
extends keyof Required<Type>Partial<Type>Pick<Type, Keys>Omit<Type, Keys>- typeof
ReturnType<Type>Utility TypeParameters<T>, NonNullable<T>Record<Keys, Type>- index signature
- intersection
Response— The Fetch API typePromise<T>— Typing Promisesasync/await— Typing async functions- Utility types with
| null— Nullable utility types Map<K, V>— Practical usage patterns
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 calls produce errors:
sayLouder(123)
// Error: Argument of type 'number' is not assignable to parameter of type 'string'.
sayLouder(true)
// Error: 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 an 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-nocheck
// 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 should not use as a type annotation.
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 annotation for each of these. Example:
function doSomething(firstParam: string, secondParam: null) {
// function implementation
}
null vs. undefined — the mental model
The simplest way to remember the difference:
undefined | null | |
|---|---|---|
| Who sets it? | JavaScript does it for you | You do it yourself |
| Meaning | "Nothing here yet" | "I intentionally put nothing here" |
| When it happens | A variable was declared but never given a value | A variable was explicitly assigned null by the developer |
Think of a shelf in a warehouse:
undefined— the shelf exists, but nobody has put anything on it yet. It is empty by default.null— somebody walked up to the shelf and placed a label that says "intentionally empty". It is empty on purpose.
let a // a exists but was never assigned → undefined (JavaScript did this)
let b = null // b was explicitly set to null by the developer (you did this)
console.log(a) // undefined
console.log(b) // null
Where you'll see undefined in practice
1. Declared but not assigned
let score
console.log(score) // undefined — JavaScript's default
2. A function with no return statement
function greet() {
console.log('Hi')
}
const result = greet()
console.log(result) // undefined — no explicit return value
3. Accessing a property that doesn't exist
const user = { name: 'Sam' }
console.log(user.age) // undefined — age was never defined
Where you'll see null in practice
1. Resetting a value on purpose
let selectedUser = { name: 'Sam' }
selectedUser = null // the developer deliberately cleared it
2. API responses
Many APIs return null to say "we looked, but there is nothing":
const element = document.getElementById('nonexistent')
console.log(element) // null — the DOM searched and found nothing
Quick comparison
console.log(typeof undefined) // "undefined"
console.log(typeof null) // "object" ← this is a known JavaScript bug, but it's kept for backward compatibility
console.log(undefined == null) // true — loose equality treats them as the same
console.log(undefined === null) // false — strict equality knows they are different types
Rule of thumb: use
undefinedto mean "not yet set" andnullto mean "set to nothing on purpose". In most day-to-day code you will encounterundefinedfar more often, because JavaScript produces it automatically. You only writenullwhen you want to be explicit about the absence of a value.
undefined as type and value
This is a concept that confuses many people, so let's be very explicit about it.
The word undefined does two completely different jobs in TypeScript, and which job it's doing depends on where it appears:
Job 1 — undefined as a value (right side of =)
When undefined appears where a value is expected, it is the actual data that a variable holds. JavaScript assigns this value automatically when you declare a variable without initialising it:
let score // score holds the value undefined right now
console.log(score) // undefined ← you are printing the value
You can also assign it explicitly:
let score: number | undefined = 42
score = undefined // you deliberately set the value to undefined
Job 2 — undefined as a type (left side of =, in annotations)
When undefined appears in a type annotation, it tells TypeScript "this variable is allowed to hold the value undefined":
let score: number | undefined
// ^^^^^^ ^^^^^^^^^ ← these are types, not values
Without | undefined in the type, TypeScript would not let you assign undefined to score later.
Both jobs in one line
let data: string | undefined = undefined
// ^^^^^^^^^^^^^^^^^ type ^^^^^^^^^ value
// "data can be a string "right now,
// or undefined" it IS undefined"
The type describes what is allowed. The value describes what is currently stored.
A real-world example
function getUserPlan(hasPaid: boolean | undefined) {
// ^^^^^^^^^ type: "this parameter is allowed to be undefined"
if (hasPaid === true) {
return 'Pro'
}
return 'Trial'
}
getUserPlan(true) // "Pro"
getUserPlan(false) // "Trial"
getUserPlan(undefined) // "Trial" — the caller didn't know the payment status yet
Here, undefined appears as a type in the parameter annotation (allowing it), and as a value in the function call (passing it).
Memory aid: Ask yourself — "Am I describing what's allowed, or am I providing actual data?" If describing → it's a type. If providing → it's a value.
Annotating variable declarations
You annotate a variable by placing a colon and a type between the variable name and the = sign:
let variableName: Type = value
// ^^^^^^
// type annotation goes here
Basic examples
const username: string = 'sam'
const fullName: string = ''
const isActive: boolean = true
let age: number = 20
You can also annotate variables that hold null or undefined. In these cases the only value you can ever assign is the one matching the type:
let config: null = null // config can only ever be null
let data: undefined = undefined // data can only ever be undefined
These are rarely useful on their own — you will almost always combine them with other types using union types:
let config: object | null = null // starts as null, can later hold an object
let data: string | undefined // starts as undefined, can later hold a string
Type mismatches produce errors
The type and the value must agree. If they don't, TypeScript tells you immediately:
const isActive: string = true
// Error: Type 'boolean' is not assignable to type 'string'.
Reassignment respects the type
Once a variable has a type (whether you wrote it or TypeScript inferred it), every future assignment must match:
let sum: number = 0
sum += 10
console.log(sum) // 10
sum = ''
// Error: Type 'string' is not assignable to type 'number'.
sum was declared as number, so it must stay a number. TypeScript enforces this on every reassignment — the type is a contract that lasts for the entire lifetime of the variable.
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 = ''
// Error: Type 'string' is not assignable to type 'number'.
TypeScript still catches the error on the last line, even without an explicit type annotation.
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, it 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.
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 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 is equivalent to a union of two literal types, true and false:
// Conceptually, boolean is equivalent to:
// 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 separator 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
}
}
An optimized version using the nullish coalescing operator ??, which eliminates the if block entirely:
function logUser(user: User) {
console.log(user.fullName)
user.age = (user.age ?? 0) + 1
}
user.age ?? 0 returns 0 when user.age is undefined (or null), then adds 1.
TypeScript accepts this because the result of (number | undefined) ?? number is always number.
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
}
Comma and semicolons are both accepted to separate 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
Both type and interface can describe object shapes, but they differ in key ways:
- Interface is always extendable — it supports declaration merging and
extends. - Type alias cannot be reopened to add new properties once defined.
If you want to prevent an object's shape from being extended elsewhere in the project,
use type. If you want consumers to be able to extend it, use interface.
For most cases, either works fine. A common convention is to use interface for
object shapes and type for unions, intersections, and utility types.
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.
- 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.
- 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
Let's break this into three steps:
Step 1 — Define the callback type:
type SumCallback = (result: number) => void
This says: SumCallback is a function that receives one number parameter and returns nothing (void).
Step 2 — Use it as a parameter type in another function:
function calculateSum(a: number, b: number, callback: SumCallback) {
const sum = a + b
callback(sum) // call the callback and pass sum to it
}
The third parameter callback must be a function matching the SumCallback shape.
Inside the function, we call callback(sum) — passing the computed sum as the result.
Step 3 — Call the function and pass a callback:
calculateSum(5, 7, (result) => {
console.log('Sum is: ' + result) // "Sum is: 12"
})
Here result receives the value of sum (which is 12).
TypeScript automatically knows result is a number because of the SumCallback type.
About parameter names: The name result in the type definition is just a label for readability.
You can use any name when you actually write the callback — only the type (number) and position matter:
// All of these are valid — same type, different parameter names:
calculateSum(5, 7, (result) => { console.log(result) })
calculateSum(5, 7, (sum) => { console.log(sum) })
calculateSum(5, 7, (x) => { console.log(x) })
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')
})
Here the parent function calculateSum calls the callback with one argument (sum),
but our callback function doesn't use it — and that's fine.
Callbacks can safely ignore parameters they don't need.
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. That means the implementation of the
main function is passing all the array item properties to the callback function and this is our choice
to define the callback function to read them or not.
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.toUpperCase()
// Error: Property 'toUpperCase' does not exist on type 'void'.
}
initApp(getName)
We get a TypeScript error because we tried to use the return value of a void-returning callback.
() => data.push(5) returns the new array length, but it is still a valid callback for initApp — because void means the caller promises not to use the return value. TypeScript ensures you keep that promise:
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
Its 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
TypeScript's structural type system says: if things have the expected shape or behavior, they are compatible and can be substituted. In other words, TypeScript's type system isn't nominal.
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 && 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. Otherwise, TypeScript does not allow us to call anything on unknown.
On the other hand,
You can call anything on any without TypeScript complaining
because any turns off (disables) TypeScript's type checking. That is the unknown and any difference.
Example:
function isPositiveNumber(input: unknown): boolean {
return typeof input === 'number' && input > 0
}
// Sample usage
isPositiveNumber(10) // true
isPositiveNumber(-5) // false
isPositiveNumber('5') // false
As we learned, number, string, boolean, undefined, null, bigint, and symbol are 7 primitive types. We also learned about unknown.
Now let's learn about the object type.
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
Function overloads let you define multiple call signatures for one function. Each overload signature describes a specific combination of parameters and return type, while the implementation signature handles all cases.
function format(value: string): string
function format(value: number): string
function format(value: string | number): string {
if (typeof value === 'string') {
return value.trim()
}
return value.toFixed(2)
}
format('hello') // string overload
format(3.14159) // number overload
The first two lines are the overload signatures — they describe what callers see. The third is the implementation signature — it must be compatible with all overloads but is not directly callable.
format(true) // Error: No overload matches this call.
A more practical example — fetching a user by ID or by username:
function getUser(id: number): User
function getUser(username: string): User
function getUser(idOrUsername: number | string): User {
if (typeof idOrUsername === 'number') {
return db.findById(idOrUsername)
}
return db.findByUsername(idOrUsername)
}
When to use overloads vs. union types: Use overloads when different parameter types produce different return types. If the return type stays the same, a simple union parameter is usually cleaner.
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"
In this specific case, we can 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 a built-in API called 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])
When TypeScript reaches the call duplicate(['abc', 123]), it inspects the argument to determine T. The array contains a string ('abc') and a number (123), so TypeScript infers T as the union string | number. It then substitutes this union into every position where T appears — the parameter type and the return type — producing the following resolved signature:
function duplicate<string | number>(items: (string | number)[]): (string | number)[]
This inference happens automatically at each call site. If you called duplicate([true, 42]), TypeScript would infer T as boolean | number instead. The generic definition stays the same; only the inferred type changes based on the argument you pass.
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. It works the same as T[].
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, interfaces, 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 Required<Type>.
It creates a new type where all properties become optional.
interface User {
id: number
name: string
email: string
}
type PartialUser = Partial<User>
// equivalent to:
// {
// id?: number
// name?: string
// email?: string
// }
The most common use case is update functions where you only send the fields that changed:
function updateUser(id: number, updates: Partial<User>) {
// updates can have any combination of User properties
db.update(id, updates)
}
updateUser(1, { name: 'Sam' }) // only update name
updateUser(1, { name: 'Sam', email: '[email protected]' }) // update two fields
Without Partial, you'd need to pass all properties every time — even the ones that didn't change.
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 checks that the Keys you pass satisfy
the constraint keyof Product, meaning they must be valid keys of the source type.
Pick stays in sync with the original type automatically. If you change a property type in the
source interface, all Pick-derived types reflect the change:
interface User {
id: number
name: string
email: string
}
type UserPreview = Pick<User, 'id' | 'name'>
// { id: number; name: string }
// Later, if User.id changes to string:
// type UserPreview automatically becomes { id: string; name: string }
Omit<Type, Keys>
The Omit<Type, Keys> utility type is the opposite of Pick<Type, Keys>.
Instead of selecting which properties to keep, you specify which properties to remove.
interface Product {
id: number
name: string
price: number
description: string
inStock: boolean
}
type CreateProductInput = Omit<Product, 'id'>
// { name: string; price: number; description: string; inStock: boolean }
A common use case: when creating a new record, the id is auto-generated by the database,
so you omit it from the input type.
You can omit multiple properties with a union:
type ProductSummary = Omit<Product, 'description' | 'inStock'>
// { id: number; name: string; price: number }
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>
Parameters<T>
Parameters<T> extracts the parameter types of a function type as a tuple.
function createUser(name: string, age: number, active: boolean) {
return { name, age, active }
}
type CreateUserParams = Parameters<typeof createUser>
// [string, number, boolean]
This is useful when you need to match another function's parameters without repeating the types manually:
function logAndCreate(...args: Parameters<typeof createUser>) {
console.log('Creating user:', args)
return createUser(...args)
}
NonNullable<T>
NonNullable<T> removes null and undefined from a type.
type MaybeString = string | null | undefined
type DefiniteString = NonNullable<MaybeString>
// string
A practical use case — ensuring a value exists before processing:
function processItems(items: NonNullable<string | null>[]) {
items.forEach((item) => console.log(item.toUpperCase()))
}
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
If 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
}
Response — The Fetch API type
When you call fetch(), it returns Promise<Response>. The Response type
is a built-in Web API type (not something you define yourself) that TypeScript
already knows about. It represents the HTTP response from a server.
const response = await fetch('/api/users')
// response is typed as Response — you get autocomplete for all its properties
Key properties and methods on Response:
response.ok // boolean — true if status is 200–299
response.status // number — HTTP status code (200, 404, 500, etc.)
response.statusText // string — e.g. "OK", "Not Found"
response.headers // Headers — the response headers
response.json() // Promise<any> — parse body as JSON. Must be awaited: `const data = await response.json()`
response.text() // Promise<string> — parse body as plain text. Must be awaited: `const text = await response.text()`
response.blob() // Promise<Blob> — parse body as binary data (images, files). Must be awaited: `const file = await response.blob()`
A common pattern is checking response.ok before parsing:
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error(`Request failed: ${response.statusText}`)
}
const data = await response.json() // only parse if response was successful
Note that response.json() returns Promise<any>, which means the result
is untyped. That's why you'll often annotate it yourself:
interface User {
id: number
name: string
}
const data: User[] = await response.json() // now TypeScript knows the shape
Promise<T> — Typing Promises
A Promise in TypeScript is generic: Promise<T> where T is the type
of the value the promise resolves to. If you don't specify T, TypeScript
infers it as Promise<unknown>.
// A promise that resolves to a string
const greeting: Promise<string> = new Promise((resolve) => {
resolve('Hello, World!')
})
// A promise that resolves to a number
const count: Promise<number> = new Promise((resolve) => {
setTimeout(() => resolve(42), 1000)
})
You'll often see Promise<T> as the return type of functions:
function fetchName(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => resolve('Hamed'), 500)
})
}
Promise with custom types
interface User {
id: number
name: string
email: string
}
function getUser(): Promise<User> {
return new Promise((resolve) => {
resolve({ id: 1, name: 'Hamed', email: '[email protected]' })
})
}
TypeScript ensures the resolved value matches the type. Resolving with a wrong shape causes a compile error:
function getUser(): Promise<User> {
return new Promise((resolve) => {
// Error: Property 'email' is missing in type '{ id: number; name: string; }'
resolve({ id: 1, name: 'Hamed' })
})
}
Promise with .then() chaining
When you chain .then(), TypeScript tracks the return type through each step:
function fetchUserId(): Promise<number> {
return Promise.resolve(42)
}
// Each .then() changes the resolved type
fetchUserId()
.then((id) => `user-${id}`) // id is number → returns string
.then((label) => label.toUpperCase()) // label is string → returns string
.then((result) => console.log(result)) // result is string → returns void
A real-world pattern — making an HTTP call and chaining .then():
function deleteUser(userId: string): Promise<string> {
return fetch(`/api/users/${userId}`, { method: 'DELETE' }).then((res) => {
if (!res.ok) throw new Error('Failed to delete user')
return res.text() // res.text() returns Promise<string>
})
}
In the chain above, res is typed as Response (the Fetch API type),
and .text() returns Promise<string>, making the entire function
return Promise<string>.
async / await — Typing async functions
An async function always returns a Promise. TypeScript infers the
Promise<T> wrapper automatically, so you annotate just the inner type:
// TypeScript infers the return type as Promise<string>
async function greet() {
return 'Hello!'
}
// Explicit annotation (both forms are equivalent)
async function greet(): Promise<string> {
return 'Hello!'
}
await unwraps the Promise
Inside an async function, await unwraps Promise<T> to T:
async function loadUser() {
const response = await fetch('/api/user/1') // response: Response
const data = await response.json() // data: any (json() returns Promise<any>)
return data
}
Since response.json() returns Promise<any>, the result is untyped.
You can fix this by annotating the variable:
interface User {
id: number
name: string
email: string
}
async function loadUser(): Promise<User> {
const response = await fetch('/api/user/1')
const data: User = await response.json() // now data is typed as User
return data
}
Generic async function — real-world API utility
A powerful pattern is combining async, generics, and Promise<T>
into a reusable fetch utility:
async function fetchData<T>(endpoint: string): Promise<T> {
const response = await fetch(`http://localhost:3001${endpoint}`)
if (!response.ok) {
throw new Error(`Failed to fetch ${endpoint}: ${response.statusText}`)
}
const data: T = await response.json()
return data
}
The caller specifies T at the call site, and TypeScript enforces it:
interface Lead {
id: string
name: string
dealStatus: 'won' | 'in-progress' | 'lost'
}
// T is Lead[] → returns Promise<Lead[]>
const leads = await fetchData<Lead[]>('/api/leads')
leads[0].name // ✅ TypeScript knows this is a string
leads[0].foo // ❌ Error: Property 'foo' does not exist on type 'Lead'
This is exactly the pattern used when integrating React Query:
import { useQuery } from '@tanstack/react-query'
const { data } = useQuery<Lead[]>({
queryKey: ['leads'],
queryFn: () => fetchData<Lead[]>('/api/leads'),
})
// data is Lead[] | undefined
Why | undefined?
useQuery is a React hook, not an await. It returns synchronously on every render — it does not pause and wait for the fetch to finish the way await does.
Here is what happens across renders:
- First render: The component mounts,
useQuerystarts the fetch in the background, and immediately returns. The fetch has not resolved yet, sodataisundefined. - After fetch resolves: React Query triggers a re-render. Now
dataisLead[].
TypeScript reflects both possible states with the union Lead[] | undefined. You must check for undefined before using data:
if (!data) return <p>Loading...</p>
data[0].name // ✅ TypeScript now knows data is Lead[], not undefined
This is different from useSuspenseQuery, which guarantees data is always resolved — so TypeScript types it as Lead[] with no | undefined.
import { useSuspenseQuery } from '@tanstack/react-query'
interface Lead {
id: string
name: string
dealStatus: 'won' | 'in-progress' | 'lost'
}
function LeadList() {
const { data } = useSuspenseQuery<Lead[]>({
queryKey: ['leads'],
queryFn: () => fetchData<Lead[]>('/api/leads'),
})
// data is Lead[] — TypeScript does not include | undefined here
return (
<ul>
{data.map((lead) => (
<li key={lead.id}>{lead.name} — {lead.dealStatus}</li>
))}
</ul>
)
}
How this actually works — it is not a freeze:
The component does not pause or wait in place. What happens is React throws the component away mid-render and tries again later. The sequence:
- React starts rendering
LeadList useSuspenseQuerysees the fetch is not yet complete — it throws a Promise internally (this is the Suspense protocol)- React catches that thrown Promise at the nearest
<Suspense>boundary above this component - React abandons the current render of
LeadListand renders the fallback instead (e.g.<p>Loading...</p>) - When the Promise resolves (data arrives), React re-renders
LeadListfrom the top - This time
useSuspenseQueryfinds data in cache and returns normally withdata = Lead[] - The rest of the component runs with
dataalways beingLead[]—undefinedis impossible at this point
You must provide a <Suspense> boundary yourself. useSuspenseQuery relies on one being present above the component in the tree. If there is no <Suspense> wrapper, React has nowhere to catch the thrown Promise and the app will crash.
import { Suspense } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
// ✅ Correct — LeadList is wrapped in a Suspense boundary
function App() {
return (
<Suspense fallback={<p>Loading leads...</p>}>
<LeadList />
</Suspense>
)
}
function LeadList() {
const { data } = useSuspenseQuery<Lead[]>({
queryKey: ['leads'],
queryFn: () => fetchData<Lead[]>('/api/leads'),
})
// data is Lead[] — guaranteed, because this function body
// only runs after React has confirmed data is in the cache.
return <ul>{data.map((lead) => <li key={lead.id}>{lead.name}</li>)}</ul>
}
The fallback prop on <Suspense> is what gets rendered while LeadList is waiting. Once data arrives, React automatically removes the fallback and renders LeadList with real data.
So the contract is:
useSuspenseQuery(inside the component): throws while loading, returns resolved data when ready<Suspense fallback=...>(in the parent): catches the throw, shows the fallback until the component is ready
Both sides are required. You write one, and the other is handled by React and React Query.
So the code after the hook only ever runs when data is ready. React never lets LeadList render partially with missing data. That is why TypeScript can type data as Lead[] without | undefined — the undefined state simply never reaches the return statement.
With useQuery, there is no throwing. The hook returns immediately on every render, including the first one before the fetch completes — so data genuinely can be undefined and TypeScript correctly reflects that with Lead[] | undefined.
Utility types with | null — Nullable utility types
You can combine any utility type with | null (or | undefined)
to represent values that might be absent:
interface User {
id: string
name: string
email: string
role: string
}
interface Lead {
id: string
name: string
dealStatus: 'won' | 'in-progress' | 'lost'
salesperson: Pick<User, 'id' | 'name'> | null
}
Here salesperson is either an object with id and name, or null
(e.g. when a user is deleted and the server sets the field to null).
This requires careful handling when accessing properties:
function getSalespersonName(lead: Lead): string {
// ❌ Error: 'lead.salesperson' is possibly 'null'
return lead.salesperson.name
// ✅ Optional chaining + nullish coalescing
return lead.salesperson?.name ?? 'Unassigned'
}
This pattern works with any utility type:
type MaybeAdmin = Partial<User> | null
type MaybePreview = Pick<User, 'id' | 'name'> | null
type MaybeUserList = Omit<User, 'email'>[] | null
Map<K, V> — Practical usage patterns
The Map generic type is covered above in the generics section.
Here we look at practical patterns for using Map with its methods.
Building a count map
A common pattern is counting occurrences from an array:
interface Lead {
id: string
name: string
salesperson: { id: string; name: string } | null
}
const leads: Lead[] = [
{ id: '1', name: 'Acme Corp', salesperson: { id: 'u1', name: 'Alice' } },
{ id: '2', name: 'Globex', salesperson: { id: 'u2', name: 'Bob' } },
{ id: '3', name: 'Initech', salesperson: { id: 'u1', name: 'Alice' } },
{ id: '4', name: 'Umbrella', salesperson: null },
]
// Count how many leads each salesperson has
const dealCountByUser = new Map<string, number>()
leads.forEach((lead) => {
if (lead.salesperson) {
const count = dealCountByUser.get(lead.salesperson.id) ?? 0
dealCountByUser.set(lead.salesperson.id, count + 1)
}
})
dealCountByUser.get('u1') // 2
dealCountByUser.get('u2') // 1
dealCountByUser.get('u3') // undefined (not in the map)
Map methods and their return types
const scores = new Map<string, number>()
scores.set('Alice', 95) // returns Map<string, number> (the map itself, for chaining)
scores.get('Alice') // returns number | undefined
scores.has('Alice') // returns boolean
scores.delete('Alice') // returns boolean (true if key existed)
scores.size // number
scores.clear() // returns void
Note that .get() returns V | undefined (not just V), because the key
might not exist. This is why the nullish coalescing operator ?? is so useful
with Map:
const count = scores.get('Bob') ?? 0 // number, defaults to 0 if not found
Without ??, you'd need a manual check:
const raw = scores.get('Bob') // number | undefined
const count = raw !== undefined ? raw : 0