Skip to main content

generics-generic-interfaces-and-classes

12. Generics: Generic Interfaces and Classes

- Explore how interfaces and classes can be made generic for creating type-safe and reusable code structures.

This structure aims to give developers a deep dive into the foundational elements of TypeScript, while also covering the key aspects that make TypeScript a powerful addition to JavaScript.

Absolutely, diving into generics, especially as they pertain to interfaces and classes, can significantly deepen your TypeScript expertise. Here are 20 key points that every developer should know:

Generics in TypeScript:

  1. Understanding Generics:

    Generics act as placeholders for types, enabling you to write flexible and reusable code that can work with any type, ensuring type safety without losing the ability to operate across different types.

function identity<T>(arg: T): T {
return arg;
}
  1. Basic Generic Syntax:

    The syntax for generics involves using a type variable in angle brackets, like <T>, which you can then use throughout your function or class to denote a type that will be provided later.

function genericFunction<T>(arg: T): T {
return arg;
}
  1. Generics in Interfaces:

    Interfaces can have generic type parameters, allowing you to create flexible interfaces that can adapt to various types.

interface GenericInterface<T> {
value: T;
add: (item: T) => void;
}
  1. Generic Constraints:

    Constraints limit the types that can be used in a generic function or class, typically using the extends keyword.

function logLength<T extends { length: number }>(arg: T): T {
console.log(arg.length);
return arg;
}
  1. Generics in Classes:

    Classes can use generics to operate on generic types while still maintaining type safety, perfect for creating data structures.

class GenericClass<T> {
private data: T[];

constructor() {
this.data = [];
}

addItem(item: T): void {
this.data.push(item);
}
}
  1. Generic Factories:

    Factory functions or classes can utilize generics to ensure that the instances they create conform to a specific type.

function createInstance<T>(c: { new (): T }): T {
return new c();
}
  1. Multi-Type Generics:

    Generics can accept multiple types, allowing for operations on combinations of types in a type-safe manner.

function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
  1. Generics in Method Signatures:

    Methods within a class or interface can have their own generics, which can be independent of the class's type parameters.

class GenericMethodClass {
method<T>(arg: T): T {
return arg;
}
}
  1. Default Generics:

    TypeScript allows for default type parameters in generics, providing a default type if none is explicitly provided.

function createArray<T = string>(length: number, value: T): T[] {
return new Array(length).fill(value);
}
  1. Conditional Types with Generics:

    Conditional types use generics to create types that depend on conditions applied to the generics.

type ConditionalType<T> = T extends string ? string[] : T[];

Generics are one of the most powerful features in TypeScript, enabling you to write highly reusable and maintainable code that remains type-safe. They are fundamental to many advanced patterns and structures in TypeScript, including React components, state management libraries, and more.

  1. Keyof and Generics:

    The keyof type operator can be used in conjunction with generics to query the keys of a type, making it easier to create strongly-typed functions like getters and setters.

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

let x = { a: 1, b: 2, c: 3, d: 4 };
let value = getProperty(x, "a"); // value will be of type number
  1. Mapped Types and Generics:

    Mapped types can also use generics, which allows you to create new types based on the properties of an existing type.

type Mapped<T> = {
[P in keyof T]: T[P];
};

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

type PersonProps = Mapped<Person>; // Equivalent to Person
  1. Type Inference with Generics:

    TypeScript is capable of automatically inferring the type of a generic when it's not explicitly provided, based on how the generic is used.

function merge<T, U>(obj1: T, obj2: U) {
return {
...obj1,
...obj2
};
}

let result = merge({ name: 'John' }, { age: 30 });
// result will have the type { name: string; age: number; }
  1. Generics with Utility Types:

    Utility types such as Partial, Readonly, and Pick can also accept generic types, making them more flexible.

type User = {
id: number;
name: string;
email: string;
};

type UserWithoutEmail = Omit<User, 'email'>; // Utility type removing 'email' property
  1. Generic Decorators:

    In TypeScript, decorators can also be made generic, providing a way to add metadata or modify behaviors based on types.

function Log<T>() {
return function (target: T) {
// Decorator logic here
};
}

@Log()
class MyClass { }
  1. Generic Function Overloads:

    Function overloading can be used in tandem with generics, allowing multiple type signatures for a single function, each with different generic types.

function makeArray<T>(item: T): T[];
function makeArray<T>(items: T[]): T[];
function makeArray<T>(arg: any): T[] {
if (Array.isArray(arg)) {
return arg;
}
return [arg];
}
  1. Higher-Order Generics:

    Generics can be nested to create higher-order generic types. This is advanced but offers high flexibility in complex scenarios.

type Container<T> = { value: T };
type Transform<T, U> = (c: Container<T>) => Container<U>;

function stringToNumber(transform: Transform<string, number>): Container<number> {
return transform({ value: "42" });
}
  1. Discriminated Generics:

    Generics can be used in discriminated unions to maintain type safety while allowing for variations in type.

interface GenericResponse<T> {
type: "success" | "error";
data?: T;
message?: string;
}

function handleResponse<T>(response: GenericResponse<T>) {
if (response.type === "success") {
// response.data is available here
} else {
// response.message is available here
}
}
  1. Mixing Generics and Non-Generics:

    In classes and interfaces, it's possible to have both generic and non-generic methods and properties, offering a blend of flexibility and strictness.

class Repository<T> {
add(item: T): void {
// add item to repository
}

findById(id: number): T | undefined {
// find item by id
return;
}

getAll(): T[] {
// get all items
return [];
}
}
  1. Best Practices with Generics:

    Adhering to best practices, like clear naming conventions and understanding when not to use generics, can make your code more maintainable and understandable.

// Use T for the main type parameter
function identity<T>(arg: T): T {
return arg;
}

// Use descriptive names for multiple and related types
function merge<TData, TResult>(data: TData, result: TResult): TData & TResult {
return { ...data, ...result };
}

// Avoid generics when they do not improve the type safety or readability of your code
function greet(name: string): string {
return `Hello, ${name}!`;
}

21. Using Type Predicates:

Type predicates are a way to provide type information to the TypeScript compiler in user-defined type guard functions.

function isNumber(value: unknown): value is number {
return typeof value === 'number';
}

let value: unknown = 5;

if (isNumber(value)) {
console.log(value.toFixed(2)); // The compiler knows that value is a number here.
}

22. Generic Constraints:

Constraints allow you to limit the types that can be used as arguments for a generic type.

function logProperty<T extends object, K extends keyof T>(obj: T, key: K) {
let value = obj[key];
console.log(value);
}

logProperty({ name: 'Jane' }, 'name'); // Works fine.
logProperty({ name: 'Jane' }, 'age'); // Error: Argument of type '"age"' is not assignable to parameter of type '"name"'.

23. Using typeof for Type Guards:

The typeof operator can be used for type guarding to check the type of a variable at runtime.

function padLeft(value: string, padding: string | number) {
if (typeof padding === 'number') {
return Array(padding + 1).join(' ') + value;
}
return padding + value;
}

console.log(padLeft("Hello world", 4)); // Outputs: " Hello world"
console.log(padLeft("Hello world", "_")); // Outputs: "_Hello world"

24. Using in for Type Guards:

The in operator checks for the presence of a property on an object and can act as a type guard.

interface Bird {
fly(): void;
}

interface Fish {
swim(): void;
}

function move(pet: Bird | Fish) {
if ('fly' in pet) {
return pet.fly();
}
if ('swim' in pet) {
return pet.swim();
}
}

let bird: Bird = {
fly() {
console.log("Flying");
},
};

move(bird); // Calls fly()

25. Default Generic Type:

You can provide a default type for a generic parameter to be used when no explicit type is provided.

function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}

let strings = createArray(3, 'hello'); // Type is inferred to be 'string[]'
let numbers = createArray<number>(3, 10); // Type is explicitly set to 'number[]'

26. Using as for Type Assertions:

The as syntax in TypeScript allows you to assert the type of a variable, overriding TypeScript's inferred type.

let someValue: unknown = "this is a string";

let strLength = (someValue as string).length;
console.log(strLength); // Outputs: 16

27. Union Types and Function Overloading:

Union types and function overloads can be used together to provide different implementations based on the input type.

function formatInput(input: string): string;
function formatInput(input: number): number;
function formatInput(input: string | number) {
if (typeof input === 'string') {
return `String: ${input}`;
}
return `Number: ${input}`;
}

console.log(formatInput('Hi')); // Outputs: "String: Hi"
console.log(formatInput(42)); // Outputs: "Number: 42"

Understanding these concepts and variations can help in demonstrating a solid grasp of TypeScript's type system during an interview.