Advanced TypeScript Concepts

Types

In TypeScript, you can define a type using the type keyword, allowing you to associate a name with a type and use it wherever you need it. Types can be used to define complex structures easily.

type Point = {
  x: number;
  y: number;
};

let coord: Point = { x: 10, y: 20 };

Types vs Interfaces

In TypeScript, both type and interface can be used to define the shape of an object, but there are subtle differences and preferences when to use which.

  • Interfaces are preferred when defining the shape for object literals due to their extendable and more powerful capabilities
  • Types are more versatile and can represent a wider range of shapes including primitive types, union types, and intersection types (more on this later)
interface Person {
  name: string;
  age: number;
}

type Employee = {
  manager: Person;
  department: string;
};

Composing Types

TypeScript allows for composing types using union and intersection types, allowing developers to combine existing types to create new ones. Union types are helpful when a value can be one of several types, represented by the | symbol. Intersection types are represented using the & symbol and are used when a value should be of multiple types.

type StringOrNumber = string | number; // Union Type
type EmployeeInfo = Person & { manager: Person }; // Intersection Type

When it comes to creating interfaces in TypeScript, they cannot be directly composed using union (|) or intersection (&) types.

Union (|) - "OR"

In TypeScript, the Union type (denoted with |) is used when a value can be one of several types. You can think of it as an "OR" operation. When you define a type as TypeA | TypeB, you're saying that a value of that type can be either of TypeA or TypeB.

Example:

type StringOrNumber = string | number;

let myVar: StringOrNumber;

myVar = 42;       // OK, because it's a number
myVar = "Hello";  // OK, because it's a string

Intersection (&) - "AND"

On the other hand, the Intersection type (denoted with &) is used when you want a type to have all the properties of several types. You can think of it as an "AND" operation. If you have TypeA & TypeB, then a value of this type will have all the properties of both TypeA and TypeB.

Example:

type HasName = { name: string };
type HasAge = { age: number };

type Person = HasName & HasAge;

const person: Person = {
  name: "John",
  age: 30
};

Set Theory Perspective

Now, from a set theory standpoint, the names "Union" and "Intersection" might seem reversed, but here's how to understand them:

  • Union (|): Refers to the union of two sets. In set theory, the union of two sets A and B is the set of elements which are in A, in B, or in both A and B. Hence in TypeScript, a value of type A | B can be any member of A, or any member of B (or both if the types have overlap).
  • Intersection (&): Refers to what is common between two sets. In set theory, the intersection of two sets A and B is the set that contains all elements of A that also belong to B (or equivalently, all elements of B that also belong to A). In TypeScript's type system, when you do A & B, you're taking what's common (and extending) between the type definitions, effectively combining them.

Type Aliases

Type aliases in TypeScript allow you to create a new name for a type. It can represent a primitive type, union type, and any other types that you would otherwise have to write out by hand. Type aliases are created using the type keyword and are particularly useful for ensuring consistent usage of complex types across your codebase, enhancing readability and maintainability.

type StringOrNumber = string | number; // Union Type Alias
type Coordinate = { x: number; y: number; }; // Object Type Alias
type Callback = () => void; // Function Type Alias

In this example, StringOrNumber is a type alias for a union type, Coordinate is a type alias for an object type representing a point in a 2D space, and Callback is a type alias for a function type that returns no value.

By using type aliases, developers can avoid rewriting complex type annotations, make the code more self-explanatory, and simplify any future changes to the type definition, as it needs to be updated in only one place.

Structural Type System

TypeScript employs a structural type system, which focuses on the shape that values have. This system is more concerned with the properties and methods of a value, rather than its nominal type or the name of the type it was declared with.

Rethinking Types as Sets

In TypeScript’s structural type system, types can be understood as sets of values. A type is considered a set of possible values that it can hold, and a value is a member of a type if it has all the properties that the type expects, with appropriate types of their own. This is a significant shift from nominal typing systems where types are associated with names and the relationship between them is determined by explicit declarations.

Erased Structural Types

TypeScript’s structural types are erased during the compilation process, and do not exist at runtime. This means that the type information is used solely for type checking during development and is not available for reflection or any runtime type operations. This design choice allows TypeScript to stay close to JavaScript’s runtime behavior while providing a robust type system for development.

The erasure of structural types means that TypeScript does not support reflection based on type information, as the type annotations do not exist at runtime. This can be a limitation when developers need to perform operations based on type information at runtime.

Consequences of Structural Typing

Since structural typing is based on the properties of types, an object with no properties can be assigned to any type, making empty objects assignable to anything.

let empty = {};
let person: { name: string, age: number } = empty; // No Error

Two different types with identical properties are considered the same type in a structural type system. This allows for more flexible and intuitive type assignments.

type A = { id: number };
type B = { id: number };
let a: A = { id: 1 };
let b: B = a; // No Error, A and B are structurally identical

Enums

Enums, short for "enumerations", are a feature in TypeScript that allows for defining named constants, making code more readable and expressive. They are particularly useful when representing a fixed set of related values, like days of the week, colors, or directions.

Basic Usage

In TypeScript, you define an enum using the enum keyword:

enum Days {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday
}

let today: Days = Days.Friday;

By default, the first value of an enum starts at 0, and each subsequent value is incremented by one. In the example above, Days.Monday would have a value of 0, Days.Tuesday would have a value of 1, and so on.

Custom Values

You can also assign custom values to enums:

enum Colors {
    Red = "#FF0000",
    Green = "#00FF00",
    Blue = "#0000FF"
}

let favoriteColor: Colors = Colors.Green;

Using Enums

Enums can help improve code clarity. Instead of using hard-coded values, you can use descriptive names:

function scheduleMeeting(day: Days) {
    if (day === Days.Saturday || day === Days.Sunday) {
        console.log('Weekends are not suitable for meetings.');
    } else {
        console.log(`Scheduled meeting on ${Days[day]}`);
    }
}

scheduleMeeting(Days.Monday);  // Output: Scheduled meeting on Monday

Computed and Constant Members

Enums can also have computed members:

enum FileAccess {
    None,
    Read = 1 << 1,
    Write = 1 << 2,
    ReadWrite = Read | Write
}

In the above enum, Read, Write, and ReadWrite are computed members, while None is a constant.

Abstract Classes

Abstract classes are base classes from which other classes may be derived. They may not be instantiated directly and may contain implementation details for their members. The abstract keyword is used to define abstract classes and abstract methods within them.

abstract class Animal {
  abstract makeSound(): void; // must be implemented by any derived class

  move(): void {
    console.log('Roaming the earth...');
  }
}

Generics

Generics provide a way to create reusable components which can work over a variety of types rather than a single one. Generics are particularly useful when dealing with data structures, allowing them to work with any type while maintaining type safety.

class Box<T> {
  contents: T;

  constructor(contents: T) {
    this.contents = contents;
  }
}

const stringBox = new Box('Hello, World!');
const numberBox = new Box(100);

Tuples

In TypeScript, a tuple is a special type that allows you to create an array where the type of a fixed number of elements is known, but need not be the same. It enables you to represent a value as a combination of different types.

let student: [string, number];
student = ['John Doe', 22]; // OK
student = [22, 'John Doe']; // Error

In the example above, the student tuple has a string as its first element and a number as its second element. Assigning values in a different order or introducing elements of a different type would result in an error.

Using Tuples

Tuples are useful when you want to create a quick, fixed-size collection of elements of varied types without creating a class or interface. For example, if you want to represent a Point in a 2D space, you can use a tuple of two numbers:

type Point = [number, number];
let coordinate: Point = [10, 20];

Accessing Tuple Elements

You can access the elements of a tuple using indexing, just like an array. However, accessing an element outside the known indices will result in an error.

console.log(coordinate[0]); // 10
console.log(coordinate[1]); // 20
console.log(coordinate[2]); // Error: Tuple type '[number, number]' of length '2' has no element at index '2'.

Tuple with Optional and Rest Elements

TypeScript also allows you to have optional elements in a tuple, represented using the ? symbol, and rest elements, represented using the ... symbol, allowing more flexibility in handling tuples with varying lengths.

type Person = [string, number?];
let john: Person = ['John']; // OK
let jane: Person = ['Jane', 22]; // OK

type Numbers = [number, ...number[]];
let numbers: Numbers = [1, 2, 3, 4]; // OK

In this example, Person is a tuple with an optional second element, allowing it to either have one or two elements, and Numbers is a tuple with a rest element, allowing it to have any number of elements, with at least one.

Tuples, with their ability to represent multiple types in a single, ordered collection, provide a way to store related values without creating a structured type, making them a handy feature in TypeScript’s type system.

Decorators

Decorators are an advanced topic beyond the scope of this course. However, you may work with frameworks and libraries that make use of decorators. Therefore, we will briefly discuss them.

Decorators provide a way to add annotations and a meta-programming syntax for class declarations and members. Decorators are denoted by the @ symbol and can be used to modify classes, properties, methods, and parameters.

function sealed(target: any) {
  Object.seal(target);
  Object.seal(target.prototype);
}

function enumerable(value: boolean) {
  return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;
  };
}

@sealed // Class Decorator
class Greeter {
  greeting: string;

  constructor(message: string) {
    this.greeting = message;
  }

  @enumerable(false) // Method Decorator
  greet() {
    return 'Hello, ' + this.greeting;
  }
}