Generics in TypeScript are a bit of a challenge. In this article, you’ll learn how to use TypeScript generics in eight different ways.

If you’re still wondering, get started on the TypeScript generics journey!

Want to entry the TypeScript friend, Po brother specially for you – 1.2 W word | great TypeScript introductory tutorial (1027 + 👍) tutorial.

What are generics

In software engineering, we should not only create consistent, well-defined apis, but also consider reusability. Components can support not only current data types, but future data types as well, which gives you great flexibility when creating large systems.

In languages like C# and Java, you can use generics to create reusable components that can support multiple types of data. This allows users to use components with their own data types.

The key purpose of designing generics is to provide meaningful constraints between members: instance members of the class, methods of the class, function arguments, and function return values.

To help you understand the above, let’s take an example, where we will step by step reveal what generics can do. First let’s define a generic identity function that takes an argument and returns it directly:

function identity (value) {
  return value;
}

console.log(identity(1)) / / 1
Copy the code

Now, let’s adjust the identity function appropriately to support TypeScript arguments of type Number:

function identity (value: Number) : Number {
  return value;
}

console.log(identity(1)) / / 1
Copy the code

The problem with identity here is that we assign the Number type to the argument and return type, making the function available only to the primitive type. But the function is not extensible or generic, which is obviously not what we want.

We can indeed change the Number to any, we lose the ability to define which type should be returned, and in the process the compiler loses type protection. Our goal is to make the identity function applicable to any particular type. To achieve this goal, we can use generics to solve this problem, as follows:

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

console.log(identity<Number> (1)) / / 1
Copy the code

For readers new to TypeScript generics, the

syntax will be unfamiliar for the first time. But that’s nothing to worry about. Just like passing arguments, we passed the type we want to use for a particular function call.

Referring to the above image, when we call identity

(1), the Number type is just like the argument 1, and it will populate the type wherever T appears. The T inside the

diagram is called a type variable. It is the type placeholder we want to pass to the identity function, and it is assigned to the value argument in place of its type: T acts as the type here, not the specific type Number.

Where T stands for Type and is often used as the first Type variable name when defining generics. But actually T can be replaced by any valid name. In addition to T, here are the meanings of common generic variables:

  • K (Key) : indicates the Key type in the object;
  • V (Value) : indicates the Value type of an object.
  • E (Element) : indicates the Element type.

Instead of defining just one type variable, we can introduce as many type variables as we want. For example, we introduce a new type variable U that extends our defined identity function:

function identity <T.U> (value: T, message: U) : T {
  console.log(message);
  return value;
}

console.log(identity<Number.string> (68."Semlinker"));
Copy the code

In addition to explicitly setting values for type variables, a more common approach is to have the compiler automatically select these types, thus making the code more concise. We can leave out the Angle brackets entirely, as in:

function identity <T.U> (value: T, message: U) : T {
  console.log(message);
  return value;
}

console.log(identity(68."Semlinker"));
Copy the code

For the above code, the compiler is smart enough to know our parameter types and assign them to T and U without requiring the developer to explicitly specify them. Let’s take a look at a GIF to get an intuitive sense of the type transfer process:

(Photo credit: medium.com/better-prog…

Thanks to @Ron for pointing out that the GIF is buggy.

Is the last sentence of the GIF wrong? Console. log(identity([1,2,3]))

As you can see, this function accepts whatever type you pass to it, allowing us to create reusable components for different types. Now let’s look at the identity function again:

function identity <T.U> (value: T, message: U) : T {
  console.log(message);
  return value;
}
Copy the code

The new identity function adds a type variable U to the previously defined identity function, but we still use T for the return type of the function. What if we want to return both types of objects? There are several ways to solve this problem, one of which is to use tuples, that is, to set a common type for tuples:

function identity <T.U> (value: T, message: U) :T.U] {
  return [value, message];
}
Copy the code

While using tuples solves the above problems, is there a better solution? The answer is yes, you can use generic interfaces.

2. Generic interfaces

To fix the problem mentioned above, first let’s create a common Identities interface for the identity function:

interface Identities<V, M> {
  value: V,
  message: M
}
Copy the code

In the Identities interface above, we introduced type variables V and M to further illustrate that valid letters can be used to represent type variables, and then we can use the Identities interface as the return type of the identity function:

function identity<T.U> (value: T, message: U) :Identities<T.U> {
  console.log(value + ":" + typeof (value));
  console.log(message + ":" + typeof (message));
  let identities: Identities<T, U> = {
    value,
    message
  };
  return identities;
}

console.log(identity(68."Semlinker"));
Copy the code

After the above code runs successfully, the console will output the following:

68: number
Semlinker: string
{value: 68, message: "Semlinker"}
Copy the code

In addition to functions and interfaces, generics can also be used in classes. Let’s take a look at how generics can be used in classes.

Generic classes

Using generics in a class is also easy. We just need to use

defines any number of variables of any type, as shown in the following example:

interface GenericInterface<U> {
  value: U
  getIdentity: (a)= > U
}

class IdentityClass<T> implements GenericInterface<T> {
  value: T

  constructor(value: T) {
    this.value = value
  }

  getIdentity(): T {
    return this.value
  }

}

const myNumberClass = new IdentityClass<Number> (68);
console.log(myNumberClass.getIdentity()); / / 68

const myStringClass = new IdentityClass<string> ("Semlinker!");
console.log(myStringClass.getIdentity()); // Semlinker!
Copy the code

Let’s take an example of instantiating myNumberClass and see how it works:

  • The instantiationIdentityClassObject, we pass inNumberType and constructor parameter values68;
  • After theIdentityClassClass, type variablesTThe value of the intoNumberType;
  • IdentityClassClass implements theGenericInterface<T>, at a time whenTsaidNumberType, thus equivalent to the class implementationGenericInterface<Number>Interface;
  • And forGenericInterface<U>Interface, type variableUBecame theNumber. I purposely use different variable names here to indicate that type values propagate up the chain and are independent of the variable name.

Generic classes ensure that the specified data types are used consistently throughout the class. For example, you may have noticed the following conventions used in React projects that use Typescript:

typeProps = { className? :string. };typeState = { submitted? : bool ... };class MyComponent extends React.Component<Props, State> {
   ...
}
Copy the code

In the code above, we use generics with the React component to ensure that the component’s props and state are type-safe.

Some of you may be wondering, when do we need to use generics? There are usually two criteria for deciding whether to use generics:

  • When your function, interface, or class will handle multiple data types;
  • When a function, interface, or class uses the data type in more than one place.

There is probably no way to guarantee that you will use generic components early in the project, but as the project progresses, the functionality of the components will often be extended. This increased extensibility will most likely end up satisfying both of the above conditions, in which case it will be cleaner to introduce generics than to replicate components to satisfy a range of data types.

We’ll explore more use cases that satisfy both of these conditions later in this article. But before we do that, let’s look at some of the other features that Typescript generics provide.

4. Generic constraints

Sometimes we might want to limit the number of types each type variable accepts, and that’s where generic constraints come in. Let’s take a few examples of how to use generic constraints.

4.1 Ensure that attributes exist

Sometimes we want certain properties to exist on the type corresponding to a type variable. At this point, the compiler will not know that a particular property exists unless we explicitly define it as a type variable.

A good example is when dealing with strings or arrays, where we assume the length property is available. Let’s use the identity function again and try to output the length of the argument:

function identity<T> (arg: T) :T {
  console.log(arg.length); // Error
  return arg;
}
Copy the code

In this case, the compiler will not know that T does contain the length attribute, especially since any type can be assigned to a type variable T. All we need to do is make the type variable extends an interface that contains the properties we want, like this:

interface Length {
  length: number;
}

function identity<T extends Length> (arg: T) :T {
  console.log(arg.length); // You can get the length attribute
  return arg;
}
Copy the code

T extends Length is used to tell the compiler that we support any type that has implemented the Length interface. Later, when we call the identity function with an object that does not have the length attribute, TypeScript prompts an error message:

identity(68); // Error
// Argument of type '68' is not assignable to parameter of type 'Length'.(2345)
Copy the code

Additionally, we can use the, to separate multiple constraint types, such as

. As for the length attribute, we can also solve this problem if we explicitly set the variable to an array type, as follows:

function identity<T> (arg: T[]) :T[] {
   console.log(arg.length);  
   return arg; 
}

// or
function identity<T> (arg: Array<T>) :Array<T> {      
  console.log(arg.length);
  return arg; 
}
Copy the code

4.2 Checking whether The Key on the Object Exists

Another common use case for generic constraints is to check for the presence of a key on an object. But before we look at the examples, let’s look at the keyof operator. The keyof operator was introduced in TypeScript 2.1. This operator can be used to get all keys of a type whose return type is the union type. “Seeing is believing, but hearing is believing.”

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

type K1 = keyof Person; // "name" | "age" | "location"
type K2 = keyof Person[];  // number | "length" | "push" | "concat" | ...
type K3 = keyof { [x: string]: Person };  // string | number
Copy the code

With the KEYof operator, we can get all the keys of a given type, and then we can combine the extends constraint introduced earlier, which restricts the input property name to be included in the union type returned by keyof. The specific use mode is as follows:

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

In the getProperty function above, we use K extends keyof T to ensure that the parameter key must be a key contained in the object, so that a runtime error does not occur. This is a type-safe solution, with a simple call let value = obj[key]; Different.

Let’s see how to use the getProperty function:

enum Difficulty {
  Easy,
  Intermediate,
  Hard
}

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

let tsInfo = {
   name: "Typescript",
   supersetOf: "Javascript",
   difficulty: Difficulty.Intermediate
}
 
let difficulty: Difficulty = 
  getProperty(tsInfo, 'difficulty'); // OK

let supersetOf: string = 
  getProperty(tsInfo, 'superset_of'); // Error
Copy the code

In the example above, the TypeScript compiler prompts the following error for the getProperty(tsInfo, ‘superset_of’) expression:

Argument of type '"superset_of"' is not assignable to parameter of type 
'"difficulty" | "name" | "supersetOf"'. (2345).Copy the code

It is clear that by using generic constraints, errors can be detected early at compile time, greatly improving the robustness and stability of the program. Next, let’s look at the default types of generic parameters.

Default types of generic parameters

Since TypeScript 2.3, we can specify default types for type parameters in generics. This default type is used when the type parameter is not specified directly in the code and the type cannot be inferred from the actual value parameter.

The Default Type of a generic parameter is similar to the Default value of a normal function. The syntax is simple:
=default>

interface A<T=string> {
  name: T;
}

const strA: A = { name: "Semlinker" };
const numB: A<number> = { name: 101 };
Copy the code

The default type of a generic parameter follows the following rules:

  • Type arguments with default types are considered optional.
  • The mandatory type parameter cannot be followed by the optional type parameter.
  • If the type parameter has a constraint, the default type of the type parameter must satisfy that constraint.
  • When specifying type arguments, you only need to specify type arguments for the required type arguments. Type arguments that are not specified are resolved to their default types.
  • If a default type is specified and the type inference cannot select a candidate type, the default type is used as the result of the inference.
  • A declaration of a class or interface that is merged by an existing class or interface can introduce default types for existing type parameters.
  • A declaration of a class or interface that is merged by an existing class or interface can introduce a new type parameter as long as it specifies the default type.

Generic condition types

Conditional types were introduced in TypeScript 2.8, allowing us to obtain different types based on certain conditions, in this case type compatibility constraints. Even though the extends keyword is used in the above code, you don’t necessarily enforce inheritance, but rather check for structural compatibility.

Conditional types are checked for type relationships with a conditional expression, which selects one of the two types:

T extends U ? X : Y
Copy the code

The above expression means that the type is X if T can be assigned to U, and Y otherwise. We also usually use the infer keyword in conditional type expressions to achieve type extraction:

interface Dictionary<T = any> {
  [key: string]: T;
}
 
type StrDict = Dictionary<string>

type DictMember<T> = T extends Dictionary<infer V> ? V : never
type StrDictMember = DictMember<StrDict> // string
Copy the code

In the above example, when type T satisfies the T extends Dictionary constraint, we declare a type variable V with the infer keyword and return that type. Otherwise, we return never.

In TypeScript, the never type represents the type of values that will never exist. For example, the type never is the return value type for function or arrow function expressions that always throw an exception or never return a value at all.

Also, note that no type is a subtype of never or can be assigned to never (except for never itself). Even any cannot be assigned to never.

In addition to the above applications, we can easily obtain the return value type of a Promise object using the condition type and infer keyword. For example:

async function stringPromise() {
  return "Hello, Semlinker!";
}

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

async function personPromise() {
  return { name: "Semlinker", age: 30 } as Person;
}

type PromiseType<T> = (args: any[]) = > Promise<T>;
type UnPromisify<T> = T extends PromiseType<infer U> ? U : never;

type extractStringPromise = UnPromisify<typeof stringPromise>; // string
type extractPersonPromise = UnPromisify<typeof personPromise>; // Person
Copy the code

7. Generic tool types

Common utility types such as Partial, Required, Readonly, Record, and ReturnType are built into TypeScript for developers’ convenience. For the sake of space, we’ll just briefly cover a few of these common tool types.

7.1 Partial

Partial

makes all properties of a certain type optional, right? .

Definition:

/** * node_modules/typescript/lib/lib.es5.d.ts * Make all properties in T optional */
type Partial<T> = {
    [P inkeyof T]? : T[P]; };Copy the code

In the above code, we first get all the attribute names of T through keyof T, then use in to iterate, assign the value to P, and finally get the corresponding attribute value through T[P]. In the middle? The number used to make all properties optional.

Example:

interface Todo {
  title: string;
  description: string;
}

function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
  return{... todo, ... fieldsToUpdate }; }const todo1 = {
  title: "organize desk",
  description: "clear clutter"
};

const todo2 = updateTodo(todo1, {
  description: "throw out trash"
});
Copy the code

In the updateTodo method above, we use the Partial

tool type and define the fieldsToUpdate to be of type Partial

, that is:

{ title? :string | undefined; description? :string | undefined;
}
Copy the code

7.2 Record

Record

converts the value of all properties in K to type T.

Definition:

/** * node_modules/typescript/lib/lib.es5.d.ts * Construct a type with a set of properties K of type T */
type Record<K extends keyof any, T> = {
    [P in K]: T;
};
Copy the code

Example:

interface PageInfo {
  title: string;
}

type Page = "home" | "about" | "contact";

const x: Record<Page, PageInfo> = {
  about: { title: "about" },
  contact: { title: "contact" },
  home: { title: "home"}};Copy the code

7.3 Pick

Pick

picks out a subtype of a type and turns it into a subtype that contains some of the properties of that type.
,>

Definition:

// node_modules/typescript/lib/lib.es5.d.ts

/** * From T, pick a set of properties whose keys are in the union K */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
Copy the code

Example:

interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, "title" | "completed">;

const todo: TodoPreview = {
  title: "Clean room",
  completed: false
};
Copy the code

7.4 the Exclude

Exclude

excludes a type that belongs to another type.
,>

Definition:

// node_modules/typescript/lib/lib.es5.d.ts

/** * Exclude from T those types that are assignable to U */
type Exclude<T, U> = T extends U ? never : T;
Copy the code

Never is returned if T can be assigned to U, and T is returned otherwise. The result is to remove some of the types in T that belong to U.

Example:

type T0 = Exclude<"a" | "b" | "c"."a">; // "b" | "c"
type T1 = Exclude<"a" | "b" | "c"."a" | "b">; // "c"
type T2 = Exclude<string | number | (() = >void), Function>; // string | number
Copy the code

7.5 ReturnType

ReturnType

is used to get the ReturnType of function T.

Definition:

// node_modules/typescript/lib/lib.es5.d.ts

/** * Obtain the return type of a function type */
type ReturnType<T extends(... args:any) = >any> = T extends(... args:any) => infer R ? R : any;
Copy the code

Example:

type T0 = ReturnType<(a)= > string>; // string
type T1 = ReturnType<(s: string) = > void>; // void
type T2 = ReturnType<<T>(a)= > T>; / / {}
type T3 = ReturnType<<T extends U, U extends number[] >(a)= > T>; // number[]
type T4 = ReturnType<any>; // any
type T5 = ReturnType<never>; // any
type T6 = ReturnType<string>; // Error
type T7 = ReturnType<Function>; // Error
Copy the code

After a brief introduction to generic utility types, we’ll finish by showing how to use generics to create objects.

Create objects using generics

8.1 Signature Construction

Sometimes, a generic class may need to create its type-dependent objects based on the generic T passed in. Such as:

class FirstClass {
  id: number | undefined;
}

class SecondClass {
  name: string | undefined;
}

class GenericCreator<T> {
  create(): T {
    return newT(); }}const creator1 = new GenericCreator<FirstClass>();
const firstClass: FirstClass = creator1.create();

const creator2 = new GenericCreator<SecondClass>();
const secondClass: SecondClass = creator2.create();
Copy the code

In the above code, we define two generic classes and a generic class GenericCreator

. In the generic GenericCreator class, we define a member method called create that uses the new keyword to call the constructor of the actual type passed in to create the corresponding object. Unfortunately, this code doesn’t work. The TypeScript V3.9.2 compiler will prompt you with the following error:

'T' only refers to a type, but is being used as a value here.
Copy the code

T type refers only to type, but here it is used as a value. So how to solve this problem? According to the TypeScript documentation, in order for a generic class to create an object of type T, we need to reference the type T through its constructor. For the above problems, let’s look at constructing signatures before introducing the specific solutions.

In TypeScript interfaces, you can use the new keyword to describe a constructor:

interface Point {
  new (x: number, y: number): Point;
}
Copy the code

New (x: number, y: number) in the above interface is called a construct signature. Its syntax is as follows:

ConstructSignature: New TypeParametersopt (ParameterListopt) TypeAnnotationopt

In the above construction signature, TypeParametersopt, ParameterListopt, and TypeAnnotationopt indicate an optional type parameter, an optional parameter list, and an optional type annotation, respectively. Several common uses of this syntax are as follows:

new C  
new C ( ... )  
newC < ... > (...).Copy the code

Having introduced constructing signatures, let’s move on to a related concept, the constructor type.

8.2 Constructor types

The TypeScript language specification defines the constructor type as follows:

An object type containing one or more construct signatures is said to be a constructor type. Constructor types may be written using constructor type literals or by including construct signatures in object type literals.

From the information described in the specification, we can draw the following conclusions:

  • An object type that contains one or more constructor signatures is called a constructor type;
  • Constructor types can be written using constructor type literals or object type literals that contain the constructor signature.

So what are constructor type literals? Constructor type literals are shorthand for object types that contain a single constructor signature. In particular, constructor type literals have the following form:

new < T1, T2, ... > ( p1, p2, ... ) = > R
Copy the code

This form is equivalent to the following object type literals:

{ new < T1, T2, ... > ( p1, p2, ... ) : R }
Copy the code

Here’s a practical example:

// Constructor literal
new (x: number, y: number) => Point
Copy the code

Is equivalent to the following object type literals:

{
   new (x: number, y: number): Point;
}
Copy the code

8.3 Application of constructor types

Before introducing constructor types, let’s look at an example:

interface Point {
  new (x: number, y: number): Point;
  x: number;
  y: number;
}

class Point2D implements Point {
  readonly x: number;
  readonly y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y; }}const point: Point = new Point2D(1.2);
Copy the code

For the above code, the TypeScript compiler prompts the following error:

Class 'Point2D' incorrectly implements interface 'Point'.
Type 'Point2D' provides no match for the signature 'new (x: number, y: number): Point'.
Copy the code

Many people who are new to TypeScript will encounter these problems. To solve this problem, we need to separate the Point interface defined earlier, that is, we need to separate the interface properties from the constructor types:

interface Point {
  x: number;
  y: number;
}

interface PointConstructor {
  new (x: number, y: number): Point;
}
Copy the code

After splitting the interface, we define a newPoint factory function in addition to the previously defined Point2D class, which is used to create the corresponding Point object based on the constructor of type PointConstructor passed in.

class Point2D implements Point {
  readonly x: number;
  readonly y: number;

  constructor(x: number, y: number) {
    this.x = x;
    this.y = y; }}function newPoint(
  pointConstructor: PointConstructor,
  x: number,
  y: number
) :Point {
  return new pointConstructor(x, y);
}

const point: Point = newPoint(Point2D, 1.2);
Copy the code

8.4 Using generics to create objects

Now that we know how to construct signatures and constructor types, we need to refactor the create method as follows:

class GenericCreator<T> {
  create<T>(c: { new (): T }): T {
    return newc(); }}Copy the code

In the above code, we have redefined the create member method. Based on the signature of the method, we know that the method takes a parameter whose type is the constructor type, that the constructor contains no arguments, and that when the constructor is called, it returns an instance of type T.

If the constructor takes an argument, such as one of type number, we can define the create method as follows:

create<T>(c: { new(a: number): T; }, num: number): T {
  return new c(num);
}
Copy the code

After updating the GenericCreator class, we can create instances of FirstClass and SecondClass using the following method:

const creator1 = new GenericCreator<FirstClass>();
const firstClass: FirstClass = creator1.create(FirstClass);

const creator2 = new GenericCreator<SecondClass>();
const secondClass: SecondClass = creator2.create(SecondClass);
Copy the code

9. Reference Resources

  • typescript-generics
  • typescript-generics-explained
  • typescript-tip-of-the-week-generics