Typescript tutorial
Introduction
TypeScript is a superset of JavaScript that adds types. Any valid JavaScript code is valid TypeScript code.
JavaScript is "dynamically" typed language, meaning variable types are determined at runtime. The environment (e.g., browser or Node) doesn't know variable types until execution.
Look at an example:
function add(a, b) {
return a + b
}
JavaScript does not know the data type of the a
and b
parameters.
You may assume they are numbers and the function returns the sum of both.
However, that's an incorrect assumption. The +
operator works also with strings, so add("abc", "def")
returns "abcdef".
This seems to be "versatile" for small projects, but as they grow and requirements become more complex, type safety becomes essential. Type safety provides predictability and other benefits. TypeScript adds types to JavaScript, making it more useful.
Here's the revised function in TypeScript, ensuring it accepts only number parameters and returns number:
function add(a: number, b: number): number {
return a + b
}
We've added type annotations to a
and b
,
instructing the TypeScript compiler to accept only number
parameters for this function.
Now, if you try to use this function with string parameters, the TypeScript compiler will throw an error:
add('abc', 'def')
// Error: Argument of type 'string' is not assignable
// to parameter of type 'number'.
You see how TypeScript compiler errors help reduce bugs in your code.
TypeScript compiles down to JavaScript
TypeScript code needs to be compiled to plain JavaScript for browser execution. This means the TypeScript types you use will disappear from the compiled JavaScript. They are only useful at compile time to prevent runtime errors with your JavaScript code.
To see compiled JavaScript from TypeScript, use the official TS Playground. Write TypeScript on the left and see the compiled JavaScript on the right.
Types are not accessible at runtime in JavaScript.
Basic primitive types and syntax
When a parameter is typed, we provide the TypeScript compiler with a specific data type. TypeScript ensures the function is called only with arguments that match this type.
One of the primitive data type is number
(starting with lowercase n).
You can specify a parameter's type as number
using the : number
syntax.
The :
(colon) followed by a type indicates the data type.
A number
type represents any JavaScript number, including positive, negative, zero, and decimal numbers (e.g., 4.94).
function double(value: number): number {
return value * 2
}
console.log(double(3)) // 6
console.log(double(4.5)) // 9
Another primitive data type is string
. To specify a function parameter as a string, use the : string
syntax:
// before applying type
function sayLouder(text) {
return text.toUpperCase()
}
// after applying type
function sayLouder(text: string): string {
return text.toUpperCase()
}
TypeScript ensures sayLouder
is called only with a string argument. The following examples will throw errors:
sayLouder(123);
Argument of type 'number' is not assignable to parameter of type 'string'.
sayLouder(true);
Argument of type 'boolean' is not assignable to parameter of type 'string'.
An empty string is still a string, so sayLouder("")
is valid.
The boolean
type is a primitive type and used for true
or false
values:
function flipSwitch(value: boolean): boolean {
return !value // converts true to false, and false to true
}
console.log(flipSwitch(true)) // false
console.log(flipSwitch(false)) // true
any type
If you forget to type a variable, it implicitly has type any
. Typescript type-checking will show error.
And you have to give it a type.
TypeScript's any
type disables type-checking, allowing a variable to be of any type.
Avoid using the any
type, as it negates TypeScript's benefits and makes your code error-prone.
A variable with the any
type can hold any value, making
TypeScript unable to distinguish if it's a string, number, or any other type.
example:
function add(a: any, b: any): any {
return a + b
}
console.log(add('abc', 'def')) // "abcdef"
console.log(add(10, 5)) // 15
The add
function accepts any data type because a
and b
are any
,
negating TypeScript's benefits and potentially causing unexpected bugs.
Avoid using any
as it disables TypeScript's type-checking.
Tsconfig.json
Every TypeScript project has a tsconfig.json
file.
It configures TypeScript for your project,
instructing the compiler on which features to enable
or disable and which files to process or ignore.
TypeScript use cases
1) New project
When starting a new project, writing TypeScript from the start is ideal. Most frameworks and starter projects now include a TypeScript option, making setup easier. Writing types is simpler in a new project since you can integrate them as you code.
2) Convert an existing project from JS to TS
Upgrading an existing JavaScript project to TypeScript can be done
progressively, one file at a time. Rename .js
files to .ts
and add types.
However, older projects with legacy code may be harder to convert.
3) Library typings
Another use case for TypeScript is to define types for a library you've developed,
enhancing the experience for other developers. For instance, if you create a carousel JavaScript library,
you can generate typings and publish them on npm
. This provides autocomplete and
ensures correct usage through TypeScript's code checks.
TypeScript strictness
The tsconfig.json
file lets you set TypeScript's strictness.
There are many tsconfig options that you may encounter. Here's a link to the documentation.
For simplicity look at here:
{
"compilerOptions": {
"target": "es2022",
"esModuleInterop": true,
"allowJs": true,
"noUncheckedIndexedAccess": true,
"strict": true
}
}
The key setting is "strict": true,
which enables all strict mode checks. Without it, TypeScript wouldn't be as useful.
target
: Specifies the JavaScript version to transpile to.
For older browsers, use versions like es2021
or es2022
. For the latest browsers, use esnext
.
esModuleInterop
: Fixes issues with older libraries using require
instead of import
.
allowJs
: Allows importing JavaScript files, useful when not all files are .ts
.
noUncheckedIndexedAccess
: Warns when accessing an array item that may be undefined
.
"include": ["src"]
instructs the TypeScript compiler to process all files within the src
folder.
"exclude": ["node_modules", "dist"]
instructs the TypeScript compiler to ignore the
node_modules
and dist directories, ensuring it doesn't process any files within these folders.
TypeScript directive comments
A directive comment gives instructions to the compiler.
For example, If a piece of TypeScript code has an error—like an implicit any
type
or incorrect type usage—you can instruct the compiler
to ignore it with // @ts-ignore
. For example:
function greet(name: string) {
return `Hello, ${name}`
}
// @ts-ignore
greet(42) // Incorrectly calling with the wrong data type
This comment // @ts-ignore
disables type-checking for the following line.
It may be acceptable to have a few // @ts-ignore
comments in your code,
as long as they're not overused.
You can disable TypeScript's type checking for the entire file
by placing // @ts-nocheck
at the top of the file:
// @ts-no-check
// The entire file is NOT checked by TypeScript
It can be helpful if you're including a small third-party script in your source code that you don't want to type-check.
@ts-expect-error
instructs TypeScript to expect an error,
useful in unit tests when you write incorrect code to assert specific errors.
If used on valid TypeScript code, it will produce an error because the directive is unused.
function double(value: number) {
return value * 2
}
// @ts-expect-error
double('a')
The directive comment silences the TypeScript compiler
when we incorrectly use the double
function.
Silencing TypeScript errors is generally a bad idea, but sometimes necessary in large projects for quick prototyping or deferring type handling.
TypeScript ESLint
TypeScript ESLint is a tool that helps find and fix common code quality issues in TypeScript. Static code analysis examines your code without executing it.
Installing TypeScript ESLint
To install Typescript ESLint check out the official Getting Started guide
If your project doesn't include TypeScript ESLint, open a terminal and install the following packages:
npm install --save-dev
@typescript-eslint/parser
@typescript-eslint/eslint-plugin
eslint
typescript
Then, create a .eslintrc.cjs
file at the root of your project with the following code:
/* eslint-env node */
module.exports = {
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
root: true,
}
Then, run the following command to check your code:
npx eslint .
If you're using VSCode, install the ESLint extension to see errors directly in the editor.
Typescript ESLint Rules
TypeScript ESLint offers three useful rules:
@typescript-eslint/no-explicit-any
@typescript-eslint/ban-ts-comment
@typescript-eslint/ban-types
@typescript-eslint
is the name of the package and
the part after the /
is the name of the rule.
@typescript-eslint/no-explicit-any
Enabling the strict
option in tsconfig.json
makes TypeScript warn you when a variable implicitly has the type any
;
for example:
function sum(a, b) {
// Parameters implicitly have 'any' type.
return a + b
}
It's good that the compiler warns us to provide types for a
and b
.
However, using the any
type explicitly will silence those errors:
function sum(a: any, b: any): any {
return a + b
}
While the code above is valid for the TypeScript compiler, it's not good practice.
Using any
disables type checking, essentially bypassing TypeScript.
This is where @typescript-eslint/no-explicit-any
comes in handy.
It disallows explicit uses of the any
type, causing the code above to produce an error:
function sum(a: any, b: any): any {
return a + b // Error: Unexpected any. Specify a different type.
// (@typescript-eslint/no-explicit-any)
}
That's great because it forces us to provide an appropriate type!
The TypeScript compiler's strict
flag helps disable implicit any
types,
while the TypeScript ESLint plugin helps disable explicit any
types.
@typescript-eslint/ban-ts-comment
disables directive comments,
enhancing your TypeScript experience. When enabled,
using a directive comment will cause an error, forcing you to resolve the issue.
To bypass this ESLint rule, add the following to your .eslintrc.cjs
file:
/* eslint-env node */
module.exports = {
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
root: true,
rules: {
"@typescript-eslint/ban-ts-comment": "off",
},
};
The @typescript-eslint/ban-types
rule enforces that you use : string
instead of : String
, : number
instead of : Number
, : boolean
instead of : Boolean
, etc.
Why avoid using String
, Number
, and Boolean
as types (with capital S
, N
, and B
)?
The explanation is technical, but you should avoid them.
Using : string
refers to the primitive data type, while : String
refers to the global JavaScript String object, which you shouldn't use it to make a string instance.
Since you likely won't work with global String
, Number
, and Boolean
objects,
this ESLint rule ensures you use the correct types.
Primitive types
JavaScript has seven primitive types:
-
string
-
number
-
boolean
-
undefined
-
null
-
bigint
-
symbol
TypeScript provides a type for each of these JavaScript primitive types. Example:
function doSomething(firstParam: string, secondParam: null) {
// function implementation
}
null vs. undefined
it's generally recommended to use undefined
to represent a
variable that has not been assigned a value yet
and null
to represent a deliberate absence of value.
In another mean:
null
is an explicitly assignment value to a variable to indicate that it has no value.
It is an object.
undefined
is a type itself and means
a variable has been declared but not yet assigned a value (not initialized).
let a // a is declared but not initialized, so it is undefined
let b = null // b is explicitly set to null
console.log(a) // Output: undefined
console.log(b) // Output: null
A function that does not return a value explicitly returns undefined
.
While null
can be used to indicate the intentional absence of an object value.
undefined as type and value
- type:
undefined
can be used as a type to indicate that a variable might not have been initialized or is expected to hold the valueundefined
. - value:
undefined
is the value assigned to variables that have been declared but not initialized.
In Union type section there is a good example shows how undefined
is used in two roles.
Annotating variable declarations
We can annotate variable declarations using the same syntax as function parameters.
The type annotation comes before the assignment =
sign.
const username: string = 'sam'
const fullName: string = ''
const isActive: boolean = true
let age: number = 20
age += 1
console.log(age) // 21
let config: null = null
let data: undefined = undefined
Of course, the types must match the values provided. If they don't, you'll get an error:
const isActive: string = true
// Error: Type 'boolean' is not assignable to type 'string'.
For variables that can be reassigned, TypeScript ensures the type is respected.
If a variable is typed as a number
, it must remain a number
when reassigned:
let sum: number = 0
sum += 10
console.log(sum) // 10
sum = ''
// Error: Type 'string' is not assignable to type 'number'.
Type inference
All the types we provided above could have been omitted, and you would still get the benefits of TypeScript. This is because the TypeScript compiler can automatically determine the data type of a variable based on the assigned value, a feature called type inference.
For example, when you write const username = "sam"
,
TypeScript deduces that username is of type string
,
so specifying the type is unnecessary.
let sum = 0; // sum is expected to be a number (type inference)
sum += 10;
console.log(sum); // 10
sum = "";
Type 'string' is not assignable to type 'number'.
You see that Typescript will still catch the error on the last line.
Inferred types work in many scenarios. For example, if you sum or multiply two numbers, the compiler knows the result is a number. This also enhances editor autocomplete. That means, the editor is able to use that information from the compiler to only show you the methods you can call on a number. Additionally, it provides useful autocomplete, showing the required arguments when calling a function.
Union types
We can instruct TypeScript to expect
either a number
or a string
using the pipe character |
, like this:
let value: number | string
This is called a union type. Union types can be used wherever TypeScript expects a type, including function parameter declarations. It specifies that a variable can be one of the two specified types. A union type combines different types into a single type that can represent any of them.
For example, number | string
combines number
and string
,
meaning the parameter can be either a number or a string.
let value: number | string | undefined = 0
The code above declares a variable with a
union type of string
, number
, and undefined
, initializing it to 0
.
Read this union type as "number or string or undefined".
This means the variable can be a string
, number
, or undefined
.
Since it is defined with let
,
although the variable value
starts as a number
, but can later be
reassigned to a string
or undefined
.
The variable must always be one of these types.
function getUserPlan(hasPaid: boolean | undefined) {
if (hasPaid === true) {
return 'Pro'
}
return 'Trial'
}
// Sample usage (do not modify)
console.log(getUserPlan(true)) // "Pro"
console.log(getUserPlan(false)) // "Trial"
console.log(getUserPlan(undefined)) // "Trial"
The undefined
type here allows the function to
accept a third possible state besides true
or false
,
which can be useful in scenarios where
the payment status is not determined.
Here, we used undefined
in two roles:
- value:
undefined
is a primitive value and provides a way to handle scenarios that indicates the absence of a value or not yet set. - type:
undefined
can be used to explicitly declare that a variable can hold the valueundefined
. Usingundefined
as a type in union types allows for more flexible and robust function signatures and variable declarations.
Type narrowing
Let's see an incorrect implementation of a function in Typescript:
function orderSummary(customerName: string, count: number | string): string {
return `${customerName} ordered ${count.toLowerCase()} item(s)`
// Error: Property 'toLowerCase' does not exist on type 'string | number'.
// Property 'toLowerCase' does not exist on type 'number'.
}
toLowerCase()
is valid for strings but not for numbers.
Since count
is number | string
, you can only use methods
common to both types. TypeScript prevents calling toLowerCase()
on a number
,
which avoids runtime errors.
The solution is to call toLowerCase()
only if count is a string.
Here's the correct implementation of orderSummary
:
function orderSummary(customerName: string, count: number | string): string {
if (typeof count === 'string') {
count = count.toLowerCase()
}
return `${customerName} ordered ${count} item(s)`
}
Type narrowing is when TypeScript deduces a more specific type based on your code. For example, TypeScript doesn't complain about this:
if (typeof count === 'string') {
count = count.toLowerCase()
}
TypeScript is smart enough to know that inside the if
block,
count
is a string
, so you can safely use string methods like .toLowerCase()
.
Remember, TypeScript types are not available at runtime and they disappear.
However, at runtime, you can check a variable's type using JavaScript's typeof
operator,
which returns a string like "string", "number", "boolean", etc.
The type alias
You can create a custom name for a type using a type
alias.
This will come in handy when your the same type is used in
multiple places (repeated) or the types become more complex.
For example, to create an alias for the Union type string | number
:
type StringOrNumber = string | number
Use UpperCamelCase (also known as PascalCase) as a naming convention for type aliases and lowerCamelCase for variables.
Example:
type StringOrNumber = string | number
function convertNumber(value: StringOrNumber): number {
if (typeof value === 'number') {
return value
}
return Number.parseInt(value, 10)
}
You cannot change a type alias once defined; you can only define and assign it once.
Array type literal
The []
is called the array type literal.
When used after a TypeScript type, it creates a new type representing
an array containing elements of that type.
const grades: number[] = [10, 15, 18, 8, 19]
const answers: boolean[] = [true, false, false, true]
function formatNames(names: string[]) {
//
}
formatNames(['Sam', 'Alex'])
Note: If you use []
as a type (without any other type), it will denote an empty array.
The typescript and consequently the editor understands (Type Inference) that for example;
when you have an array of strings (string[]
) and you call
.forEach(item)
on it, the item variable will be of type string
.
This is because .forEach()
iterates over each string
in the array.
const names: string[] = ['Alex', 'Sam']
names.forEach((name) => {
name.toFixed(2) // Error: Property 'toFixed' does not exist on type 'string'.
})
The name.toFixed(2)
line causes an error
because toFixed
is a method for numbers, not strings.
If an array is expected to contain more than one type, you can combine Union types with array type literal. For example:
const values: (number | string)[] = [10, 'Sam', 'Alex', 15, 19, 20, '']
The parentheses around number | string
is required.
With type alias:
type GradeOrNameArray = (number | string)[]
const values: GradeOrNameArray = [10, 'Sam', 'Alex', 15, 19, 20, '']
// or a preferred multi purpose type alias like
type GradeOrName = number | string
const values: GradeOrName[] = [10, 'Sam', 'Alex', 15, 19, 20, '']
Tuples
A tuple is a fixed-size, ordered array with known types for each element.
const point: [number, number] = [15.10419, 91.14034]
// or
type Point = [number, number];
const p1: Point = [15.10419, 91.14034];
const p2: Point = [18.85239, -14.89517);
Remember that the array's size is dynamic. On the other hand, tuples are fixed-sized.
Tuples can contain different types. For example, some libraries return database rows in array format.
type UserRow = [number, string, number, boolean]
const row: UserRow = [3, 'Sam', 29, true]
TypeScript knows the types of each individual element.
type UserRow = [number, string, number, boolean];
const row: UserRow = [3, "Sam", 29, true];
row[2]++; // increment the age
console.log(row[2]); // 30
row[3] = false; // change isAdmin to false
row[3] = "no";
Type 'string' is not assignable to type 'boolean'.
You can also easily destructure items from tuples and TypeScript will know the type of each element (Type inference).
Note: []
is a tuple of 0 elements. Hence if you use it as a type for a variable, Typescript,
will prevent us from being able to add elements to it.
Example of tuple type:
function doSomething(first: number[] | string[], second: [number, string]) {}
// Sample usage (do not modify)
doSomething([1, 2, 3], [1, 'test'])
doSomething(['high', 'five'], [2, 'hello'])
String literal type
TypeScript checks the value (not the type) when the type is String literal. This means TypeScript expects a specific string value, unlike the usual behavior of checking only the type.
Let's store the value of a traffic light.
With the TrafficLight
type,
TypeScript throws an error when an invalid value is assigned,
catching typos and saving debugging time.
type TrafficLight = 'red' | 'orange' | 'green'
let trafficLight: TrafficLight = 'red'
trafficLight = 'orange'
trafficLight = 'green'
trafficLight = 'gren'
// Error: Type '"gren"' is not assignable to type 'TrafficLight'.
// Did you mean '"green"'?
trafficLight = 'rice'
// Error: Type '"rice"' is not assignable to type 'TrafficLight'.
There’s no difference between using double quotes,
single quotes, or template strings.
The string "red"
is equivalent to 'red'
.
Example of Union of two String literal type:
function animate(speed: 'slow' | 'fast') {}
animate('slow')
animate('fast')
animate('medium')
// Error: Argument of type '"medium"' is not assignable
// to parameter of type '"slow" | "fast"'.
The "slow" | "fast"
type enforces the restriction
that the animate
function can only be called with one of these two values.
let and const declarations
When you define a const
variable, you can't reassign it later.
const message = 'Welcome'
Since message
can't change,
TypeScript infers its type as 'Welcome'
a string literal type,
more specific than string
type. This is equivalent to (which is not necessary):
const message: 'Welcome' = 'Welcome'
Although with let
, values can be reassigned,
this is not possible when the type is a string literal type.
let message: 'Welcome' = 'Welcome'
message = 'Morning'
// Error: Type '"Morning"' is not assignable to type '"Welcome"'.
Hence, these two are equivalent:
const message = 'Welcome'
let message: 'Welcome' = 'Welcome'
Number literal types
The same concept applies to numerical values.
Here's an example defining a StatusCode type,
expecting its value to be either 200
, 401
, or 404
:
type StatusCode = 200 | 401 | 404
let code: StatusCode = 200
code = 404
Example:
function animate(speed: 'slow' | 'fast' | number) {
// implementation here (leave this empty)
}
animate(200)
animate(300)
animate('slow')
animate('fast')
In fact, the boolean
type itself is a literal type of two values, true
and false
:
// You cannot write this because `boolean` is already defined by TypeScript.
// However, this is how it's defined in TypeScript:
type boolean = true | false
This means true
and false
are also TypeScript types.
Although you won't often need to use them,
suppose a function always accepts an argument that is true
.
In that case, you can use the type true
:
function doSomething(value: true) {
// function implementation
}
doSomething(true)
doSomething(false)
// Error: Argument of type 'false' is not assignable to parameter of type 'true'.
Function return type
TypeScript can often, in many instances, automatically infer the return type of a function. For example, it can infer that the following function returns a string:
function concat(x: string, y: string) {
return x + y
}
This also applies to functions with if/else
statements
or when more than one return type is expected.
function getDetails(name: string, isAdmin: boolean) {
if (isAdmin) {
return `Details for ${name}`
}
return false
}
In the above example, TypeScript can infer that the function getDetails
returns either a string
or a boolean
, (Union type string | false
) based on the isAdmin
parameter.
For more complex functions, the type can't always be inferred and we need to annotate the return type of a function explicitly like:
function sum(a: number, b: number): number {
return a + b
}
For arrow functions, you would place before the arrow =>
:
const concat = (x: string, y: string): string => {
return x + y
}
// Or
const concat = (x: string, y: string): string => x + y
If we make a mistake, TypeScript catches the bugs
and we have to adjust the return type or the logic (like return empty string ''
instead of false
):
function getDetails(name: string, isAdmin: boolean): string {
if (isAdmin) {
return `Details for ${name}`
}
return false // Error: Type 'boolean' is not assignable to type 'string'.
}
More examples:
type Point = [number, number]
function getCoords(location: string): Point {
if (location === 'start') {
return [1, 2]
}
if (location === 'end') {
return [14, 23]
}
return [0, 0]
}
function getEnvType(env: string): 'dev' | 'prod' {
if (env === 'development') return 'dev'
if (env === 'production') return 'prod'
return 'dev' // default to "dev"
}
The void type
TypeScript infers the void
type for functions that do not return a value.
function logName(name: string) {
console.log('Received name', name)
}
Typescript has void
type and you can also use it to explicitly specify the return type:
function logName(name: string): void {
console.log('Received name', name)
}
The void
type is only a TypeScript type that doesn't exist in JavaScript.
You can't write return void
in a function because void
will
disappear when TypeScript is compiled to JavaScript.
Using void
as a return type on a function means that
you should not use its return value in Javascript.
Implicit return
In JavaScript,
if you don't write a return
statement, then the function will implicitly return undefined
.
function logName(name: string) {
console.log("Received name", name);
}
const result = logName("Sam");
console.log(result); // undefined
So, a function that does not return anything in TypeScript
is still a function that will implicitly return undefined
in JavaScript.
The reason for using the void
data type is
to make the intention clear that
no return value should be expected or used.
void
is used even if a function returns undefined
(whether explicitly or implicitly).
It sets the expectation that
the function's return value does not matter
and should not be used or the function does not return anything.
void vs. undefined
undefined
represents the absence of a value or an uninitialized variable.
So, a function that has a return type of void
can omit the return
keyword,
explicitly return;
, or return undefined;
.
function logName1(name: string): void {
console.log('Received name', name)
}
function logName2(name: string): void {
console.log('Received name', name)
return
}
function logName3(name: string): void {
console.log('Received name', name)
return undefined
}
A function that returns void
, can only return nothing (or undefined
).
Other types will throw an error:
function logName4(name: string): void {
console.log('Received name', name)
return false
// Error: Type 'boolean' is not assignable to type 'void'.
}
Required parameters
In JavaScript, calling a function without required arguments
does not throw an error,
and the skipped arguments are assigned undefined
:
function logName(name) {
console.log(name)
}
logName() // logs undefined
In TypeScript, all arguments are required by default, and missing arguments result in a compile-time error:
function logName(name: string) {
console.log(name)
}
logName('Sam') // logs "Sam"
logName() // Error: Expected 1 argument(s), but got 0.
This feature prevents bugs from missing arguments.
Even with a union type including undefined, you must explicitly pass undefined
:
function logName(name: string | undefined) {
console.log(name)
}
logName('Sam') // logs "Sam"
logName(undefined) // logs undefined
logName() // Error: Expected 1 argument(s), but got 0.
A parameter is required even if its value can be undefined.
Optional parameters
In TypeScript, you can use the ?
symbol to make a parameter optional:
function logName(name?: string) {
console.log(name)
}
logName('Sam') // logs "Sam"
logName() // logs undefined
Optional parameters can be skipped when calling the function,
defaulting to undefined
, just like in JavaScript.
Restriction:
A required parameter cannot follow an optional one in TypeScript. This will cause an error:
function logName(name?: string, age: number) {
console.log(name, age)
}
// Error: A required parameter cannot follow an optional parameter.
To fix this, either re-order the parameters:
function logName(age: number, name?: string) {
console.log(name, age)
}
Or make both parameters optional:
function logName(name?: string, age?: number) {
console.log(name, age)
}
Default parameters
In JavaScript, you can use default parameters.
Default parameters allow you to specify a default value for a parameter.
This default value is used if no value is provided for the parameter or
if undefined
is explicitly passed.
function logName(name = '', age = 0) {
console.log(name, age)
}
logName() // Logs "", 0
function greet(name: string = 'Guest') {
console.log(`Hello, ${name}`)
}
// Calling the function without any arguments
greet() // Output: Hello, Guest
// Calling the function with `undefined`
greet(undefined) // Output: Hello, Guest
// Calling the function with a specific value
greet('Alice') // Output: Hello, Alice
You can't use default parameters with optional parameters simultaneously. Combining both features can lead to confusion and unexpected results.
You have to choose one. Default parameters can also be used to skip a parameter
while also providing it a meaningful value (instead of undefined
):
function logName(name: string, age = 0) {
console.log(name, age)
}
logName('Sam') // Logs "Sam", 0
TypeScript infers age
as number
due to the default value,
but you can also define it explicitly:
function logName(name: string, age: number = 0) {
console.log(name, age)
}
logName('Sam') // Logs "Sam", 0
Examples:
function orderSummary(
customerName: string,
count: string | number = 0,
): string {
return `${customerName} ordered ${count} item(s)`
}
console.log(orderSummary('Sam', 4))
;('Sam ordered 4 item(s)')
console.log(orderSummary('Alex', 'six'))
;('Alex ordered six item(s)')
console.log(orderSummary('Alex'))
;('Alex ordered 0 item(s)')
With arrow functions:
const welcomeUser = (username: string) => {
return `Welcome back ${username}.`
}
In Typescript,
all parameters are required by default unless you explicitly
specify them as optional (either using ?
or with a default value).
Modules (import/export)
In TypeScript, types declared in a file are not accessible in other files unless you export and import them. Although global types can be declared, it's generally not recommended.
If you have an index.ts
file and a helpers.ts
file that exports a showToast
method:
// helpers.ts
export function showToast(message: string) {
//
}
Trying to import it using the .ts
extension will cause an error:
// index.ts
import { showToast } from './helpers.ts'
// Error: An import path can only end with a '.ts'
// extension when 'allowImportingTsExtensions' is enabled.
Even though the file is named helpers.ts
, TypeScript requires
you to import helpers.js
to align with ES Modules in native ECMAScript
environments (like the browser).
The helpers.ts
file is the source file that will compile to helpers.js
.
So, replacing helpers.ts
with helpers.js
in the import statement will fix the issue:
import { showToast } from './helpers.js'
However, many developers preferred import paths to match the file on disk.
To address this, TypeScript added the allowImportingTsExtensions
option.
By adding "allowImportingTsExtensions": true
to tsconfig.json
under compilerOptions
,
you can import using the .ts
extension:
{
"compilerOptions": {
"allowImportingTsExtensions": true
}
}
and then
import { showToast } from './helpers.ts'
You can also omit the extension when using a module bundler:
// index.ts
import { showToast } from './helpers'
When you import the function, all the type information (typed parameters, return type) is still preserved and used by TypeScript.
Import Types
If a TypeScript type alias is used in multiple files, you don't need to rewrite it each time. Type aliases can be exported and imported like variables.
For example, if you have a Point
type in data.ts
, you can export
it:
// data.ts
export type Point = [number, number]
export function logPoint(point: Point) {
console.log(point[0], point[1])
}
Then, import it in index.ts
:
// index.ts
import { Point, logPoint } from './data.ts'
const amsterdam: Point = [52.37814, 4.90052]
logPoint(amsterdam)
This allows you to use the Point
type across different files.
Import as type
While the example above works,
there's a better way to distinguish between normal imports
and type imports.
TypeScript supports the import type
syntax for importing types.
This enhances readability and maintainability,
and helps module bundlers remove type-only imports, reducing the bundled file size.
To use import type
, consider the following syntax:
// data.ts
export type Point = [number, number]
export function logPoint(point: Point) {
console.log(point[0], point[1])
}
In index.ts
:
// index.ts
import type { Point } from './data.ts'
import { logPoint } from './data.ts'
const amsterdam: Point = [52.37814, 4.90052]
logPoint(amsterdam)
Using import type
is recommended for better readability
and to ensure type imports are handled correctly.
Module bundlers can now safely remove
import type { Point } from "./data.ts"
since it's only importing type information, not JavaScript code.
You can also combine imports on a single line while distinguishing types:
// index.ts
import { logPoint, type Point } from './data.ts'
const amsterdam: Point = [52.37814, 4.90052]
logPoint(amsterdam)
Use type
to prefix the type import.
Choose the syntax that suits your needs. For example, to import only types:
import type { User, Order, OrderInformation } from './types.ts'
The object type and object type alias
In TypeScript, the object type is written
using curly braces {}
to describe the shape of an object and its properties' types.
The properties' types can be any TypeScript type we already learned about.
For example, given a user
object:
const user = {
firstName: 'Sam',
lastName: 'Green',
}
The object type is:
{
firstName: string,
lastName: string,
}
You can create a type alias for this object type:
type User = {
firstName: string
lastName: string
}
const user: User = {
firstName: 'Sam',
lastName: 'Green',
}
Here, User
is the object type that describes user
objects,
requiring firstName
and lastName
to be strings.
Let's use the User
type in a function:
type User = {
firstName: string
lastName: string
}
function getFullName(user: User) {
return `${user.firstName} ${user.lastName}`
}
getFullName({ firstName: 'Sam', lastName: 'Green' }) // "Sam Green"
The getFullName
function expects a User
parameter.
TypeScript uses this to suggest (autocomplete in Editor) firstName
and lastName
when accessing the user
parameter.
TypeScript not only provides autocomplete but also checks that arguments match the specified type. Here's an example where an incorrect value as argument throws an error:
type User = {
firstName: string
lastName: string
}
function getFullName(user: User) {
return `${user.firstName} ${user.lastName}`
}
getFullName({ firstName: 'Sam' })
// Error: Argument of type '{ firstName: string; }' is not assignable to parameter of type 'User'.
// Property 'lastName' is missing in type '{ firstName: string; }' but required in type 'User'.
Type aliases act like contracts that must be satisfied, helping catch bugs early.
Object types in TypeScript list properties and their types, separated by commas or semicolons. Both are valid:
type User = {
firstName: string.; // semicolons
lastName: string.; // semicolons
}
The last comma or semicolon is optional:
type User = {
firstName: string.;
lastName: string
}
You can also use an unnamed object type, but it's usually better to name it for readability:
function getFullName(user: { firstName: string; lastName: string }) {
return `${user.firstName} ${user.lastName}`
}
Unnamed object types can be used for short, non-reusable types, but naming them is generally recommended.
More examples of object types:
type Order = {
customerName: string
amount: number | string
}
const order1: Order = {
customerName: 'Sam',
amount: 'ten',
}
const order2: Order = {
customerName: 'Alex',
amount: 20,
}
Optional properties
In TypeScript, the ?
operator denotes an optional property:
type User = {
fullName: string
age?: number
}
const user1: User = {
fullName: 'Sam Green',
age: 31,
}
const user2: User = {
fullName: 'Alex Bern',
}
Here's a function that receives a User
type,
showcasing TypeScript's type inference for property possible values:
function logUser(user: User) {
user.age = user.age + 1
// Error: user.age is possibly 'undefined'.
}
The error occurs because user.age
can be undefined
,
and undefined + 1
results in NaN
.
This is a common TypeScript error that helps prevent bugs.
You can fix this by narrowing the union type.
Use an if
condition to check if user.age
exists,
removing undefined
from the number | undefined
union type:
function logUser(user: User) {
console.log(user.fullName)
if (user.age !== undefined) {
// user.age's type narrowed to number
user.age += 1
}
}
More examples:
type ClassroomReport = {
name: string
grades: number[]
delivered: boolean
}
Structural type system
TypeScript's type system is structural, meaning objects with the same shape are considered the same type, and can be used interchangeably, regardless of their names. This is also known as Duck typing.
For example, these types are equivalent:
type Point = {
lat: number
lng: number
}
type MyPoint = {
lat: number
lng: number
}
The names don't matter; what matters is the shape.
A function accepting Point
can accept MyPoint
since they have the same shape:
const p1: MyPoint = { lat: 15, lng: 18 }
function showPoint(point: Point) {
//
}
showPoint(p1) // no errors
You can also call showPoint
with an object having an inferred type:
const p2 = { lat: 10, lng: 12 }
showPoint(p2) // no errors
This works because p2
has the compatible shape:
{
lat: number,
lng: number
}
Why? TypeScript, being a superset of JavaScript, uses a structural type system to maintain the flexibility of JavaScript. In JavaScript, you can pass an object anywhere as long as it has the required properties.
AnyObject
Notice how any object satisfies the type {}
. As {}
has no shape (it does not describe the properties)
type AnyObject = {}
const user: AnyObject = { name: 'Sam' }
const copyOfWindow: AnyObject = window
const copyOfDocument: AnyObject = document
Interfaces
Let's say you have the following JavaScript object:
const user = {
firstName: 'Sam',
lastName: 'Green',
}
we define an interface:
interface User {
firstName: string
lastName: string
}
const user: User = {
firstName: 'Sam',
lastName: 'Green',
}
This interface describes the user object by structurally checking the object's shape. Missing or mismatched properties will result in an error.
Interface with optional property
interface User {
fullName: string
age?: number
}
const user1: User = {
fullName: 'Sam Green',
age: 31,
}
const user2: User = {
fullName: 'Alex Bern',
}
Also avoid empty interfaces as it will match any object and is not sufficient.
interface AnyObject {}
export and import
// helpers.ts
export interface User {
firstName: string
lastName: string
}
command and semicolon is also accepted to separate the properties.
// index.ts
import { type User } from './helpers.ts'
See the difference syntax of type
and interface
:
type User = {
...
}
interface User {
...
}
Declaration merging - interface
You can merge two interface declarations with the same name in TypeScript:
interface User {
fullName: string
}
interface User {
age: number
}
const user: User = {
fullName: 'Sam Doe',
age: 31,
}
TypeScript automatically merges these two separate interface User
declarations into:
interface User {
fullName: string
age: number
}
So a User
object must have both fullName
(string
) and age
(number
).
If you forget age, you get an error:
const user: User = {
fullName: 'Sam Doe',
}
// Error: Property 'age' is missing in type '{ fullName: string; }' but required in type 'User'.
Interfaces merge only if they are in the same scope. This means they must be defined in the same file and within the same function, class, or module.
extends - Interface
A TypeScript interface can extend another interface, similar to class inheritance in JavaScript:
interface User {
id: number
email: string
}
interface AdminUser extends User {
permissions: string[]
}
interface GuestUser extends User {
invitationCode: string
}
The AdminUser
interface extends the User
interface,
so it includes all properties of User
plus those specific to AdminUser
.
{
id: number;
email: string;
permissions: string[];
}
And GuestUser
has the following properties:
{
id: number
email: string
invitationCode: string
}
Type vs. Interface
When working with object type we should consider that,
an interface is always extendable,
while an object type cannot be reopened to add new properties.
You sometimes want to prevent an object's shape from changing
or being extended elsewhere in the project;
in that case, it's best to use type
to define it.
Some examples of real-life complex objects
Nested objects
An object can contain other objects as properties. For example, a product can have a category property that is also an object:
Let's see how you can define Category
as a separate
type/interface and then use it inside the Product
type/interface.
interface Category {
id: number
name: string
}
interface Product {
id: number
name: string
price: number
category: Category
isInStock: boolean
}
const watermelon: Product = {
id: 142,
name: 'Seedless Watermelon',
price: 9.99,
category: {
id: 9,
name: 'Melons',
},
isInStock: true,
}
Another case is when a Product
has a Categories
property, which is an array of Category
objects.
interface Category {
id: number
name: string
}
interface Product {
id: number
name: string
price: number
categories: Category[]
isInStock: boolean
}
const watermelon = {
id: 142,
name: 'Seedless Watermelon',
price: 9.99,
categories: [
{
id: 9,
name: 'Melons',
},
{
id: 14,
name: 'Summer fruits',
},
],
isInStock: true,
}
Notice the array literal type Category[]
.
More example:
interface Risk {
name: string
tags: string[]
awardYears: number[]
}
let risk = {
name: 'Risk',
tags: ['Strategy', 'War'],
awardYears: [1957, 1960, 1999],
}
Optional nested properties
The parentCategory nested under category is optional.
If omitted, its value in JavaScript will be undefined
, when accessed.
interface Product {
id: number
name: string
price: number
category: {
id: number
name: string
parentCategory?: {
id: number
name: string
}
}
isInStock: boolean
}
When accessing parentCategory
in an object implementing this interface,
TypeScript recognizes it as the following union type:
{
id: number,
name: string
} | undefined
Accessing product.category.parentCategory.name
might result in JavaScript
errors such as Cannot access name on undefined
.
Hence, type narrowing is needed to handle when the parentCategory
is undefined
.
- 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
type SumCallback = (result: number) => void
function calculateSum(a: number, b: number, callback: SumCallback) {
const sum = a + b
callback(sum)
}
// Usage
calculateSum(5, 7, (result) => {
console.log('Sum is: ' + result)
})
When defining the callback type, you need to specify a parameter name. This name doesn't have to match the argument name used when calling the callback function.
Callback parameters and Substitutability of TypeScript
Implementing a callback in JavaScript involves three steps and you must separate the concepts and stages:
- Defining the callback function
- Passing the callback as a parameter to another function
- Calling the callback within that parent function
A callback is different from a regular function in JavaScript. The main difference is that callbacks can be called with fewer parameters.
In JavaScript, if a function has two parameters, you must call it with two arguments. However, this is not the case for callbacks.
function calculateSum(a, b, callback) {
const sum = a + b
callback(sum)
}
// usage
calculateSum(5, 7, () => {
console.log('Completed sum calculation')
})
Even though the when calling calculateSum
callback we pass sum
argument,
we can safely pass a callback function which is not using any argument,
as we may decide to not read them.
Since TypeScript is a superset of JavaScript, it mimics JavaScript's behavior, including its handling of callbacks. Thus, TypeScript behaves exactly like JavaScript in this regard.
As an example of a native JavaScript method, consider the following code. All of the examples below are valid JavaScript and, consequently, also valid TypeScript:
const data = [1, 4, 2]
data.forEach(() => {
console.log('An iteration')
})
data.forEach((item) => {
console.log(item)
})
data.forEach((item, index) => {
console.log(item, index)
})
The .forEach
method is a "parent" method (like calculateSum
) that accepts a callback as
parameters and its implementation is inside the JavaScript core.
TypeScript mimics this behavior and lets you safely ignore (not use) extra parameters for callbacks (only for callbacks, not for normal function calls).
Example:
type SumCallback = (result: number) => void
function calculateSum(a: number, b: number, callback: SumCallback) {
const sum = a + b
callback(sum)
}
calculateSum(5, 7, () => {
console.log('Completed sum calculation')
})
As you can see, even though the calculateSum
callback requires a result: number
parameter,
we can safely pass a callback function without using any parameters.
You might be wondering, isn't this similar to optional parameters? It's not the same. Only define a callback parameter as optional if you intend to call that function without that parameter sometimes.
The callback in the parent function is always called with these three parameters, but you can choose whether to read them or not.
In JavaScript, even if we don't read the index
in the defined callback body:
;[1, 4, 2].forEach((item) => {
console.log(item)
})
JavaScript still passes item
, index
, and array
parameters to call it.
It's just that our passed and defined callback function ignores the index
and array
.
In both JavaScript and TypeScript, callback functions are always called with all their parameters, but you can choose to ignore some of them.
using a non-void returning function as a void returning callback, Substitutability of TypeScript
We define a callback function that explicitly returns a string,
and can still safely (without Typescript error) use it as a callback function where a void
return type is expected:
function getName(): string {
return 'John'
}
function initApp(callback: () => void) {
console.log('Starting app')
callback()
}
initApp(getName)
This behavior means, whether the function returns a value or not, we're not going to look at it.
How come callback: () => void
accepts getName
which explicitly returns a string
and not void
?
The meaning of void
in TypeScript is that the return value
does not matter and will not be observed or used.
function getName(): string {
return 'John'
}
function initApp(callback: () => void) {
console.log('Starting app')
const name = callback()
name.toUpperString()
// Error Property 'toUpperString' does not exist on type 'void'.
}
initApp(getName)
We get Typescript error, as we try to read the return value of a void returning callback and call something on it.
Hence now you know why () => data.push(5)
,
which actually returns the new length of the array,
is a valid callback for initApp
function. However,
Typescript will make sure that you don't try and use that return value later on.
Now you know why () => data.push(5)
,
which returns the new array length,
is a valid callback for the initApp
function.
However, TypeScript ensures you don't use that return value later.
const data = [1, 4, 2]
function initApp(callback: () => void) {
console.log('Starting app')
callback()
}
initApp(() => data.push(5))
Also, remember that void
is not a JavaScript type; it's a TypeScript type.
Types in Classes
While in Javascript this is a valid class:
class User {
constructor(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
getFullName() {
return `${this.firstName} ${this.lastName}`
}
}
The Typescript valid version would be.
class User {
firstName: string
lastName: string
constructor(firstName: string, lastName: string) {
this.firstName = firstName
this.lastName = lastName
}
getFullName(): string {
return `${this.firstName} ${this.lastName}`
}
}
You see how Typescript demands declaring the class properties (fields) before using/accessing them.
However, this is also a valid Typescript class:
class User {
firstName
lastName
constructor(firstName: string, lastName: string) {
this.firstName = firstName
this.lastName = lastName
}
getFullName() {
return `${this.firstName} ${this.lastName}`
}
}
TypeScript doesn't need
explicit type declarations
for the properties
as it automatically infers the types from the constructor
signature.
Typing Class in Typescript with strict mode
If we enable the strict
option of TypeScript in the tsconfig.json
it automatically enables TypeScript's strictPropertyInitialization
setting.
That means,
this setting requires you to initialize properties ( default value for every property)
either using the class field syntax or in the constructor.
Otherwise, if a property is undefined
and you make a mathematical operation on it,
you will get NaN
.
class Point {
//It's valid to skip the explicit number type
lat = 0
lng = 0
}
// or in the constructor
class Point {
lat: number
lng: number
constructor() {
this.lat = 0
this.lng = 0
}
}
Typing optional class properties in Typescript with restrict mode
If a property cannot be defined yet because it's optional,
that is okay but it has to be explicitly marked with ?
.
class Point {
lat = 0
lng = 0
elevation?: number
}
const p1 = new Point()
console.log(p1) // {lat: 0, lng: 0} // It won't return an elevation property
console.log(p1.elevation) // undefined
p1.elevation = 10 // we are able to assign it later on to a number or undefined
console.log(p1) // {lat: 0, lng: 0, elevation: 10}
If we want to have the elevation
property from the start, we can then default it to undefined
.
class Point {
lat = 0
lng = 0
elevation?: number = undefined
constructor(lat: number, lng: number) {
this.lat = lat
this.lng = lng
}
}
const p1 = new Point(10, 20)
console.log(p1) // {lat: 10, lng: 20, elevation: undefined}
Another complex example of a valid Typescript class:
interface Todo {
title: string
category: string
}
class Todos {
todos: Todo[]
constructor() {
this.todos = [
{
title: 'Learn JavaScript',
category: 'work',
},
{
title: 'Meditate',
category: 'personal',
},
]
}
}
Class visibility in Typescript
TypeScript supports member visibility using the public
and private
properties.
All properties and methods are public by default, thus the public
keyword is optional.
You can make a property or method private using the private
keyword
and TypeScript will check – at compile time – that you're not
accessing this property/method from outside of the class.
When the Typescript code is converted to JavaScript code, there is no sign of private
keyword.
That means, the check only happens on compile time.
Hence, it is recommended you use the JavaScript
private field approach as it offers true private behavior.
The JavaScript's private fields, which still works in TypeScript and also offers compile-time checks by TypeScript, as well as browser checks at runtime:
class User {
email: string
constructor(email: string) {
this.email = email
}
logDetails() {
console.log(this)
}
#sendWelcomeEmail() {
//
}
}
const user = new User('[email protected]')
user.logDetails()
user.#sendWelcomeEmail()
// Error: Property '#sendWelcomeEmail'
// is not accessible outside class 'User' because it has a private identifier.
For public fields there is no such concern.
While the public
keyword is optional as public is the default visibility,
you can still use it if you want to explicitly
show the visibility of this property or method.
class User {
public email: string
constructor(email: string) {
this.email = email
}
}
Remember, that this is not necessary as a class field is already public by default.
capture parameter properties in Typescript
TypeScript offers a feature called parameter properties
,
which lets us capture a constructor parameter into a class property with the same name.
That means, we are explicitly
instructing TypeScript that this constructor parameter
is a class field that we need to capture.
The code above can be refactored to:
class User {
// no need to define the class field here anymore!
// TypeScript will generate it behind the scene.
constructor(public email: string) {
// no need to manually capture the constructor params anymore!
// TypeScript will generate it behind the scene.
}
}
Example: While this is a valid Typescript class
class Person {
constructor(public age: number) {
console.log(`You are ${this.age} years old`)
}
}
const person = new Person(30) // Logs 'You are 30 years old'
console.log(person.age) // 30
It's compiled Javascript version would be:
class Person {
constructor(age) {
this.age = age
console.log(`You are ${this.age} years old`)
}
}
const person = new Person(30) // Logs 'You are 30 years old'
console.log(person.age) // 30
More example:
class User {
constructor(
public name: string,
public email?: string,
) {}
}
const user1 = new User('Sam', '[email protected]')
console.log(user1.email) // "[email protected]"
const user2 = new User('Alex')
console.log(user2.email) // undefined
Readonly properties in Typescript
If you set a class property as readonly
,
TypeScript will throw an error if you try to reassign it,
both inside and outside the class.
class User {
readonly isAdmin = false
}
// or
class User {
public readonly isAdmin = false
}
Note that using TypeScript's parameter properties syntax, the constructor is the only place you can change the property. That's because, TypeScript generates and assigns the readonly property behind the scenes, and allows reassignment immediately.
class User {
constructor(public readonly age: number) {
// we can still increase the age in the constructor. But, not outside
this.age = this.age + 1
}
}
Making object properties readonly in Typescript
A class is a blueprint for creating an instance, called an object.
Similarly, we can make the object properties readonly
.
The readonly
property prevents an object property from being changed:
interface Product {
readonly id: number
name: string
price: number
}
function changeId(product: Product) {
product.id += 1
// Error: Cannot assign to 'id' because it is a read-only property.
}
Structural typing (Duck typing) for classes in Typescript
Substitutability of TypeScript says, If things have the expected shape or behavior, they are compatible and can be substituted. In another word, TypeScript's type system isn't literal.
If two objects have the same shape, they are considered the same type, regardless of the type or interface name.
Similarly, if two classes have the same shape and no private
members,
TypeScript considers them the same, ignoring the class name.
class Point {
constructor(
public x: number,
public y: number,
) {}
}
class Coordinates {
constructor(
public x: number,
public y: number,
) {}
}
function logPoint(point: Point) {
console.log(point.x, point.y)
}
const point = new Point(10, 20)
const coord = new Coordinates(30, 14)
logPoint(point) // This works, as expected.
logPoint(coord) // Also works!
// while the function expects an instance of Point,
// an instance of the class Coordinates can be used
Typing an object instance of a class
An object instance of a class can have methods in addition to properties.
Here's an interface that describes a sample object with three methods annotated with this interface:
interface UserVoteDriveWithLicense {
age: number
canVote(): boolean
canDrive: () => boolean
canHaveInternationalLicense(min: number, wheels: number): boolean
}
const obj: UserVoteDriveWithLicense = {
age: 32,
canVote: function () {
return this.age >= 18
},
canDrive: function () {
return this.age >= 15
},
canHaveInternationalLicense: function (minAge: number, wheelsSize: number) {
return this.age >= minAge && this.wheelsSize < 18
},
}
Both types canVote(): boolean
and canDrive: () => boolean
are valid.
The canHaveInternationalLicense
method accepts multiple parameters.
The parameter names in the interface do not need to match the actual
method's parameter names, but their types and order must match.
class implements interface
When a class implements an interface/type, that interface acts as a contract. That means, the class must implement at least all the properties and methods of that interface with the correct types. However, the class can implements more methods and have more properties.
Example:
The implemented interface represents the instance object created when we call the constructor
method, new User()
.
interface UserVoting {
age: number
canVote(): boolean
}
class User implements UserVoting {
age: number
constructor(age: number) {
this.age = age
}
canVote() {
return this.age >= 18
}
}
Sometimes we need a class satisfies multiple contracts to prevent bugs when we develop our code, for example:
interface Runnable {
run(): void
}
interface Stoppable {
stop(): void
}
class Process implements Runnable, Stoppable {
run() {
console.log('Process running')
}
stop() {
console.log('Process stopped')
}
}
extends class in Typescript
Once a subclass extends from a base class, the subclass will automatically inherit all the properties and methods.
While the extends syntax works exactly the same as that of JavaScript, it adds additional checks and is stricter than that of JavaScript.
TypeScript will enforce that a subclass is always a subtype of its base class, meaning that the subclass should always follow the same contract as that of the class.
This ensures that whenever an instance of the base class is expected, you can safely use an instance of the subclass too.
It is obvious that the subclass can have additional methods.
However, when overriding a method or property of the base class,
you cannot break the contract defined in the base class,
including types and return types.
That means, if a property was a string
in the base class,
it cannot become a number
in the subclass.
Example of overriding a method:
class BaseClass {
isAvailable(): boolean {
return true
}
}
class SubClass extends BaseClass {
isAvailable(): boolean {
// additional logic or different implementation
return false
}
}
You can override a method and add or remove parameters as long as you follow the contract. If a method was callable with no parameters, it should remain callable with no parameters. It is valid to override it with a method that accepts an optional parameter:
class FormElement {
constructor(public name: string) {}
onSubmit() {
console.log(`The form ${this.name} has been submitted`)
}
}
class PaymentForm extends FormElement {
onSubmit(successCode?: number) {
console.log(`The form ${this.name} has been submitted`, successCode)
}
}
const paymentForm = new PaymentForm()
This restriction ensures that your classes and their derived versions can be used interchangeably without causing type errors or unexpected behaviors.
The Liskov Substitution Principle (LSP) is a fundamental principle in object-oriented programming that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program.
Example:
class Rectangle {
constructor(
public width: number,
public height: number,
) {}
getArea() {
return this.width * this.height
}
render() {
return `Rectangle [${this.width} x ${this.height}]`
}
}
class Square extends Rectangle {
constructor(size: number) {
super(size, size)
}
render() {
return `Square [${this.width}]`
}
}
function describeShape(shape: Rectangle) {
return `${shape.render()} with an area of ${shape.getArea()}`
}
// Sample usage
const rect = new Rectangle(10, 20)
const square = new Square(5)
describeShape(rect) // "Rectangle [10 x 20] with an area of 200"
describeShape(square) // "Square [5] with an area of 25"
You see how super(size, size)
overrides the base class constructor.
Still, the square
instance object has two
properties width
and height
but with the same value.
You also see how the describeShape
method also
accepts square
object as the objects can be used interchangeably.
Both objects has the same shape:
{
width: number,
height: number,
getArea(): number,
render(): string
}
How come a Square is accepted in place of a Rectangle? It is due to TypeScript's type system is structural rather than literal (substitutability). Meaning that it doesn't look at the object's name but rather its shape.
Notice how the Rectangle
class name is used as a type shape: Rectangle
.
Substitutability in Javascript tells us that we can still pass an instance of a class (with or without additional properties), even if that class does not extend the base class, as long as the object’s satisfy the shape described by the accepted instance of the base class.
Example:
class Parallelogram {
constructor(
public width: number,
public height: number,
) {}
getArea() {
return this.width * this.height
}
render() {
return `Parallellogram [${this.width} x ${this.height}]`
}
}
function describeShape(shape: Rectangle) {
return `${shape.render()} with an area of ${shape.getArea()}`
}
const p = new Parallelogram(10, 20)
describeShape(p) // works without errors
More example:
class Book {
constructor(
public title: string,
public price: number,
) {}
getPrice() {
return this.price
}
}
class UsedBook extends Book {
constructor(title: string, price: number) {
super(title, price)
}
getPrice() {
return this.price - (this.price * 25) / 100
}
}
function getTotalPrice(books: Book[]) {
return books.reduce((total, book) => total + book.getPrice(), 0)
}
// Sample usage
const book1 = new Book('A Brief History of Time', 20)
const book2 = new UsedBook("The Hitchhiker's Guide to the Galaxy", 10)
const books = [book1, book2]
console.log(getTotalPrice(books))
Notice how the parameter books
is annotated with type Book[]
.
protected property in Typescript
A protected property or method is available for the current class and its subclasses, but not outside of the class.
class Book {
constructor(
protected title: string,
protected price: number,
) {}
getPrice() {
return this.price
}
}
class UsedBook extends Book {
constructor(title: string, price: number) {
super(title, price)
}
getPrice() {
return this.price - (this.price * 25) / 100
}
}
Polymorphism
Polymorphism is that objects of different types can be treated as objects of a common base type. There are many cases where a certain behavior has been described by two or more different classes extends a base class.
what makes polymorphism possible is that TypeScript forces subclasses to follow their parent's base class contract. That means the instance objects are already satisfying the shape/type of the instance object of that base class.
Example:
class Post {
constructor(public author: string) {}
render() {}
}
class TextPost extends Post {
constructor(
author: string,
public content: string,
) {
super(author)
}
render() {
return `<p>${this.content} by ${this.author}</p>`
}
}
class ImagePost extends Post {
constructor(
author: string,
public imageUrl: string,
) {
super(author)
}
render() {
return `<img src="${this.imageUrl}" alt="Image alt text here">
<p>Posted by by ${this.author}</p>`
}
}
function renderFeedPost(post: Post) {
document.querySelector('#feed').insertAdjacentHTML('beforeend', post.render())
}
const textPost = new TextPost('Sam', 'Welcome to my profile')
renderFeedPost(textPost)
Note that when we extend a class, it doesn't necessarily mean that all the methods of base class must do operations. We can override a method and set it as a no-op (no operation). Example:
class PayPalMethod extends PaymentMethod {
pay() {
// specific PayPal payment method logic
}
refund() {} // empty
}
While we define the refund
method to ensure it follows the contract, we can keep it empty.
Assume as noop just for PayPal case.
Abstract classes
We can make PaymentMethod
abstract to prevent creating instances of the class.
This class is now a contract that you can use for the subclasses.
abstract class PaymentMethod {
pay() {}
refund() {}
}
A subclass can inherit a base class as it is without need to implementing it.
When a method is abstract
, it cannot have an implementation in the base class.
It forces the subclasses to implement them.
abstract class PaymentMethod {
abstract pay(): void
abstract refund(): void
}
Polymorphism can be achieved not only by extending
a base or abstract
class but also by implementing a common interface
.
We know an interface
can be a type of a instance object of a class.
interface PaymentMethod {
pay(): void
refund(): void
}
class CardMethod implements PaymentMethod {
pay() {
// specific card payment method logic
}
refund() {
// specific card refund method logic
}
}
class PayPalMethod implements PaymentMethod {
pay() {
// specific PayPal payment method logic
}
refund() {
// specific PayPal refund method logic
}
}
unknown type
TypeScript has a type unknown
. A type that is not known yet.
function logToConsole(input: unknown) {
//
}
The input
parameter can be of any type.
Without using unknown, we'd need an impractically large and incomplete union type.
unknown and type narrowing example:
function logToConsole(input: unknown) {
if (typeof input === "string") {
return `<p class="console-string">${input}</p>`;
}
if (typeof input === "number") {
return `<p class="console-number">${input}</p>`;
}
if (Array.isArray(input) {
return `<p class="console-array">[${input.join(", ")}]</p>`
}
}
Although unknown
also represents any
data type,
it is safer than any
because TypeScript does not allow us to call anything on unknown
.
With type unknown
, we must narrow it before use.
It's a temporary state where the variable's type is unknown
.
TypeScript allows you to clarify the type through type narrowing.
On the other hand,
You can call anything on any
without TypeScript complaining
because any
turns off (disables) TypeScript's type checking.
Example:
function isPositiveNumber(input: unknown): boolean {
return typeof input === 'number' && input > 0
}
// Sample usage
isPositiveNumber(10) // true
isPositiveNumber(-5) // false
isPositiveNumber('5') // false
In JavaScript, for legacy reasons,
typeof null
is object
, typeof input === "object"
. This is why we sometimes need more specific type narrowing.
function getKeys(input: unknown) {
if (typeof input === 'object' && input !== null) {
return Object.keys(input)
}
return []
}
Otherwise Typescript will warn us.
function getKeys(input: unknown) {
if (typeof input === 'object') {
return Object.keys(input)
// Error: Argument of type 'object | null' is not assignable to parameter of type 'object'.
}
return []
}
Function overloads
?
Generics, generic function
Generics enable type-safe code when a function works with various types.
We can make a function generic that means works for any data type, but we define the type when we call it. So, a generic function creates a template to work with different types.
function getFirst<Type>(array: Type[]) {
return array[0]
}
Type
is the placeholder and is determined when we call it.
It captures the type information and uses it to make a relationship
between the inputs and outputs!
getFirst<string>(['a', 'b'])
getFirst<number>([1, 2])
When we call getFirst<string>(...)
, we provide string
as a value for the Type
type parameter.
So, this getFirst
function not only receives an array parameter,
but it also receives a TypeScript type
(string
, or number
, or ...)
that we can use to make a relationship between the inputs and the outputs of the function.
<string>
is a type argument passed to the getFirst<Type>
generic function.
Similarly, <number>
is a type argument passed to the getFirst<Type>
generic function.
A single character T
is conventionally accepted instead of a descriptive name Type
.
function getFirst<T>(array: T[]) {
return array[0]
}
Return type of the function is automatically inferred by Typescript. However, we can define it explicitly.
function getFirst<T>(array: T[]): T {
return array[0]
}
Now it is possible to have such general usage/implementations easily otherwise it would be verbose to implement this.
getFirst<number>([1, 2, 3]).toFixed(2); // "1.00"
getFirst<string>(["a", "b"]).toUpperCase(); // "A"
getFirst<{name:string, age:number}>([{name: "Sam", age: 43}).name; // "Sam"
However, in this specific case, we can completely drop the type argument and let TypeScript infer it!:
getFirst([1, 2, 3]).toFixed(2); // "1.00"
getFirst(["a", "b"]).toUpperCase(); // "A"
getFirst([{name: "Sam", age: 43}).name; // "Sam"
Note that we cannot drop the type parameter (for example, <T>
) from the function definition.
The function is generic just because of this <T>
syntax.
generic Set class
In Javascript we have and API which is set:
const set = new Set()
set.add(10)
set.add(5)
console.log(set) // Set(2) {10, 5}
const anotherSet = new Set()
anotherSet.add('abc')
console.log(anotherSet) // Set(1) {"abc"}
However, this JavaScript API is implemented in TypeScript as a generic for type safety. When you create a Set, you can tell TypeScript the data type it accepts: This Set can now only accept number elements. So, take a look at the error message you'll get if you try to add another data type:
In TypeScript, this JavaScript API is implemented as a generic for type safety. When you create a Set, you specify the data type it accepts. For example, a Set of numbers only accepts number elements. An error occurs if you try to add a different type:
const set = new Set<number>();
set.add(10);
set.add("abc");
Argument of type 'string' is not assignable to parameter of type 'number'.
More example:
function makeTuple<T>(element: T): [T, T] {
return [element, element]
}
makeTuple('abc') // ["abc", "abc"]
makeTuple(10) // [10, 10]
Notice the return type is a tuple type.
More example:
Consider the the return type T[]
is optional as Typescript infers it.
function ensureArray<T>(value: T | T[]): T[] {
return Array.isArray(value) ? value : [value]
}
ensureArray(['Sam', 'Alex']) // ["Sam", "Alex"]
ensureArray([123]) // [123]
ensureArray(512) // [512]
ensureArray('Sam') // ["Sam"]
ensureArray(true) // [true]
ensureArray([123, 'Sam']) // [123, "Sam"]
interface User {
name: string
age: number
}
ensureArray<User>({ name: 'Sam', age: 31 }) // [{name: "Sam", age: 31}]
Example:
function duplicate<T>(items: T[]) {
return [...items, ...items]
}
duplicate(['abc', 123])
Behind the scene Typescripts creates a template with such signature.
function duplicate<string | number>(items: (string | number)[]): (string | number)[]
Array generics
The Array<T>
generic type (where T
is the array item type) is an alternative way to define an array type in TypeScript.
We still can use array literal type.
const grades1: number[] = [15, 16, 18, 14]
const grades2: Array<number> = [15, 16, 18, 14]
Both variables are exactly the same.
const names1: string[] = ['John', 'Alex']
const names2: Array<string> = ['John', 'Alex']
Since T
in Array<T>
can be any type we'd like, this can also be a
union type, interface
or type.
const data1: (number | string)[] = [1, 'hello']
const data2: Array<number | string> = [1, 'Hello']
interface User {
name: string
age: number
}
const users1: User[] = [{ name: 'Alex', age: 31 }]
const users2: Array<User> = [{ name: 'Alex', age: 31 }]
Object Generics
This is how we convert an interface to a a generic interface.
interface ApiResponse<T> {
status: number
data: T
}
Where it describes a template of an object where the status is always a number and the data
is T
(type argument), which we can pass User
or Product
as type argument for example.
The ApiResponse<T>
describes a generic object that accepts a type T
.
Then we can annotate different objects using this generic interface.
interface User {
id: number
name: string
}
interface Product {
id: number
title: string
price: number
}
const user1: ApiResponse<User> = {
status: 200,
data: {
id: 1,
name: 'Sam Green',
},
}
const product1: ApiResponse<Product> = {
status: 200,
data: {
id: 1,
title: 'Oranges',
price: 10,
},
}
Lets see for example products
object where T
is Product[]
.
const products: ApiResponse<Product[]> = {
status: 200,
data: [
{
id: 1,
title: 'Oranges',
price: 10,
},
{
id: 2,
title: 'Pen',
price: 5,
},
],
}
A single generic interface serves different objects, preventing repetition.
Note that both of these are equal but with slight syntax differences:
type ApiResponse<T> = {
status: number
data: T
}
// or
interface ApiResponse<T> {
status: number
data: T
}
Look at how we use the type
keyword to create new types out of existing generic object types:
interface ApiResponse<T> {
status: number
data: T
}
interface User {
id: number
name: string
}
interface Product {
id: number
title: string
price: number
}
type UserApiResponse = ApiResponse<User>
type ProductApiResponse = ApiResponse<Product>
With this knowledge, we see that the
Array<T>
generic type is implemented int he Typescript core as a generic object using a generic interface.
Now let's see a complex example:
interface User {
id: number
name: string
}
interface SuccessResponse<T> {
status: number
data: T
}
interface ErrorResponse {
status: number
message: string
}
type ApiResponse<T> = SuccessResponse<T> | ErrorResponse
// Sample usage
// Successful response
const response1: ApiResponse<User> = {
status: 200,
data: {
id: 1,
name: 'Sam Green',
},
}
// Error response
const response2: ApiResponse<User> = {
status: 404,
message: 'Could not find user with id 2',
}
interface PaginatedResponse<T> {
currentPage: number
totalPages: number
totalCount: number
data: T[]
}
// Sample usage
const response: PaginatedResponse<User> = {
currentPage: 1,
totalPages: 4,
totalCount: 38,
data: [
{
id: 1,
name: 'Sam Green',
},
{
id: 2,
name: 'Alex Blue',
},
],
}
Let's try to implement our custom array generic type.
interface MyArray<T> {
length: number
toString(): string
// the "more" correct version would be push(...items: T[]) because .push() can receive multiple arguments
push(item: T): number
}
// Sample usage
const data1: MyArray<number> = [1, 14, 2]
console.log(data1.length) // 3
console.log(data1.toString()) // "3"
console.log(data1.push(10)) // 4 (the new length of the array)
const data2: MyArray<string> = ['abc']
console.log(data2.length) // 1
console.log(data2.toString()) // "abc"
console.log(data2.push('def')) // 2 (the new length of the array)
Generic class
This is a type-safe generic class which is used in two different scenarios:
class DataWrapper<T> {
data
constructor(value: T) {
this.data = value
}
}
const first = new DataWrapper('This one contains a string')
first.data.toUpperCase() // TypeScript knows that data is a string in this example
const second = new DataWrapper(14)
second.data.toFixed(2) // TypeScript knows that data is a number in this example
You see how Typescript was able to infer the T
type argument. However, it is also possible
to explicitly specify it.
new DataWrapper<string>('This one contains a string')
new DataWrapper<number>(14)
Remember in the similar way for generic function, we didn't pass the type argument, and typescript could infer the types.
See how this generic class type is useful in more complex data types:
interface User {
id: number
name: string
age: number
}
const users = [
{
id: 1,
name: 'Sam Green',
age: 41,
},
]
const wrapper = new DataWrapper<User[]>(users)
// Benefit from type-safe code (and autocomplete)
wrapper.data.forEach((user) => {
console.log(user.name)
})
Remember that how we used new Set<number>()
.
Map is a key-value data structure in JavaScript that provides an efficient way to add, edit, and delete data.
const map = new Map()
map.set('abc', 'My value here')
map.set(19427, 'Another value')
map.get('abc') // "My value here"
map.get(19427) // "Another value"
More example:
class CacheService<T> {
store
constructor() {
this.store = new Map()
}
set(key: T, value: number) {
this.store.set(key, value)
}
get(key: T) {
return this.store.get(key)
}
}
// usage
const cityCaches = new CacheService<string>()
cityCaches.set('London', 4)
cityCaches.get('London') // 4
type LatLng = [number, number]
const locationCaches = new CacheService<LatLng>()
locationCaches.set([52.3676, 4.9041], -3)
locationCaches.get([52.3676, 4.9041]) // -3
Another Example:
class Stack<T> {
elements: Array<T> = []
push(input: T): void {
this.elements.push(input)
}
pop(): void {
this.elements.pop()
}
}
// Sample usage
const numberStack = new Stack<number>()
numberStack.push(1)
numberStack.push(2)
numberStack.pop()
console.log(numberStack.elements) // [1]
const stringStack = new Stack<string>()
stringStack.push('Apple')
stringStack.push('Banana')
stringStack.pop()
console.log(stringStack.elements) // ["Apple"]
Generics with default type value.
for all sorts of generics, such as generic functions, arrays, objects, interface and classes we can specify a default value for the generic type.
function wrapInArray<T = string>(value: T) {
return [value]
}
The T = string
syntax in TypeScript sets the
default type of T
to string
when no other type is specified or inferred.
class Stack<T = string | number> {
elements: T[] = []
push(element: T) {
this.elements.push(element)
}
pop() {
return this.elements.pop()
}
}
Multiple generic types
This is how Map
is defined in the TypeScript source code but with different
type names. TypeScript uses K
and V
(short for Key and Value):
interface Map<K, V> {
/* ... */
}
const map1 = new Map<number, string>()
map1.set(4, 'value here')
If we don't not specify the K
and V
types our code is not type-safe.
In fact, the result of get()
has any
type because TypeScript does not
know what is being returned from the Map since it's not type-safe.
Similarly, new Set()
is not type-safe while new Set<string>()
is type-safe.
Type inference example:
function createPair<T>(first: T, second: T): [T, T] {
return [first, second]
}
createPair('abc', 'def') // ["abc", "def"]
There is no need to pass the type argument as typescript can infer the type.
createPair<string>('abc', 'def')
More complex example:
function createPair<T1, T2>(first: T1, second: T2) {
return [first, second]
}
createPair<number, boolean>(10, true) // [10, true]
// or
createPair(10, true) // using type inference
Generic constraint
Look at this how Typescript complains about the
item.price
because at the moment of function definition.
As T
is just a generic type;
it can be anything!
It can also be a number or a string.
So, TypeScript is not letting us treat item
as an object with a price
key.
function displayItemPrice<T>(item: T) {
return `This item costs ${item.price} dollars.`
// Error: Property 'price' does not exist on type 'T'.
}
This is different from type inference where at the call time it can infers the type of the item.
displayItemPrice({ id: 1, name: 'Online course', price: 10, taxable: true })
See how we constraint the T
and Typescript
knows that the input item
is object which can have other
properties, but it must satisfy an interface PricedItem
,
meaning that it must be an object with at least the
price
key and number
value.
interface PricedItem {
price: number
}
function displayItemPrice<T extends PricedItem>(item: T) {
return `This item costs ${item.price} dollars.`
}
Having this, TypeScript will only allow us to call displayItemPrice()
with objects that follow the PricedItem
shape.
See how we combined constraint with default type value.
interface UnlabledItem {
id: number
price: number
}
interface PricedItem {
price: number
}
function displayItemPrice<T extends PricedItem = UnlabledItem>(item: T) {
return `This item costs ${item.price} dollars.`
}
keyof
operator
keyof
is always placed before an object type (interface type), and it will give you a union type of its keys.
interface Point {
lat: number
lng: number
elevation: number
}
type ValidKeys = keyof Point
ValidKeys
evaluate to "lat" | "lng" | "elevation".
Example of how we pass safely argument to the getProperty
function.
interface Point {
lat: number;
lng: number;
elevation: number;
}
function getProperty(point: Point, key: keyof Point) {
return point[key];
}
const p1 = {
lat: 10,
lng: 20,
elevation: 3
}
getProperty(p1, "lat"); // 10
getProperty(p1, "lng"); // 20
getProperty(p1, "elevation"); // 3
getProperty(p1, "latitude");
Argument of type '"latitude"' is not assignable to parameter of type 'keyof Point'.
Generic extends keyof
In this code, Typescript complains
type Person = {
id: number
firstName: string
lastName: string
age: number
}
function getValue(person: Person, prop: keyof Person) {
return person[prop]
}
const person: Person = {
id: 1,
firstName: 'Alex',
lastName: 'Blue',
age: 30,
}
const firstName = getValue(person, 'firstName')
firstName.toUpperCase()
// Error: Property 'toUpperCase' does not exist on type 'string | number'.
// Error: Property 'toUpperCase' does not exist on type 'number'.
The return type of getValue
is inferred to be Person[keyof Person]
,
which is a union of all property types in Person
, that means (number | string)
.
It would be better if TypeScript infers the type correctly as string
(since the firstName
property has the type string).
But, this type information is lost when we rely on the keyof
operator.
keyof
is not able to remember the types of the keys.
The correct type-safe version is making the function generic using Generic extends keyof
.
type Person = {
id: number
firstName: string
lastName: string
age: number
}
function getValue<T, K extends keyof T>(person: T, prop: K) {
return person[prop]
}
// Sample usage
const person: Person = {
id: 1,
firstName: 'Alex',
lastName: 'Blue',
age: 30,
}
const firstName = getValue(person, 'firstName')
firstName.toUpperCase()
Key extends keyof
Type means that Key
must be a valid key
of Type
.
TypeScript is now able to map the return type to T[K]
where T
is person
and K
is
"firstName"
and can thus infer the correct return type, which is string
.
With this extends keyof T
we determine exactly what is K
type and make it specific
at the time of calling.
Note 1: generics are generic at the moment when the function, class, object, interface are defined. but they are specific when they are called.
Note 2: string literal is a value that a variable can take and is not a type.
Another example:
function updateObj<O, K extends keyof O> (obj: O, key: K, value: O[K]):void {
obj[key] = value;
console.log(obj)
}
// Sample usage
const user = { name: "Sam", age: 30 };
updateObj(user, "name", "Alex");
updateObj(user, "age", 31);
Notice to O[K]
which introduces a new type.
Required<Type>
Required<Type>
introduce a new type depending on the provided argument Type
.
The new type is all the properties of the Type
we provide are set to required.
It works with object types and interface type.
This is useful for avoiding the creation of a new interface type when an existing one can be repurposed and used as an argument to introduce a new type.
The benefits are:
- Your code remains DRY (Don't Repeat Yourself)
- Your new type updates with the one it's based on
interface User {
id?: number
name: string
age: number
verified?: boolean
}
type DBUser = Required<User>
function sendEmail(user: DBUser) {
console.log(`Email sent to user id ${user.id.toString()}`)
}
Hence, what object we provide as argument for sendEmail
function
must have all the properties of User
interface type,
even if there are multiple optional properties.
Otherwise, it would not be possible to ensure
the calling of user.id
within the function body.
Partial<Type>
The Partial<Type>
utility type is the opposite of the Required<Type>
utility type.
Partial<Type>
will create a new type where all the properties are set to optional.
Pick<Type, Keys>
The Pick<Type, Keys>
utility type creates a new type by extracting (picking) a
subset of properties (Keys
) from an existing type (Type
).
We'll need to provide Keys
as a union string includes those keys we are interested to keep.
Since we use Pick
which is a utility type, Typescript expects that what we
provide as Keys
must be in Type
.
interface Product {
id: number
name: string
price: number
description: string
category: string
inStock: boolean
dimensions: {
width: number
height: number
depth: number
}
tags: string[]
}
type ProductEssentials = Pick<Product, 'name' | 'price' | 'inStock'>
TypeScript is checking that the Keys
you're passing satisfies
the constraint keyof User
, meaning that it should be a valid key of User
.
Omit<Type, Keys>
The Omit<Type, Keys>
Utility Type is the opposite of Pick<Type, Keys>
.
typeof
First of all, this is different from typeof in Javascript
like typeof count === "string"
used for type narrowing.
Typescript has a typeof
type operator that can be used in type context.
The typeof
type operator automatically created a new type based on
a value at compile time and is thus erased from the generated JavaScript code.
The input value can be variable, object, or even a function.
const config = {
server: 'http://localhost:8080',
devMode: true,
}
type ConfigType = typeof config
And the ConfigType
type evaluates to the following:
{
server: string
devMode: boolean
}
Another example:
const formatDate = (date: Date) => {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
}
type FormatDateType = typeof formatDate
FormatDateType
evaluates to (date: Date) => string;
ReturnType<Type>
Utility Type
The ReturnType<Type>
utility type is used to get the return type of a function.
type FormatDateFunction = (date: Date) => string
type FormatDateReturnType = ReturnType<FormatDateFunction>
The value of FormatDateReturnType
is a string
in this example. Or:
type FormatDateReturnType = ReturnType<typeof formatDate>
Example:
const appConfig = {
apiBaseUrl: 'https://api.example.com',
enableLogging: true,
// theme: "dark"
}
function getConfigValue(key: keyof typeof appConfig) {
return appConfig[key]
}
// Sample usage
console.log(getConfigValue('apiBaseUrl'))
console.log(getConfigValue('enableLogging'))
Parameters<T>, NonNullable<T>
?
Record<Keys, Type>
The Record<Keys, Type>
creates an object type with Keys
as keys and Type
as values. It
helps to describe nested and complex objects.
Note that all keys are of the same type (Keys
),
and all values are of the same type (Types
) as well.
const grades: Record<string, number> = {
sam: 10,
alex: 15,
}
In JavaScript,
it is possible to omit the quotation marks around object keys as
long as the keys are valid JavaScript identifiers.
A valid identifier can contain letters, digits,
underscores (_
), and dollar signs ($
), and it
cannot start with a digit. Here is an example of an
object with keys that do not require quotation marks:
const obj = {
name: 'John',
age: 30,
isActive: true,
}
However, if your keys include spaces, special characters, or start with a digit, you must use quotation marks:
const obj = {
'first name': 'John',
age: 30,
'is-active': true,
'123key': 'value',
}
In this example, first name
, is-active
,
and 123key
require quotation marks because they contain spaces,
special characters, or start with a digit, respectively.
To summarize:
- You can omit quotation marks for keys that are valid JavaScript identifiers.
- You must use quotation marks for keys that are not valid JavaScript identifiers.
const users: Record<number, string> = {
1: 'Sam Green',
2: 'Alex Brane',
}
Another example:
interface UserInfo {
name: string
age: number
city: string
}
const users: Record<string, UserInfo> = {
sam: {
name: 'Sam',
age: 30,
city: 'Amsterdam',
},
alex: {
name: 'Alex',
age: 25,
city: 'Berlin',
},
}
More examples:
const appConfig: Record<string, number | string> = {
appName: 'MyApp',
version: 1.5,
environment: 'production',
maxUsers: 1000,
}
const errorCodes: Record<number, string> = {
404: 'Not Found',
500: 'Internal Server Error',
401: 'Unauthorized',
403: 'Forbidden',
}
// Add extra properties
errorCodes[405] = 'Method Not Allowed'
function createConfig(): Record<string, boolean> {
return {
isEnabled: true,
useSSL: false,
shouldRetry: true,
}
}
const config = createConfig()
// Add extra config properties
config.powerSaveMode = true
I in this syntax Record<Keys, Type>
we use a union string literal type then
it enforces that each one of the types in the union string literal type is used as a key,
Not more and not less.
You get an error if you add a key that doesn't exist in the union string literal type.
Example:
type Status = 'new' | 'pending' | 'complete'
const statuses: Record<Status, string> = {
new: 'Not started',
pending: 'Underway',
complete: 'Finished',
}
The object must then represents each and every item in the union string literal type.
type UserRole = 'admin' | 'user' | 'guest'
const permissions: Record<UserRole, Array<string | null>> = {
admin: ['create', 'read', 'update', 'delete'],
user: ['read'],
guest: [],
}
More example: This is a valid code:
type UserProfile = Record<string, string>
const user1: UserProfile = {}
An empty object {}
has no keys,
so it implicitly satisfies the Record<string, string>
type.
Since there are no keys to contradict the type constraint.
index signature
This interface
has a required property. Additionally,
with an index signature (a pattern) describing dynamic properties that can exist.
interface UserProfile {
username: string
[dynamicProps: string]: string
}
dynamicProps
is just a name.
The square brackets around the key name (dynamicProps
) and type
signify that it's a dynamic key with type string
and the value has string
type.
const user1: UserProfile = {
username: 'sam',
bio: 'I am new here',
personalWebsite: 'example.com',
}
const user2: UserProfile = {
username: 'alex',
}
As you see, all objects of type UserProfile
must have the username: string
property.
It is a must.
When defining a type with index signatures, the fixed properties type have to the same type of
the dynamic properties. For example using [key: string]: string
has a value type of string,
TypeScript enforces that all the keys have a value of type string.
To define a property of another type of dynamic property, we must first consider that type as one of the possible dynamic property's types. This is only feasible if the index signature accepts a union type.
interface UserProfile {
id: number
[key: string]: string | number
}
const user1: UserProfile = {
id: 1,
bio: 'I am new here',
personalWebsite: 'example.com',
age: 30,
}
const user2: UserProfile = {
id: 4,
}
interface UserProfile {
id: number
[key: string]: string | number
}
const user1: UserProfile = {
id: 1,
bio: 'I am new here',
personalWebsite: 'example.com',
age: 30,
}
const user2: UserProfile = {
id: 4,
}
This has a side effect that now other dynamic properties
can also accept number
values, which is why we were able to add age: 30
.
Record vs. Index signatures
If you don't have fixed properties the index signature becomes:
type Config = {
[key: string]: string
}
// or
type Config = { [key: string]: string }
The Record<string, string>
also do the same job! Hence a good practice is to avoid index signature
if we don't need to fixed properties.
Access type of a specific property
It's possible in TypeScript to access the type of a specific property of an object type.
interface User {
name: string
age: number
}
type NameType = User['name'] // string
type AgeType = User['age'] // number
intersection
The intersection type (&
) combines multiple types into one
by merging all the properties of all the types into one.
That's mean an object or a value must satisfy all the combined types' requirements.
interface Coordinates {
lat: number
lon: number
}
interface Info {
label: string
}
function merge(coords: Coordinates, info: Info): Coordinates & Info {
return { ...coords, ...info }
}
For example we can write this type as a result of intersection of two types
interface UserProfile {
username: string
[key: string]: string
}
// or
interface UserProfileRequired {
username: string
}
interface DynamicProperties {
[key: string]: string
}
type UserProfile = UserProfileRequired & DynamicProperties
Example:
interface Printable {
print: () => void
}
// TODO: explicitly specify return type
function withPrint<T>(object: T): T & Printable {
return {
...object,
print: function () {
console.log(`Printing...`)
},
}
}
// Sample usage
interface User {
id: number
name: string
}
const printableUser = withPrint<User>({ id: 1, name: 'Alex' })
printableUser.print()
If both types have a property with the same name and the same type, the resulting type will, of course, include that same property and type.
What if the two types have a property with the same name but different types? The resulting type must satisfy both types if possible. That means it finds the intersect the types if exists.
interface Product {
id: string | number
title: string
}
interface ProductWithPrice {
id: number
price: number
}
type ProductInfo = Product & ProductWithPrice
const oranges: ProductInfo = {
id: 1,
title: 'Oranges',
price: 10,
}
So, (string | number) & number
results in number
.
interface User {
id: string
name: string
}
interface Admin {
id: number
}
type AdminUser = User & Admin
string & number
results in never
. That means is the any type that satisfy both? no.
Thus, AdminUser
is equivalent to:
{
id: never
name: string
}
Which is not a useful type.
While type cannot be re-opened, you can define new type with intersection.
type User = {
name: string
age: number
}
type Admin = User & {
role: string
}
Which is equal to:
interface User {
name: string
age: number
}
interface Admin extends User {
role: string
}
However,
there is a significant difference:
the first approach may result in a type with never
,
whereas the second approach, which uses interface
,
will immediately raise an error if there is no intersection between the two types.
interface User {
id: string
name: string
}
interface Admin extends User {
// Error: Interface 'Admin' incorrectly extends interface 'User'.
// Error: Types of property 'id' are incompatible.
// Error: Type 'number' is not assignable to type 'string'.
id: number
}