Skip to content

TS5: Generic Types

Chapter 12: Generic types

  • Generic types are placeholders for types that are resolved when a class or function is used

Generic Type parameters classes

  • generic type parameters are used to define a type that is resolved at runtime and results in a generic class.
// generic typed classes
type PersonT7 = {
    id: string;
    name: string;
    age: number;
};

type ProductT7 = {
    id: string;
    name: string;
    price: number;
};

type GenericDataCollectionBase = {
    id: string;
};
class DataCollection<T extends GenericDataCollectionBase> {
    // T is `generic type parameter`
    constructor(private data: T[]) {}
    get(id: string) {
        return this.data.find((d) => d.id === id);
    }
    add(data: T) {
        this.data.push(data);
    }
}

const peopleCollection = new DataCollection<PersonT7>([]);
const productCollection = new DataCollection<ProductT7>([]);

Constraining Generic Type Values

  • you can add constraints on the generic type parameter using the extends keyword, only types that satisfy the constraints will be considered as valid values for the generic type parameter
class DataCollection<T extends { id: string }> {}
// only objects that have id property of type string can be used as generic type parameter
// other properties does not matter, the constraint is only on the id property

class DataCollection<T extends Person | PRoduct> {}
// only Person and Product types can be used as generic type parameter
  • the extends keyword constrains the types that can be assigned to the type parameter.
  • the generic type parameter restricts the types that can be used by a specific instance of the class.

Infer Type Arguments

  • The TypeScript compiler can infer generic type arguments based on the way that objects are created or methods are invoked.

  • the type of the generic type parameter is defined in the class declaration, and can be resolved from:

    • explicitly during the class instantiation
    • implicitly (inferred) depending on the context of the class instantiation (eg. the type of the arguments passed to the class constructor)
const peopleCollection = new DataCollection<PersonT7>([]); // explicitly pass the type of the generic type parameter

// implicitly resolve the type of the generic type parameter
const peopleCollection2 = new DataCollection([{ id: "1", name: "John", age: 30 }] as PersonT7[]);
// compiler can tell that peopleCollection2 is a DataCollection<PersonT7> since the
//      type of the argument that passed to the constructor is PersonT7[]

Extending Generic Classes

  • A generic class can be extended, and the subclass can choose to deal with the generic type parameters in several ways:

Adding Extra Features to the Existing Type Parameters

  • simply add features to those defined by the superclass using the same generic types
// Adding Extra Features to the Existing Type Parameters
class DataCollectionT2<T extends { name: string }> {
    protected items: T[] = []; // protected allows items to be accessed by the subclasses
    constructor(initialItems: T[]) {
        this.items.push(...initialItems);
    }
    collate<U>(targetData: U[], itemProp: string, targetProp: string): (T & U)[] {
        let results: (T & U)[] = [];
        this.items.forEach((item) => {
            let match = targetData.find((d) => d[targetProp] === item[itemProp]);
            if (match !== undefined) {
                results.push({ ...match, ...item });
            }
        });
        return results;
    }
}

// the subclass SearchableCollectionT2 passes the type of the generic type parameter to the superclass
// the subclass adds extra functionality to the superclass by adding a new `find` method that works on the same generic type parameter
class SearchableCollectionT2<T extends { name: string }> extends DataCollectionT2<T> {
    constructor(initialItems: T[]) {
        super(initialItems);
    }
    find(name: string): T | undefined {
        return this.items.find((item) => item.name === name);
    }
}
  • we just passed the T type parameter to the superclass, and the subclass can use the same type parameter, then the subclass builds on top of the functionality of the superclass by adding extra features acting on the same type

Fixing the Generic Type Parameter

  • if the superclass is generic, and the subclass is not, then the subclass can fix the generic type parameter by passing the type of the superclass to the subclass.
  • the subclass restricts the type of the superclass to the same type as the subclass or even to a specific type (fixed type).
// Fixing the Generic Type Parameter
type PreProductData = {
    id?: string;
    name?: string;
    price?: number;
};

// coverts data to a shape that is acceptable by the superclass
const processProductData = (data: PreProductData[]): ProductT7[] => {
    let results: ProductT7[] = [];
    data.forEach((item) => {
        if (item.id) {
            results.push({
                id: item.id,
                name: item.name || "N/A",
                price: item.price || 0,
            });
        }
    });
    return results;
};

// the subclass expect a generic type T, but it passes a fixed type `ProductT7` to the superclass
// it is the responsibility of the subclass to ensure that the type of the generic type parameter is compatible with the type of the superclass
// so, it is the responsibility of this class to process `PreProductData` and convert it to `ProductT7`
class SearchableProductsCollections extends DataCollection<ProductT7> {
    items: ProductT7[];
    _preProcessedData: PreProductData[];
    constructor(initialItems: PreProductData[]) {
        const productData = processProductData(initialItems);
        super(productData);
        this._preProcessedData = initialItems;
    }
    find(name: string): ProductT7 | undefined {
        return this.items.find((item) => item.name === name);
    }
}

Restricting the Generic Type Parameter

  • the subclass restricts the supercalss to only a subset of the types that can be used by the superclass
// Restricting the Generic Type Parameter
class SearchableCollection<T extends Employee | Person> extends DataCollection<T> {}
// the subclass restricts the types that can be used on the superclass to only Employee and Person

Type Guarding Generic Types

  • helpful in collections that may have different types or classes of items, to extract only one type of items
class Product {}
class Person {}

class DataCollection<T extends { id: string }> {
    constructor(private items: T[]) {}
    get(id: string) {
        return this.items.find((d) => d.id === id);
    }
    filter<V extends T>(): V[] {
        // restricting the type of the generic type parameter to only those types that are assignable to T
        // this narrows the type V to the exact type T that is passed during the instantiation of the class
        return this.items.filter((item) => item instanceof V) as V[];
    }
}

const peopleCollection = new DataCollection<Person | Products>([
    new Person({ id: "1", name: "John", age: 30 }), // person
    new Person({ id: "2", name: "Mary", age: 25 }), // person
    new Product({ id: "55", name: "Mike", price: 3.55 }), // product
]);

const productsOnly = peopleCollection.filter<Product>(); // get only products
  • if normal types aliases used instead of classes, the filter function above can be defined as predicate function to filter out types
type Product {} // type used instead of class
type Person {} // type used instead of class

class DataCollection<T extends { id: string }> {
    constructor(private items: T[]) {}
    get(id: string) {
        return this.items.find((d) => d.id === id);
    }
    filter<V extends T>(predicate: (target: any) => target is V): V[] {
        // calling the predicate function on all items in the collection to filter out types
        return this.items.filter((item) => predicate(item)) as V[];
    }
}

const peopleCollection = new DataCollection<Person | Products>([
    { id: "1", name: "John", age: 30 }, // person
    { id: "2", name: "Mary", age: 25 }, // person
    { id: "55", name: "Mike", price: 3.55 }, // product
]);

const productsOnly = peopleCollection.filter<Product>((it: any): it is Product => {
    return Boolean(it.price); // predicate function
}); // get only products

Generic types for static methods and properties

  • Only instance properties and methods have a generic type, which can be different for each object.
  • static properties and methods are accessible all over the class and cannot be restricted to a specific type (that is being passed by the generic type parameter).
  • you are still able to provide generic type parameters for static properties and methods, but these types must be separate from the class type parameters.
class DataCollection<T extends { id: string }> {
    static reverse<U>(items: U[]): U[] {
        return items.reverse();
    }
}
const arr = [1, 2, 3];
const reversed = DataCollection.reverse<number>(arr);

Generic Interfaces

  • define a generic interface by supplying a type parameter
type shapeType = { name: string };
interface ICollection<T extends shapeType> {
    add(...newItems: T[]): void;
    get(name: string): T;
    count: number;
}
  • extend the generic interface
type shapeType = { name: string };
interface ICollection<T extends shapeType> {
    add(...newItems: T[]): void;
    get(name: string): T;
    count: number;
}

// both IExtendedCollection and ICollection are generic interfaces
// both must satisfy the constraints on the type parameter T (to extend shapeType)
interface IExtendedCollection<T extends shapeType> extends ICollection<T> {
    remove(name: string): void;
}
  • a class may implement a generic interface by providing a the type parameter to the interface, in one of the following ways:
// 1. Passing on the Generic Type Parameter to the Interface
// the class type `CT` must satisfy the same constraints as the type parameter of the interface (extends shapeType)
class ExtendedCollection<CT extends shapeType> implements IExtendedCollection<CT> {
    /*...*/
}

// 2. Restricting or Fixing the Generic Type Parameter
// restricting
class ExtendedCollection<CT extends shapeType> implements IExtendedCollection<Person | Employee> {
    /*...*/
}

// fixing
class ExtendedCollection<CT extends shapeType> implements IExtendedCollection<Person> {
    /*...*/
}

Chapter 13: Advanced Generic Types

Generic Collections

  • TypeScript provides support for using the JavaScript collections with generic type parameters
  • this includes:
    • Map<K, V>
    • ReadonlyMap<K, V>
    • Set<T>
    • ReadonlySet<T>

Generic Iterators

  • iterators allow a sequence of values to be enumerated
  • support for iterators is a common feature for classes that operate on other types, such as collections
  • TypeScript provides the interfaces for describing iterators and their results:
    • Iterator<T>: This interface describes an iterator whose next method returns IteratorResult<T> objects
    • IteratorResult<T>: This interface describes the result of an iterator next method as { value: T, done: boolean }
    • Iterable<T>: This interface describes an iterable object that has Symbol.iterator property and supports iteration directly
    • IterableIterator<T>: This interface combines the Iterable<T> and Iterator<T> interfaces to describe an object that has Symbol.iterator property and defines next() and result property
// iterable
class Collection<T extends { name: string }> implements Iterable<T> {
    private items: Map<string, T>;
    constructor(initialItems: T[] = []) {
        this.items = new Map<string, T>();
        this.add(...initialItems);
    }
    add(...newItems: T[]): void {
        newItems.forEach((newItem) => this.items.set(newItem.name, newItem));
    }
    get(name: string): T | undefined {
        return this.items.get(name);
    }
    get count(): number {
        return this.items.size;
    }
    [Symbol.iterator](): Iterator<T> {
        return this.items.values();
    }
}

const pc = new Collection<ProductT7>([{ id: "1", name: "Product 1", price: 100 }]);
[...pc].forEach((item) => console.log(item.name)); // iterating through the collection using `Symbol.iterator`

Index Type Query: keyof T

  • The keyof keyword, known as the index type query operator, returns a union of the property names of a type, using the literal value type feature.
function getValue<T, K extends keyof T>(item: T, keyname: K) {
    console.log(`Value: ${item[keyname]}`);
}

Indexed Access Operator: T[K] or T[keyof T]

  • The indexed access operator is expressed using square brackets following a type so that Product["price"] // number
  • it can be used as Product[keyof Product] which will return a union of types of all properties in the Product type
  • The types returned by the indexed access operator are known as lookup types.
type Product = { id: string; name: string; price: number };
type PriceT = Product["price"]; // number
type AllT = Product[keyof Product]; // number | string

// compiler now can tell the type of T[K] dynamically, which fixes the type of this `getValue` function
function getValue<T, K extends keyof T>(item: T, keyname: K): T[K] {
    return item[keyname];
}
  • using index access operator we can make the collection class generic, allowing to specify the index name for each object separately
class Collection<T, K extends keyof T> implements Iterable<T> {
    private items: Map<T[K], T>;
    constructor(initialItems: T[] = [], private propertyName: K) {
        this.items = new Map<T[K], T>();
        this.add(...initialItems);
    }
    add(...newItems: T[]): void {
        newItems.forEach((newItem) => this.items.set(newItem[this.propertyName], newItem));
    }
    get(key: T[K]): T {
        return this.items.get(key);
    }
    get count(): number {
        return this.items.size;
    }
    [Symbol.iterator](): Iterator<T> {
        return this.items.values();
    }
}

// indexing by price property, explicitly specifying the index name and type
const pc = new Collection<Product, "price">([{ id: "1", name: "Product 1", price: 100 }], "price");
console.log(pc.get(100)); // Product 1

// indexing by id property, notice T and K are inferred by the compiler during constructing the object
const pc2 = new Collection([{ id: "1", name: "Product 1", price: 100 }], "id");
console.log(pc2.get("1")); // Product 1

Type Mapping

  • Mapped types are created by applying a transformation to the properties of an existing type.
type MappedProduct = {
    [P in keyof Product]: Product[P];
    // [Name Selector]: Type selector;
};
  • The property name selector defines a type parameter, named P in this example, and uses the in keyword to enumerate the types in a literal value union.

Changing Mapping Names and Types

type Product = { id: string; name: string; price: number };
type AllowStrings = {
    [P in keyof Product]: Product[P] | string;
    // added | string to the types of all properties in the Product type
};

type ChangeNames = {
    [P in keyof Product as `${P}Property`]: Product[P];
    // changed the property names in the Product type to `${P}Property` while preserving the original type
};

// and the results as:
type ChangeNames = {
    idProperty: string;
    nameProperty: string;
    priceProperty: number;
    // changed the property names in the Product type to `${P}Property` while preserving the original type
};

type AllowStrings = {
    id: string;
    name: string;
    price: number | string;
    // added | string to the types of all properties in the Product type
};

Using a Generic Type Parameter with a Mapped Type

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

type MappedProduct = Mapped<Product>; // { id: string; name: string; price: number }

Changing Property Optionality and Mutability

  • Mapped types can change properties to make them optional or required and to add or remove the readonly keyword,
type Product = { readonly id: string; name?: string; price: number };
type MakeOptional<T> = {
    [P in keyof T]?: T[P];
    // added ? to the types of all properties in the Product type
};
type MakeRequired<T> = {
    [P in keyof T]-?: T[P];
    // removed ? from the types of all properties in the Product type
};
type MakeReadOnly<T> = {
    readonly [P in keyof T]: T[P];
    // added readonly to the types of all properties in the Product type
};
type MakeReadWrite<T> = {
    -readonly [P in keyof T]: T[P];
    // removed readonly from the types of all properties in the Product type
};
type optionalType = MakeOptional<Product>; // { id?: string; name?: string; price?: number }
type requiredType = MakeRequired<optionalType>; // { id: string; name: string; price: number }
type readOnlyType = MakeReadOnly<requiredType>; // { readonly id: string; readonly name: string; readonly price: number }
type readWriteType = MakeReadWrite<readOnlyType>; // { id: string; name: string; price: number }
  • ? after the property name will make it optional, opposite of -?
  • -? after the property name will make it required, opposite of ?
  • readonly after the property name will make it readonly, opposite of -readonly
  • -readonly after the property name will make it readWrite, opposite of readonly
  • The types produced by mappings can be fed into other mappings, creating a chain of transformations.

Basic Built-in Mappings

  • Partial<T>: This mapping makes all properties optional.
  • Required<T>: This mapping makes all properties required.
  • Readonly<T>: This mapping makes all properties readonly.
  • Pick<T, K extends keyof T>: creates a new type with only the properties in the union K.
  • Omit<T, K extends keyof T>: creates a new type with all properties in T except those in K.
  • Record<K extends keyof T, T[K]>: creates a new type with properties K and values T[K].

Mapping Specific Properties

type Product = { id: string; name: string; price: number; tax?: number };

type SelectProperties<T, K extends keyof T> = {
    [P in K]: T[P];
    // K can be a union of property names, then those properties will be selected
};

type IdAndPrice = SelectProperties<Product, "id" | "price">; // { id: string; price: number }
type IdAndPrice2 = Pick<Product, "id" | "price">; // { id: string; price: number } //built-in mapping
type IdAndPrice3 = Omit<Product, "tax" | "name">; // { id: string; price: number } //built-in mapping

Combining Transformations in a Single Mapping

  • custom or built-in mappings can be combined with other mappings to create a new mapping
type CustomMapped<T, K extends keyof T> = {
    readonly [P in K]?: T[P];
    // selected specific properties, made them optional then readonly
};

// selected specific properties, made them optional then readonly
// built-in
type BuiltInMapped<T, K extends keyof T> = Readonly<Partial<Pick<T, K>>>;

Conditional Types

  • Conditional types are expressions containing generic type parameters that are evaluated to select new types.
type resultType<T extends boolean> = T extends true ? string : number;
// type resultType<TypeParameter> = Expression ? Type1 : Type2;

resultType<true>; // string
resultType<false>; // number

type ConditionalType<T extends unknown, U extends unknown> = T extends unknown ? U : never;

Nesting Conditional Types

  • More complex combinations of types can be described by nesting conditional types. A conditional type’s result types can be another conditional type, and the compiler will follow the chain of expressions until it reaches a result that isn’t conditional,
type references = "London" | "Bob" | "Kayak";
type nestedType<T extends references> = T extends "London" ? City : T extends "Bob" ? Person : Product;

const x = nestedType<"London">; // City
const y = nestedType<"Bob">; // Person
const z = nestedType<"Kayak">; // Product

Built-in Distributive Conditional Types

  • Exclude<T, U>: This type excludes the types that can be assigned to U from T
  • Extract<T, U>: This type extracts the types that can be assigned to U from T
  • NonNullable<T>: This type excludes null and undefined from T

Using Conditional Types in Type Mappings

type Product = { readonly id: string; name?: string; price: number };
type changeProps<T, U, V> = {
    [P in keyof T]: T[P] extends U ? V : T[P]; // if T[P] is U, then V, else T[P]
    // change all properties with type U to type V
};
type modifiedProduct = changeProps<Product, number, string>;
// change all number types to strings, { id: string; name?: string; price: string }

Identifying Properties of a Specific Type

type Product = { readonly id: string; name?: string; price: number };
type unionOfTypeNames<T, U> = {
    [P in keyof T]: T[P] extends U ? P : never; // all properties of T that are of type U or never
    // construct a new type,  with all properties of T that are of type U
};

type propertiesOfType<T, U> = unionOfTypeNames<T, U>[keyof T]; // get a union of all properties of T that are of type U

type _t1 = unionOfTypeNames<Product, number>; // { id: never, name: never, price: 'price' }
type _t2 = unionOfTypeNames<Product, string>; //  { id: 'id', name: 'name', price: never }
type _t3 = unionOfTypeNames<Product, string | number>; // { id: 'id', name: 'name', price: 'price' }

type t1 = propertiesOfType<Product, string>; // "id" | "name"
type t2 = propertiesOfType<Product, number>; // "price"
type t3 = propertiesOfType<Product, string | number>; // "id" | "name" | "price"

never is automatically removed from unions

Inferring Additional Types in Conditions

  • The TypeScript infer keyword can be used to infer types that are not explicitly expressed in the parameters of a conditional type.
type targetKeys<T> = T extends (infer U)[] ? keyof U : keyof T;
// infer U as an array of T, and if T extends U[] = T[] then return keyof U (array index as 0,1,2,...)
// if the inferred U is not an array, then return keyof T (object property as "id", "name", "price")

type t1 = targetKeys<Product[]>; // 0 | 1 | 2
type t2 = targetKeys<Product>; // "id" | "name" | "price"

function getValue<T, P extends targetKeys<T>>(data: T, propName: P): T[P] {
    if (Array.isArray(data)) {
        return data[0][propName]; // without `targetKeys<T>` the compiler will complain that 0 can not be assigned to keyof T
    } else {
        return data[propName];
    }
}

Inferring Types of Functions

type Result<T> = T extends (...args: any) => infer R ? R : never;
// if T extends a function type, infer R as the return type of the function

function processArray<T, Func extends (T) => any>(data: T[], func: Func): Result<Func>[] {
    return data.map((item) => func(item));
}

let selectName = (p: Product) => p.name;
const products31 = [
    { id: "1", name: "Product 1", price: 100 },
    { id: "2", name: "Product 2", price: 200 },
];

let names = processArray(products31, selectName); // names: ["Product 1", "Product 2"] are of type string[]
// the return type of processArray is inferred as Result<(T) => any>[]

names.forEach((name) => console.log(`Name: ${name}`));

Built-in Type Inference of Functions

  • Parameters<FunctionT>: This conditional type selects the types of each function parameter, expressed as a tuple.
  • ReturnType<FunctionT> : This conditional type selects the function result type, combined with Awaited<ReturnType<Function>> to select the awaited type of the function.
  • ConstructorParameters<ClassT | ConstructorFunctionT>: The conditional type selects the types of each parameter of a constructor function, expressed as a tuple
  • InstanceType<ClassT | ConstructorFunctionT> : returns the result type of the constructor function.