Skip to main content

Ten Typescript Concepts Everyone Should Know

· 8 min read
Sivabharathy

Learn about TypeScript generics, including their use in functions, classes, interfaces, and type constraints. This guide covers key concepts like type variance, conditional types, and how to create reusable, type-safe code for flexible applications. Explore advanced topics such as reflection, the infer keyword, and more.

1. Generics in TypeScript

Generics in TypeScript allow us to create reusable, type-safe code that can work with a variety of types. They enable us to write functions, classes, and interfaces that can operate on different data types without losing type information. This makes the code more flexible and reusable.

Example of Generics:

Let's start by writing a simple function that takes an argument of any type T and returns the same type:

function identity<T>(arg: T): T {
return arg;
}

const result1 = identity("Hello, world!"); // T is inferred as string
const result2 = identity(42); // T is inferred as number
console.log(result1, result2); // Output: "Hello, world!" 42

Here, the type T is a placeholder for any type. The function identity works for any type, and it returns that type. This is the essence of generics — making a function work for many types while keeping the types safe.

2. Generics with Type Constraints

Sometimes, we want to limit the types that a generic function can accept. Type constraints allow us to specify that a type parameter must extend from a certain type, or meet specific criteria. This ensures that we can only pass certain kinds of types into the generic function.

Example of Type Constraints:

function echo<T extends string | number>(value: T): T {
return value;
}

const stringValue = echo("Hello"); // Works, T is string
const numberValue = echo(42); // Works, T is number

// const booleanValue = echo(true); // Error: Type 'boolean' is not assignable to type 'string | number'

In this example, the generic function echo is constrained to accept only string or number types. If we try to pass a boolean or any other type, TypeScript will throw an error.

3. Generic Interfaces

Interfaces with generics allow us to define reusable contracts (shapes) for objects and classes that work with a variety of types. This is useful when you want to ensure that certain objects follow a consistent structure, but with flexibility in the type of data they hold.

Example of Generic Interface:

interface Repository<T, U> {
items: T[]; // Array of items of type T
add(item: T): void; // Method to add an item of type T
getById(id: U): T | undefined; // Method to get an item by ID of type U
}

interface User {
id: number;
name: string;
}

class UserRepository implements Repository<User, number> {
items: User[] = [];

add(item: User): void {
this.items.push(item);
}

getById(id: number): User | undefined {
return this.items.find((user) => user.id === id);
}
}

const userRepo = new UserRepository();
userRepo.add({ id: 1, name: "Alice" });
const user = userRepo.getById(1);
console.log(user); // Output: { id: 1, name: "Alice" }

Here, the Repository interface is defined as a generic interface with two type parameters: T (the type of items in the repository) and U (the type of the ID). The UserRepository class implements this interface for User objects, ensuring that it can handle items of type User and IDs of type number.

4. Generic Classes

Just like interfaces, classes can also be made generic, allowing the class to work with various types. When using generics in classes, all the properties and methods can be typed dynamically based on the provided type.

Example of a Generic Class:

interface User {
id: number;
name: string;
age: number;
}

class UserDetails<T extends User> {
id: T["id"];
name: T["name"];
age: T["age"];

constructor(user: T) {
this.id = user.id;
this.name = user.name;
this.age = user.age;
}

getUserDetails(): string {
return `User: ${this.name}, ID: ${this.id}, Age: ${this.age}`;
}

updateName(newName: string): void {
this.name = newName;
}

updateAge(newAge: number): void {
this.age = newAge;
}
}

// Using the UserDetails class with a User type
const user: User = { id: 1, name: "Alice", age: 30 };
const userDetails = new UserDetails(user);

console.log(userDetails.getUserDetails()); // Output: "User: Alice, ID: 1, Age: 30"

// Updating user details
userDetails.updateName("Bob");
userDetails.updateAge(35);

console.log(userDetails.getUserDetails()); // Output: "User: Bob, ID: 1, Age: 35"

In this example, the UserDetails class is defined as a generic class that expects a type T extending User. The class has properties and methods that are based on the User type, but it works with any object that conforms to User.

5. Constraining Type Parameters to Passed Types

At times, we may want to constrain the types based on other types passed to the function. This can be achieved using keyof and TypeScript's advanced type constraints.

Example:

function getProperty<Type, Key extends keyof Type>(
obj: Type,
key: Key
): Type[Key] {
return obj[key];
}

const person = { name: "Alice", age: 30, address: "123 Main St" };

const name = getProperty(person, "name"); // Works: type is string
const age = getProperty(person, "age"); // Works: type is number
// const invalid = getProperty(person, "email"); // Error: 'email' does not exist on type 'person'

In this case, getProperty is constrained to only accept keys that exist on the object Type. Trying to access a non-existent property (like "email") will result in an error.

6. Conditional Types

Conditional types allow us to define a type based on a condition. They work similarly to ternary operators, but for types.

Simple Example:

type IsString<T> = T extends string ? "String" : "Not String";

let result1: IsString<string>; // "String"
let result2: IsString<number>; // "Not String"

Advanced Example:

type HasProperty<T, K extends keyof T> = K extends "age"
? "Has Age"
: "Has Name";

interface User {
name: string;
age: number;
}

let test1: HasProperty<User, "age">; // "Has Age"
let test2: HasProperty<User, "name">; // "Has Name"

Here, the conditional type checks if a certain key ("age") exists in the type User. If it does, it returns "Has Age", otherwise it returns "Has Name".

7. Intersection Types

Intersection types allow you to combine multiple types into one. A value of an intersection type will have all properties from the combined types.

Example:

interface MentalWellness {
mindfulnessPractice: boolean;
stressLevel: number;
}

interface PhysicalWellness {
exerciseFrequency: string;
sleepDuration: number;
}

interface Productivity {
tasksCompleted: number;
focusLevel: number;
}

type HealthyBody = MentalWellness & PhysicalWellness & Productivity;

const person: HealthyBody = {
mindfulnessPractice: true,
stressLevel: 4,
exerciseFrequency: "daily",
sleepDuration: 7,
tasksCompleted: 15,
focusLevel: 8,
};

console.log(person);

In this example, the HealthyBody type combines the properties of MentalWellness, PhysicalWellness, and Productivity into a single type, which is then used to define a person object.

8. Infer Keyword

The infer keyword allows TypeScript to automatically infer a type within a conditional type. This is useful when you want to extract the type from a wrapped type, such as a Promise.

Example:

type ReturnTypeOfPromise<T> = T extends Promise<infer U> ? U : never;

type Result = ReturnTypeOfPromise<Promise<string>>; // Result is 'string'
type ErrorResult = ReturnTypeOfPromise<number>; // ErrorResult is 'never'

const result: Result = "Hello";
console.log(typeof result); // Output: 'string'

Here, the ReturnTypeOfPromise type uses infer to extract the type U from a Promise<T>. If the type T is not a Promise, it returns never.

9. Type Variance: Covariance and Contravariance

Type variance deals with how subtypes and supertypes relate to each other in terms of assignment compatibility. There are two main types of variance:

  • Covariance: A subtype can be used where a supertype is expected.
  • Contravariance: A supertype can be used where a subtype is expected.

Covariance Example:

class Vehicle {
start() {
console.log("Vehicle is running");
}
}

class Car extends Vehicle {
honk() {
console.log("Car honks");
}
}

function vehiclefunc(vehicle: Vehicle) {
vehicle.start();
}

function carfunc(car: Car) {
car.start();
car.honk();
}

let car: Car = new Car();
vehiclefunc(car); // Works due to covariance

In this case, Car is a subclass of Vehicle, so you can use a Car where a Vehicle is expected.

Contravariance Example:

class Vehicle {
startEngine() {
console.log("Vehicle engine starts");
}
}

class Car extends Vehicle {
honk() {
console.log("Car honks");
}
}

function processVehicle(vehicle: Vehicle) {
vehicle.startEngine();
}

function processCar(car: Car) {
car.startEngine();
car.honk();
}

let car: Car = new Car();
processVehicle(car); // Works due to contravariance

In this example, Car can be passed to a function that expects Vehicle, because of contravariance. However, if you expect specific subtype behavior (like calling honk()), it can lead to an error.

10. Reflection in TypeScript

TypeScript generally focuses on compile-time type checking, but it also provides runtime checks through operators like typeof and instanceof.

Example:

const num = 23;
console.log(typeof num); // "number"

const flag = true;
console.log(typeof flag); // "boolean"

class Vehicle {
model: string;
constructor(model: string) {
this.model = model;
}
}

const benz = new Vehicle("Mercedes-Benz");
console.log(benz instanceof Vehicle); // true

The typeof operator is used for primitive types, while instanceof is used to check if an object is an instance of a particular class.


Conclusion

TypeScript's type system, with features like generics, type constraints, conditional types, and type variance, allows you to write flexible, reusable, and type-safe code. By combining these features, we can create highly maintainable applications that are both powerful and flexible while avoiding runtime errors. The introduction of dependency injection and the use of interfaces also promote loose coupling and make your code more testable.