TypeScript learning-06 generics

generic paradigm

introduce

In software engineering, we should not only create consistent and well-defined API s, but also consider reusability. Components can support not only current data types, but also future data types, which provides you with very flexible functions when creating large-scale systems.

In languages like C# and Java, generics can be used to create reusable components. A component can support multiple types of data. In this way, users can use components with their own data types.

Generic Hello World

Let's create the first example of using generics: the identity function. This function returns any value passed in. You can think of this function as an echo command.

Without generics, this function may be as follows:

function identity(arg: number): number {
    return arg;
}

Alternatively, we use the any type to define the function:

function identity(arg: any): any {
    return arg;
}

Using any type will cause this function to receive arg parameters of any type, thus losing some information: the type passed in should be the same as the type returned. If we pass in a number, we only know that any type of value can be returned.

Therefore, we need a method to make the type of return value the same as that of the incoming parameter. Here, we use the type variable, which is a special variable, which is only used to represent the type rather than the value.

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

We added the type variable t to identity. T helps us capture the type passed in by the user (such as number), and then we can use this type. Then we use t again as the return value type. Now we can know that the parameter type is the same as the return value type. This allows us to track the type of information used in the function.

We call this version of the identity function generic because it can be applied to multiple types. Unlike using any, it doesn't lose information. Like the first example, it keeps accuracy, passes in a numeric type and returns a numeric type.

After we define a generic function, we can use it in two ways. The first is to pass in all parameters, including type parameters:

let output = identity<string>("myString");  // type of output will be 'string'

Here, we explicitly specify that T is a string type and pass it to the function as a parameter, using < > instead of ().

The second method is more common. Type inference is used - that is, the compiler will automatically help us determine the type of T according to the passed parameters:

let output = identity("myString");  // type of output will be 'string'

Note that we don'T have to use angle brackets (< >) to explicitly pass in types; The compiler can look at the value of myString and set T to its type. Type inference helps us keep our code concise and highly readable. If the compiler cannot automatically infer the type, it can only explicitly pass in the type of T as above. In some complex cases, this may occur.

Using generic variables

When using generics to create generic functions such as identity, the compiler requires you to use this generic type correctly in the function body. In other words, you must treat these parameters as any or all types.

Take a look at the previous example of identity:

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

If we want to print the length of arg at the same time. We are likely to do this:

function loggingIdentity<T>(arg: T): T {
    console.log(arg.length);  // Error: T doesn't have .length
    return arg;
}

If you do this, the compiler will report an error saying that we used arg Length attribute, but there is no place to indicate that Arg has this attribute. Remember, these type variables represent any type, so the person using this function may pass in a number and no number Of the length attribute.

Now suppose we want to manipulate an array of type T instead of directly T. Because we operate on arrays, so The length attribute should exist. We can create this array like other arrays:

function loggingIdentity<T>(arg: T[]): T[] {
    console.log(arg.length);  // Array has a .length, so no more error
    return arg;
}

You can understand the type of loggingIdentity in this way: the generic function loggingIdentity receives the type parameter T and the parameter arg. It is an array with element type T and returns an array with element type T. If we pass in an array of numbers, we will return an array of numbers, because the type of T is number at this time. This allows us to use the generic variable t as part of the type instead of the entire type, increasing flexibility.

We can also implement the above example in this way:

function loggingIdentity<T>(arg: Array<T>): Array<T> {
    console.log(arg.length);  // Array has a .length, so no more error
    return arg;
}

If you have used other languages, you may be familiar with this grammar. In the next section, we will describe how to create custom generics like Array.

generic types

In the previous section, we created the identity general function, which can be applied to different types. In this section, we look at the types of functions themselves and how to create generic interfaces.

The type of a generic function is no different from that of a non generic function, except that there is a type parameter at the front, like a function declaration:

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

let myIdentity: <T>(arg: T) => T = identity;

We can also use different generic parameter names, as long as they correspond in quantity and usage.

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

let myIdentity: <U>(arg: U) => U = identity;

We can also define generic functions using object literals with call signatures:

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

let myIdentity: {<T>(arg: T): T} = identity;

This leads us to write the first generic interface. We take the object literal in the above example as an interface:

interface GenericIdentityFn {
    <T>(arg: T): T;
}

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

let myIdentity: GenericIdentityFn = identity;

For a similar example, we may want to treat the generic parameter as a parameter of the whole interface. In this way, we can clearly know which generic type is used (such as dictionary, not just Dictionary). In this way, other members of the interface can also know the type of this parameter.

interface GenericIdentityFn<T> {
    (arg: T): T;
}

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

let myIdentity: GenericIdentityFn<number> = identity;

Notice that our example has made a few changes. Instead of describing generic functions, non generic function signatures are used as part of generic types. When we use GenericIdentityFn, we have to pass in a type parameter to specify the generic type (here: number) and lock the type used in the code later. For describing which types belong to the generic part, it is helpful to understand when to put parameters in the call signature and when to put parameters on the interface.

In addition to generic interfaces, we can also create generic classes. Note that generic enumerations and generic namespaces cannot be created.

Generic class

Generic classes look similar to generic interfaces. Generic classes use (< >) to enclose generic types, followed by class names.

class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

The use of the GenericNumber class is very intuitive, and you may have noticed that there is no limit to its use of the number type. You can also use strings or other more complex types.

let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function(x, y) { return x + y; };

console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));

Just like interfaces, putting generic types directly after classes can help us confirm that all properties of a class are using the same type.

As we said in the class section, a class has two parts: the static part and the instance part. Generic class refers to the type of the instance part, so the static properties of the class cannot use this generic type.

Generic constraints

You should remember the previous example. Sometimes we want to operate on a set of values of a certain type, and we know what properties this set of values has. In the loggingIdentity example, we want to access the length attribute of arg, but the compiler cannot prove that each type has the length attribute, so an error is reported.

function loggingIdentity<T>(arg: T): T {
    console.log(arg.length);  // Error: T doesn't have .length
    return arg;
}

Instead of manipulating all types of any, we want to restrict the function to handle any All types of the length property. As long as the incoming type has this attribute, we allow it, that is to say, it contains at least this attribute. To do this, we need to list the constraint requirements for T.

To this end, we define a constraint. Create a containing The interface of the length attribute. Use this interface and the extends keyword to implement constraints:

interface Lengthwise {
    length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
    console.log(arg.length);  // Now we know it has a .length property, so no more error
    return arg;
}

Now this generic function is defined with constraints, so it is no longer applicable to any type:

loggingIdentity(3);  // Error, number doesn't have a .length property

We need to pass in the value that conforms to the constraint type and must contain the required attributes:

loggingIdentity({length: 10, value: 3});

Using type parameters in generic constraints

You can declare a type parameter and it is constrained by another type parameter. For example, now we want to get this property from the object with the property name. And we want to ensure that this attribute exists on the object obj, so we need to use constraints between the two types.

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

let x = { a: 1, b: 2, c: 3, d: 4 };

getProperty(x, "a"); // okay
getProperty(x, "m"); // error: Argument of type 'm' isn't assignable to 'a' | 'b' | 'c' | 'd'.

Using class types in generics

When TypeScript uses generics to create factory functions, it needs to reference the class type of the constructor. For example,

function create<T>(c: {new(): T; }): T {
    return new c();
}

A more advanced example uses prototype properties to infer and constrain the relationship between constructors and class instances.

class BeeKeeper {
    hasMask: boolean;
}

class ZooKeeper {
    nametag: string;
}

class Animal {
    numLegs: number;
}

class Bee extends Animal {
    keeper: BeeKeeper;
}

class Lion extends Animal {
    keeper: ZooKeeper;
}

function createInstance<A extends Animal>(c: new () => A): A {
    return new c();
}

createInstance(Lion).keeper.nametag;  // typechecks!
createInstance(Bee).keeper.hasMask;   // typechecks!

Keywords: Javascript Front-end TypeScript

Added by akumakeenta on Tue, 01 Feb 2022 20:35:17 +0200