DEV Community

Gennaro Di Fiandra
Gennaro Di Fiandra

Posted on • Edited on

Understanding TypeScript Through Practical Notes and Examples

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;
Enter fullscreen mode Exit fullscreen mode

The following code is also valid, since any is the default type when no explicit type is provided:

let x;
x = 'John';
x = 50;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

void is used to type functions that don’t return a value explicitly:

function greet(name: string): void {
  console.log(`Hello ${name}`);
}

greet('John');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Objects and Classes

TypeScript introduces the object type to type plain objects:

const user: object = {
  name: 'John',
  age: 50,
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode
type User =  {
  name: string;
  age: number;
}

const user: User = {
  name: 'John',
  age: 50,
}
Enter fullscreen mode Exit fullscreen mode

Of course, there are differences between interface and type, 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);
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
const colors = new Set<string>();
colors.add('White');
colors.add(255); // (1)

// 1
// type number is not assignable to type string
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Callbacks

function logData(processor: () => number): void {
  console.log(processor());
}

function stringToNumber(data: string): number {
  return parseFloat(data);
}

logData(() => stringToNumber('012')); // 12
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode
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'
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode
const ids: Array<number | string> = [1, 2, 'abc', 3, 'xyz'];
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Can be rewritten like this:

type PhysicalAddress = {
  street: string;
  postalCode: number;
  city: string;
}

type VirtualAddress = {
  email: string;
}

type FullAddress = PhysicalAddress & VirtualAddress;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
// }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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;
// }
Enter fullscreen mode Exit fullscreen mode

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;
// }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
// }
Enter fullscreen mode Exit fullscreen mode

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;
// }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

Exclude specifies which literals to remove:

type Role = "admin" | "user" | "guest";

type LoggedIn = Exclude<Role, "guest">; // (1)

// 1
// type LoggedIn = "admin" | "user";
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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;
// }
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

[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;
Enter fullscreen mode Exit fullscreen mode

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)