This guide is a personal collection of concise explanations and code examples on key TypeScript concepts. It’s a learning journal made to reinforce my understanding—and hopefully help others grasp TypeScript more clearly, one concept at a time.
If you're completely new to TypeScript, I recommend starting with the video Master TypeScript in an easy way by Nova Designs.
Primitives
TypeScript has a type for every JavaScript primitive: string
, number
, bigint
, boolean
, symbol
, null
, and undefined
. It also includes any
, unknown
, never
, and void
.
any
disables type checking and should be avoided as much as possible. The following code is valid:
let x: any;
x = 'John';
x = 50;
The following code is also valid, since any
is the default type when no explicit type is provided:
let x;
x = 'John';
x = 50;
unknown
is a safer alternative to any
because it enforces type checking when the value is used:
function greet(id: any, name: string) {
console.log(`Hello ${name} (${id.toUpperCase()})`);
}
greet('abc', 'John');
greet(123, 'John'); // (1)
// 1
// Runtime error because the method toUpperCase
// doesn't exist on number type
Using unknown
helps prevent this issue, as TypeScript will flag the potential error:
function greet(id: unknown, name: string) {
console.log(`Hello ${name} (${id.toUpperCase()})`); // (1)
}
// 1
// Diagnostic error because the method toUpperCase
// doesn't exist on unknown type
Seeing this error, you are encouraged to add a type check:
function greet(id:unknown, name:string) {
if (typeof id === 'string') {
console.log(`Hello ${name} (${id.toUpperCase()})`);
} else {
console.log(`Hello ${name} (${id})`);
}
}
greet('abc', 'John');
greet(123, 'John');
Of course, you can add the same check when using any
; what’s missing is the diagnostic error.
never
is used to type functions that don’t return — neither explicitly nor implicitly — and to enforce exhaustive checks in union
types (more on union
types later).
Typing functions that don't return:
function fail(message: string): never {
throw new Error(message);
}
Enforcing exhaustive checks in a union type. Imagine you define a union type and a function based on it:
type Status = 'on' | 'off';
function logStatus(status: Status) {
switch (status) {
case 'on':
console.log('The status is on');
break;
case 'off':
console.log('The status is off');
break;
}
}
logStatus('on');
logStatus('off');
Later on, you add a new case to the union but forget to handle it in the switch
:
type Status = 'on' | 'off' | 'disabled';
function logStatus(status: Status) {
switch (status) {
case 'on':
console.log('The status is on');
break;
case 'off':
console.log('The status is off');
break;
}
}
logStatus('on');
logStatus('off');
logStatus('disabled'); // (1)
// 1
// Produces no output and no error
// making the problem easy to overlook
From the start, by using never
in the default
case, you ensure that TypeScript warns you when a new case is missing:
type Status = 'on' | 'off' | 'disabled';
function logStatus(status: Status) {
switch (status) {
case 'on':
console.log('The status is on');
break;
case 'off':
console.log('The status is off');
break;
default:
const check: never = status; // (1)
}
}
logStatus('on');
logStatus('off');
logStatus('disabled'); // (2)
// 1
// Diagnostic error because type string
// is not assignable to type never
// 2
// Still produces no output or error
// but now the issue is clearly flagged
When you see the error, it becomes obvious that you need to handle the missing case:
type Status = 'on' | 'off' | 'disabled';
function logStatus(status: Status) {
switch (status) {
case 'on':
console.log('The status is on');
break;
case 'off':
console.log('The status is off');
break;
case 'disabled':
console.log('The status is disabled');
break;
default:
const check: never = status;
}
}
logStatus('on');
logStatus('off');
logStatus('disabled');
void
is used to type functions that don’t return a value explicitly:
function greet(name: string): void {
console.log(`Hello ${name}`);
}
greet('John');
If you try to return a value explicitly, it generates a diagnostic error:
function greet(name: string): void {
console.log(`Hello ${name}`);
return name; // (1)
}
greet('John');
// 1
// type string is not assignable to type void
Objects and Classes
TypeScript introduces the object
type to type plain objects:
const user: object = {
name: 'John',
age: 50,
}
Clearly, it's not very meaningful and should only be used when the structure of the object is unknown.
When the structure is known, it's better to use interface
or type
:
interface User {
name: string;
age: number;
}
const user: User = {
name: 'John',
age: 50,
}
type User = {
name: string;
age: number;
}
const user: User = {
name: 'John',
age: 50,
}
Of course, there are differences between
interface
andtype
, but for now, I prefer not to go into them.
Classes can also be typed using interface
and type
:
interface UserInterface {
name: string;
age: number;
}
class User implements UserInterface {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
const user1 = new User('john',50);
type UserType = {
name: string;
age: number;
}
class User implements UserType {
name: string;
age: number;
constructor(name: string, age: number) {
this.name = name;
this.age = age;
}
}
const user1 = new User('john', 50);
Arrays, Tuples, Maps and Sets
TypeScript provides two ways to type arrays. Literal
:
let colors: string[];
colors = ['white', 'black'];
colors = ['red', 'green', 255]; // (1)
// 1
// type number is not assignable to type string
And Generic
(more on generic
type later):
let colors: Array<string>;
colors = ['white', 'black'];
colors = ['red', 'green', 255]; // (1)
// 1
// type number is not assignable to type string
It also introduces tuples, which are not available in JavaScript.
A Tuple is an array with a fixed length and predefined types:
let response: [number, string];
response = [200, "OK"];
response = ["OK", 200]; // (1) & (2)
// 1
// type string is not assignable to type number
// 2
// type number is not assignable to type string
The Generic
way also allows typing maps and sets:
const users = new Map<number, string>();
users.set(1, 'John');
users.set('2', 'Jack'); // (1)
// 1
// type string is not assignable to type number
const colors = new Set<string>();
colors.add('White');
colors.add(255); // (1)
// 1
// type number is not assignable to type string
Functions, Callbacks, Methods
TypeScript offers several ways to type functions depending on the context.
Inline typing
function greet(id:unknown, name:string) {
if (typeof id === 'string') {
console.log(`Hello ${name} (${id.toUpperCase()})`);
} else {
console.log(`Hello ${name} (${id})`);
}
}
greet('abc', 'John');
greet(123, 'John');
Whit interface
interface Greet {
(id: unknown, name: string) : void;
}
const greet: Greet = (id, name) => {
if (typeof id === 'string') {
console.log(`Hello ${name} (${id.toUpperCase()})`);
} else {
console.log(`Hello ${name} (${id})`);
}
}
greet('abc', 'John');
greet(123, 'John');
With type
type Greet = (id: unknown, name: string) => void;
const greet: Greet = (id, name) => {
if (typeof id === 'string') {
console.log(`Hello ${name} (${id.toUpperCase()})`);
} else {
console.log(`Hello ${name} (${id})`);
}
}
greet('abc', 'John');
greet(123, 'John');
Rest parameter
function sum(...values: number[]): number {
return values.reduce( (a, b) => a + b, 0 );
}
const result = sum(1, 2, 3, 4);
console.log(result); // 10
Callbacks
function logData(processor: () => number): void {
console.log(processor());
}
function stringToNumber(data: string): number {
return parseFloat(data);
}
logData(() => stringToNumber('012')); // 12
Inside objects
interface User {
name: string;
greet(greeting: string): void;
}
const user1 = {
name: 'John',
greet(greeting = 'Hello') {console.log(`${greeting} ${this.name}`)}
}
user1.greet(); // Hello John
user1.greet('Hi'); // Hi John
Inside classes
interface User {
name: string;
greet(greeting: string): void;
}
class User implements User {
name: string;
constructor(name: string) {
this.name = name;
}
greet(greeting = 'Hello') {
console.log(`${greeting} ${this.name}`);
}
}
const user1 = new User('John');
user1.greet(); // Hello John
user1.greet('Hi'); // Hi John
Unions and Intersections
Union type is a way to restrict typing to a set of alternatives. For example, narrowing down to a set of types instead of using a generic type:
type GenericId = unknown;
type NarrowedId = string | number;
let x: GenericId;
x = 'a';
x = 1;
x = true;
let y: NarrowedId;
y = 'a';
y = 1;
y = true; // (1)
// 1
// type 'boolean' is not assignable to type 'NarrowedId'
Or narrowing down to a set of literal types:
type Role = 'Administrator' | 'Customer';
let user1Role: Role;
user1Role = 'Administrator';
user1Role = 'Guest'; // (1)
// 1
// type 'Guest' is not assignable to type 'Role'
type Condition = 0 | 1;
let condition: Condition;
condition = 0;
condition = 1;
condition = 2; // (1)
condition = 'abc'; // (2)
// 1
// type '2' is not assignable to type 'Condition'
// 2
// type 'abc' is not assignable to type 'Condition'
Of course, union types can be combined with other type definitions. Examples:
type Role = 'Administrator' | 'Customer';
interface User {
id: number | string;
name: string;
role: Role;
}
const user1: User = {
id: 1,
name: 'John',
role: 'Administrator',
}
const ids: Array<number | string> = [1, 2, 'abc', 3, 'xyz'];
TypeScript also offers a special kind of union called discriminated union, which requires that all types involved share a common property. In the following example, both Success
and Failure
have the status
property:
type Success = {
status: 'OK';
data: string;
}
type Failure = {
status: 'Failed';
errorMessage: string;
}
type OperationResult = Success | Failure;
function logOperationResult(operationResult: OperationResult): void {
if (operationResult.status === 'OK') {
console.log(`Successful operation: ${operationResult.data}`);
} else {
console.error(`Failed operation: ${operationResult.errorMessage}`);
}
}
const successResult: Success = {
status: 'OK',
data: 'Data processed successfully.',
};
logOperationResult(successResult);
const failureResult: Failure = {
status: 'Failed',
errorMessage: 'Error during file reading.',
};
logOperationResult(failureResult);
Intersection type is a way to combine multiple types. For example:
interface PhysicalAddress {
street: string;
postalCode: number;
city: string;
}
interface VirtualAddress {
email: string;
}
type FullAddress = PhysicalAddress & VirtualAddress;
const address1: FullAddress = {
street: 'abc',
postalCode: 123,
city: 'xyz',
email: 'test@test.test',
}
const address2: FullAddress = {
street: 'efg',
postalCode: 456,
city: 'qwe',
} // (1)
// 1
// property 'email' is missing in type
// '{ street: string; postalCode: number; city: string; }'
// but required in type 'VirtualAddress'
Observations
At this point, a few key observations are worth noting.
1) TypeScript defines types, not values:
type Condition = 0 | 1; // (1)
let condition: Condition;
condition = 0; // (2)
condition = 1; // (3)
// 1
// here 0 and 1 are types, not values
// 2 and 3
// here 0 and 1 are values
2) TypeScript generates errors at compile time and only in rare cases at runtime; JavaScript generates errors exclusively at runtime; the editor (for example, Visual Studio Code) generates diagnostic errors if properly configured.
Diagnostic errors are very useful as they allow you to identify issues in real-time without needing to go through the actual code transpiler.
3) In most cases, interface
and type
are interchangeable. For example, this:
interface PhysicalAddress {
street: string;
postalCode: number;
city: string;
}
interface VirtualAddress {
email: string;
}
type FullAddress = PhysicalAddress & VirtualAddress;
Can be rewritten like this:
type PhysicalAddress = {
street: string;
postalCode: number;
city: string;
}
type VirtualAddress = {
email: string;
}
type FullAddress = PhysicalAddress & VirtualAddress;
Modifiers, Operators and Assertions
The ?
modifier is useful to make a property optional:
interface User {
name: string;
age: number;
role?: string;
}
const user1: User = {
name: 'John',
age: 50,
role: 'Developer',
}
const user2: User = {
name: 'Jack',
age: 25,
} // (1)
// 1
// no problem because 'role' is set as optional
The readonly
modifier is useful to make a property unchangeable:
interface User {
readonly name: string;
age: number;
}
const user1: User = {
name: 'John',
age: 50,
}
user1.name = 'Jack'; // (1)
user1.age = 25;
// 1
// cannot assign to 'name' because it is a read-only property
The typeof
operator is useful to create a type based on a variable:
const user1 = {
name: 'John',
age: 50,
role: 'Developer',
}
type User = typeof user1; // (1)
// 1
// type User = {
// name: string;
// age: number;
// role: string;
// }
The keyof
operator is useful to create a literal union type composed of the property names of a type or interface:
interface User {
name: string;
age: number;
};
type UserKeys = keyof User; // (1)
let key: UserKeys;
key = 'name';
key = 'age';
key = 'email'; // (2)
// 1
// type UserType = 'name' | 'age';
// 2
// type 'email' is not assignable to type 'UserKeys'
The in
operator is useful to create a type based on a literal union type:
type Keys = 'name' | 'age';
type User = {
[K in Keys]: string;
}; // (1)
// 1
// type User = {
// name: string;
// age: string;
// }
The as type
assertion is used to convince TypeScript that a certain value is of a specific type:
type User = {
id: number;
name: string;
};
const data1 = JSON.parse('{"id":1,"name":"John"}'); // (1)
const data2 = JSON.parse('{"id":2,"name":"Jack"}') as User; // (2)
// 1
// const data1: any
// 2
// const data2: {
// id: number;
// name: string;
// }
The as const
assertion is useful to make a value immutable and with literal types (I'll add more useful examples in the future):
const user1 = {
name: 'John',
age: 50,
} as const // (1) and (2)
// 1
// 'name' and 'age' are readonly so you can't assign new values to them
// 2
// the type of 'name' is the literal 'John'
// the type of 'age' is the literal 50
Generics
Allow you to define types in a flexible way:
function getFirst<T>(values: T[]): T {
return values[0];
}
const firstName = getFirst<string>(['John', 'Jake']);
const firstAge = getFirst<number>([50, 25]);
console.log(firstName); // 'John'
console.log(firstAge); // 50
Utility Types
Utility Types are built-in generics to built new types (not interfaces).
Readonly
Makes all properties readonly:
interface User {
name: string;
age: number;
}
type UserConst = Readonly<User>; // (1)
const user1: UserConst = {
name: 'John',
age: 50,
}
user1.name = 'Jack'; // (2)
// 1
// type UserConst = {
// readonly name: string;
// readonly age: number;
// }
// 2
// cannot assign to 'name' because it is a read-only property
Partial
Makes all properties optional:
interface User {
name: string;
age?: number;
}
type UserPartial = Partial<User>; // (1)
const user1: UserPartial = {
age: 50,
} // (2)
// 1
// type UserPartial = {
// name?: string;
// age?: number;
// }
// 2
// no problem because 'name' is set as optional
Required
Makes all properties mandatory:
interface User {
name: string;
age?: number;
}
type UserRequired = Required<User>; // (1)
const user1: UserRequired = {
name: 'John',
} // (2)
// 1
// type UserRequired = {
// name: string;
// age: number;
// }
// 2
// problem because 'age' is set as mandatory
Pick and Omit
They create a subset of properties from a type or interface.
Pick is used to specify which properties to keep:
interface User {
name: string;
age: number;
role: string;
}
type UserMinimal = Pick<User, 'name' | 'age'>; // (1)
// 1
// type UserMinimal = {
// name: string;
// age: number;
// }
Omit is used to specify which properties to exclude:
interface User {
name: string;
age: number;
role: string;
}
type UserMinimal = Omit<User, 'role'>; // (1)
// 1
// type UserMinimal = {
// name: string;
// age: number;
// }
NonNullable
Removes null
and undefined
from a type that may include them:
type Name = string | null | undefined;
type NameMandatory = NonNullable<Name>; // (1)
function greet1(name: Name): string {
return `Hello ${name}`;
}
function greet2(name: NameMandatory): string {
return `Hello ${name}`;
}
console.log(greet1('John'));
console.log(greet1(null));
console.log(greet2(null)); // (2)
// 1
// type NameMandatory = string;
// 2
// argument of type 'null' is not assignable
// to parameter of type 'string'
Extract and Exclude
They create a subset from a union type.
Extract specifies which literals to keep:
type Role = "admin" | "user" | "guest";
type LoggedIn = Extract<Role, "admin" | "user">; // (1)
// 1
// type LoggedIn = "admin" | "user";
Exclude specifies which literals to remove:
type Role = "admin" | "user" | "guest";
type LoggedIn = Exclude<Role, "guest">; // (1)
// 1
// type LoggedIn = "admin" | "user";
Parameters
Creates a tuple from the parameter types of a function type:
function greet(greeting: string, name: string): string {
return `${greeting} ${name}`;
}
type Greet = Parameters<typeof greet>; // (1)
const colors: Greet = ['white', 'black'];
// 1
// type Greet = [string, string];
ReturnType
Creates a type from the return type of a function type:
function createUser(name: string, age: number): {name: string, age: number} {
return {
name,
age,
};
}
type User = ReturnType<typeof createUser>; // (1)
const user1: User = {
name: 'John',
age: 50,
};
// 1
// type User = {
// name: string;
// age: number;
// }
Record
Creates a type for typing objects based on a set of keys and a value type:
type Roles = "admin" | "customer" | "guest";
type Access = Record<Roles, boolean>; // (1)
const access: Access = {
admin: true,
customer: true,
guest: false,
user: false, // (2)
};
// 1
// type Access = {
// admin: boolean;
// customer: boolean;
// guest: boolean;
// }
// 2
// object literal may only specify known properties,
// and 'user' does not exist in type 'Access'
Enum alternative
TypeScript adds the Enum data type, which many developers don’t like. You can create an alternative using a combination of typeof
, keyof
, and Indexed Access Type:
const Roles = {
ADMIN: 'Admin',
CUSTOMER: 'Customer',
GUEST: 'Guest',
} as const;
type Role = typeof Roles[keyof typeof Roles]; // (🤑)
function assignRole(role: Role) {
console.log(`Assigned role: ${role}`);
}
assignRole(Roles.ADMIN);
I found the statement type Role = typeof Roles[keyof typeof Roles];
really difficult to understand at first. Breaking it down helped:
const Roles = {
ADMIN: 'Admin',
CUSTOMER: 'Customer',
GUEST: 'Guest',
} as const;
type Step1 = typeof Roles; // (1)
// 1
// type Step1 = {
// readonly ADMIN: "Admin";
// readonly CUSTOMER: "Customer";
// readonly GUEST: "Guest";
// }
type Step2 = keyof Step1; // (2)
// 2
// type Step2 = "ADMIN" | "CUSTOMER" | "GUEST";
type Step3 = typeof Roles[Step2]; // (3)
// 3
// type Step3 = "Admin" | "Customer" | "Guest";
type Role = Step3;
function assignRole(role: Role) {
console.log(`Assigned role: ${role}`);
}
assignRole(Roles.ADMIN);
[Note] An Indexed Access Type lets you extract the type of a specific property from another type:
type Person = {
name: string;
age: number;
};
type Name = Person["name"]; // (1)
// 1
// type Name = string;
Before You Go
- if you find this guide helpful, please share it
- if you find this guide very helpful, please donate (only) one dollar to me by PayPal
Top comments (0)