Enhancing Your TypeScript Skills: Top 10 Concepts

TypeScript is an advanced programming language that many developers favor over JavaScript due to its enhanced type safety features. In this piece, I will outline the ten essential TypeScript concepts that can elevate your programming abilities. Are you prepared? Let’s dive in!
We get lots of complaints about it actually, with people regularly asking us things like:
1. Generics
Generics allow us to create flexible and reusable types, which are invaluable for managing both current and future data.
Generics Example: Consider a TypeScript function that accepts an argument of any type and returns the same type:
function func<T>(args: T): T {
return args;
}
2. Generics with Type Constraints
We can refine the type T
to only accept strings and numbers:
function func<T>(args: T): T {
return args;
}
const stringValue = func("Hello"); // Valid, T is string
const numberValue = func(42); // Valid, T is number
3. Generic Interfaces
Generic interfaces are beneficial for defining contracts (or shapes) for objects, classes, or functions that can work with various types. They provide a consistent structure while adapting to different data types.
interface Repository<T, U> {
items: T[]; // Array of items of type T
add(item: T): void; // Function to add an item of type T
getById(id: U): T | undefined; // Function to retrieve an item by ID of type U
}
// Implementing the Repository interface for a User entity
interface User {
id: number;
name: string;
}
class UserRepository implements Repository<User, number> {
items: User[] = [];
add(item: User): void {
this.items.push(item);
}
getById(idOrName: number | string): User | undefined {
if (typeof idOrName === 'string') {
console.log('Searching by name:', idOrName);
return this.items.find(user => user.name === idOrName);
} else if (typeof idOrName === 'number') {
console.log('Searching by id:', idOrName);
return this.items.find(user => user.id === idOrName);
}
return undefined; // Return undefined if no match found
}
}
// Usage
const userRepo = new UserRepository();
userRepo.add({ id: 1, name: "Alice" });
userRepo.add({ id: 2, name: "Bob" });
const user1 = userRepo.getById(1);
const user2 = userRepo.getById("Bob");
console.log(user1); // Output: { id: 1, name: "Alice" }
console.log(user2); // Output: { id: 2, name: "Bob" }
4. Generic Classes
Use generic classes when you want all properties in your class to conform to the type specified by the generic parameter. This ensures flexibility while maintaining type consistency.
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;
}
// Method to retrieve user details
getUserDetails(): string {
return `User: ${this.name}, ID: ${this.id}, Age: ${this.age}`;
}
// Method to change user name
updateName(newName: string): void {
this.name = newName;
}
// Method to change user age
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"
5. Constraining Type Parameters
Sometimes, we want a parameter's type to depend on other parameters. This can be illustrated with the following example:
function getProperty<Type>(obj: Type, key: keyof Type) {
return obj[key];
}
let x = { a: 1, b: 2, c: 3 };
getProperty(x, "a"); // Valid
// getProperty(x, "d"); // Error: Argument of type '"d"' is not assignable to parameter of type '"a" | "b" | "c"'.
In a tightly coupled scenario, if you have a stressLevel
attribute in the MentalWellness
class today and decide to change it tomorrow, you would need to update all instances where it was used. This can lead to significant refactoring and maintenance challenges.
However, with dependency injection and the use of interfaces, you can avoid this problem. By passing dependencies (like the MentalWellness
service) through the constructor, the specific implementation details (such as the stressLevel
attribute) are abstracted behind the interface. This means that changes to the attribute or class do not require modifications in the dependent classes, as long as the interface remains unchanged. This approach ensures that the code is loosely coupled, more maintainable, and easier to test,
as you’re injecting what’s needed at runtime without tightly coupling components.