4

Generics and Advanced Type Inference

Thus far, we have been exploring the type system within TypeScript, and how it relates to interfaces, classes, and primitive types. We have also explored how to use various language features to mix and match these types, including type aliases and type guards. All of the techniques we have used, however, eventually boil down to writing code that will work with a single particular type. This is how we achieve type safety within TypeScript.

But what if we would like to write some code that will work with any sort of type, or any sort of interface or class definition? Perhaps a function that needs to find an element in a list, where the list could be made of strings, or numbers, or any other type. This is where generics come into play. Generics provide a mechanism to write code that does not need to specify a specific type. It is left up to the caller of these generic functions or classes to specify the type that the generic will be working with. There are, of course, some constraints that come into play when working with generics. How do we limit the types that generic code can work with, down to a small subset of classes or interfaces? Are we able to specify multiple types within generic code, and can we specify a relationship between these two generic types?

This chapter is broken up into two sections. The first section will discuss the generic type syntax that TypeScript uses, and the various ways that we can work with types when writing generic code.

We already know that TypeScript uses inferred typing in certain cases, in order to determine what the type of an object is, if we do not specify it explicitly. TypeScript also allows us to use advanced type inference when working with generic code.

In other words, when given a generic type, we can compute or construct another completely different type based on the properties and structure of the original type. This technique allows us to map one type to another, which we will discuss in the second section of this chapter.

We will cover the following topics in this chapter:

  • Generic syntax, including:
    • Multiple generic types
    • Constraining and using the type T
    • Generic constraints and interfaces
    • Creating new objects within generics
  • Advanced type inference, including:
    • Mapped types
    • Conditional types and conditional type chaining
    • Distributed conditional types
    • Conditional type inference
    • Type inference from function signatures and arrays
    • Standard conditional types

Let's get started with generics.

Generics

Generics, or, more specifically, generic syntax is a way of writing code that will work with a wide range of objects and primitives. As an example, suppose that we wanted to write a function that iterates over a given array of objects, and returns a concatenation of their values. So, given a list of numbers, say [1,2,3], it should return the string "1,2,3". Or, given a list of strings, say ["first", "second", "third"], it should return the string "first, second, third".

Using generics allows us to write type-safe code that can force each element of the array to be of the same type, and as such would not allow a mixed list of values to be sent through to our function, say [1,"second", true].

In this section of the chapter, we will introduce the generic code syntax, and explore the rules around what we can do with generic types.

Generic syntax

TypeScript uses an angled bracket syntax, and a type symbol, or type substitute name, to indicate that we are using generic syntax. In other words, to specify that the type named T is being used within generic syntax, we will write <T> to indicate that this code is substituting a normal type name with the symbol T. Then, when we use a generic type, TypeScript will essentially substitute the type named T with an actual type.

This is best explained through some example code, as follows:

function printGeneric<T>(value: T) {
    console.log(`typeof T is : ${typeof value}`);
    console.log(`value is : ${value}`)
}

Here, we have a function named printGeneric that is using generic syntax and specifying that its type substitute name is named T by appending <T> to the function name. This function takes a single parameter named value, of the type T.

So, what we have done here is replace the type within the function definition, which would normally be value: string, or value: number, for example, with the generic syntax of value: T.

This printGeneric function will log the result of the typeof operator for the value that was sent in, as well as its actual value. We can now use this function as follows:

printGeneric(1);
printGeneric("test");
printGeneric(true);
printGeneric(() => { });
printGeneric({ id: 1 });

Here, we are calling the printGeneric function with a wide range of values. The first call is with a numeric value of 1, the second with a string value of "test", and the third with a boolean value of true. We then call the printGeneric function with an actual function, and then with an object with a single property named id. The output of this code is as follows:

typeof T is : number
value is : 1
typeof T is : string
value is : test
typeof T is : boolean
value is : true
typeof T is : function
value is : function () { }
typeof T is : object
value is : [object Object]

As we can see from this output, the printGeneric function is indeed working with pretty much every type that we can throw at it. The typeof operator is identifying what the type of T is, either a string, number, boolean, function, or object, and the value that is logged to the console is correct.

Note that there is an interesting subtlety about how we have called this function. This is best explained by showing how we can also call this function as follows:

printGeneric<string>("test");

Here, we are using type casting notation, that is, the angled brackets <type>, to explicitly specify what type we are calling this function with. Note that previously, we did not explicitly set the type using this long form notation, but simply called the function with an argument, that is, printGeneric(1). In this instance, TypeScript is inferring the type T to be a number.

Note, too, that if we explicitly set the type to be used using this long form notation, then this will override any usage of the type T, and our normal type rules will apply for any usage of the type T. Consider the following example:

printGeneric<string>(1);

Here, we are explicitly specifying that the function will be called with a type <string>, but our single argument is actually of type number. This code will generate the following error:

error TS2345: Argument of type '1' is not assignable to parameter of type 'string'

This error is telling us that we are attempting to call a generic function with the wrong type as an argument, as the type of T was explicitly set.

If we do not explicitly specify what type the generic function should use, by omitting the <type> specifier, the compiler will infer the type to be used from the type of each argument.

Multiple generic types

We can also specify more than one type to be used in a generic function, as follows:

function usingTwoTypes<A, B> ( first: A, second: B) {
}

Here we have a function named usingTwoTypes that has a type name for both the first and second parameters in this function, that is, A and B. This function can specify any type for either A or B, as follows:

usingTwoTypes<number, string> ( 1, "test");
usingTwoTypes(1, "test");
usingTwoTypes<boolean, boolean>(true, false);
usingTwoTypes("first", "second");

Here, we are freely mixing the syntax we are using to call the usingTwoTypes function. The first call is using explicit type syntax, and therefore A is of type number, and B is of type string. The second call is inferring the type of A as a number, and the type of B as a string. In the third call, we are explicitly setting both types as a boolean, and in the fourth and last call, both types are inferred as strings.

Constraining the type of T

In most instances, we will want to limit the type of T in order to only allow a specific set of types to be used within our generic code. This is best explained through an example, as follows:

class Concatenator<T extends Array<string> | Array<number>> {
    public concatenateArray(items: T): string {
        let returnString = "";
        for (let i = 0; i < items.length; i++) {
            returnString += i > 0 ? "," : "";
            returnString += items[i].toString();
        }
        return returnString;
    }
}

Here, we have defined a class named Concatenator that is using generic syntax, and is also constraining the type of T to be either an array of strings or an array of numbers, via the extends keyword. This means that wherever T is used within our code, T can only be interpreted as either a string array or a number array. This class has a single function named concatenateArray that has a single parameter named items, of type T, and returns a string.

Within this function, we are simply looping through each element in the argument array named items and appending a string representation of the item to the variable returnString. We can now use this class as follows:

let concator = new Concatenator();
let concatResult = concator.concatenateArray([
    "first", "second", "third"
]);
console.log(`concatResult = ${concatResult}`);
concatResult = concator.concatenateArray([
    1000, 2000, 3000
]);
console.log(`concatResult = ${concatResult}`);

Here, we start by creating a new instance of the Concatenator class, and assigning it to the variable named concator. We then call the concatenateArray function with an array of strings, and assign the result to the variable named concatResult, which is of type string. We then print this value to the console.

We then call the concatenateArray function with an array of numbers, and again print the returned string value to the console. The results of this code are as follows:

concatResult = first,second,third
concatResult = 1000,2000,3000

Here, we can see that our concatArray function is returning a string representation of the array that we passed in as an argument.

If, however, we attempt to call this function with an array of booleans, as follows:

concatResult = concator.concatenateArray([
    true, false, true
]);

We will generate a number of errors, as follows:

error TS2322: Type 'true' is not assignable to type 'string | number'
error TS2322: Type 'false' is not assignable to type 'string | number'
error TS2322: Type 'true' is not assignable to type 'string | number'

Here, we can see that because we constrained the type of T in our Concatenator class to only allow arrays of strings or arrays of numbers, the compiler will not allow us to call this function with an array of booleans.

Using the type T

We have already seen how we can constrain the type of T in our generic code in order to limit the number of types that can be used. Another limit of generic code is that it can only reference functions or properties of objects that are common to any type of T. As an example of this limitation, consider the following code:

interface IPrintId {
    id: number;
    print(): void;
}
interface IPrintName {
    name: string;
    print(): void;
}

Here, we have two interfaces, named IPrintId, and IPrintName. Both interfaces have a function named print that returns void. The IPrintId interface, however, has a property named id of type number, and the IPrintName interface has a property named name of type string. These two properties are unique to each of these interfaces. Now let's consider a generic function that is designed to work with these two interfaces, as follows:

function useT<T extends IPrintId | IPrintName>(item: T)
    : void {
    item.print();
    item.id = 1;  // error : id is not common
    item.name = "test"; // error : name is not common
}

Here, we have defined a function named useT that accepts a type named T that can be either an instance of the IPrintId interface, or an instance of the IPrintName interface. The function has a single parameter named item of type T.

Within this function, we are calling the print method of the item parameter, and then we are attempting to assign the value of 1 to the id property, and a value of "test" to the name property of the item parameter.

This code will generate the following errors:

error TS2339: Property 'id' does not exist on type 'T'
error TS2339: Property 'name' does not exist on type 'T'

This error clearly indicates that the id and name property does not exist on the type T. In other words, we are only able to call properties or functions on type T where they are common to all types of T. As the id property is unique to the IPrintId interface, and the name property is unique to the IPrintName interface, we are not allowed to reference these properties when we reference T. The only property that these two interfaces have in common is the print function, and therefore only the print function can be used in this case.

TypeScript will ensure that we are only able to reference properties and functions on a type of T, where these properties and functions are common across all types that are allowed for T.

Generic constraints

A generic type can be constructed out of another generic type. This technique essentially uses one type to apply a constraint on another type. Let's take a look at an example, as follows:

function printProperty<T, K extends keyof T>
    (object: T, key: K) {
    let propertyValue = object[key];
    console.log(`object[${key}] = ${propertyValue}`);
}

Here, we have a function named printProperty that has two generic types, named T and K. The type K is constrained to be a value computed from the keyof operator on type T. Remember that the keyof operator will return a string literal type that is made up of the properties of an object, so K will be constrained to the property names of the type T.

The printProperty function has two parameters, named object of type T, and key of type K. The function assigns the value of the object's property named in the key parameter to a variable named propertyValue, using the syntax object[key]. It then logs this value to the console.

Let's test this function as follows:

let obj1 = {
    id: 1,
    name: "myName",
    print() { console.log(`${this.id}`) }
}
printProperty(obj1, "id");
printProperty(obj1, "name");
printProperty(obj1, "surname");

Here, we have constructed an object named obj1, which has an id property of type number, a name property of type string, and a print function. We are then calling the printProperty function three times, once with the key argument set to "id", another with it set to "name", and the third time with the key argument set to "surname". Note that the last line of this code snippet will produce the following error:

error TS2345: Argument of type '"surname"' is not assignable to parameter of type '"id" | "name" | "print"'

Here, we can see that the compiler has generated a string literal type based on the properties of the obj1 object, and we are only allowed to call this function with a valid property name as the second argument. As the obj1 object does not have a surname property, this last line of the code snippet is generating an error. The output of this code is as follows:

object[id] = 1
object[name] = myName

Here we can see that our printProperty function is indeed printing the value of the corresponding property. Let's now see what happens when we use this function to print the print property, as follows:

printProperty(obj1, "print");

Here, we are calling the printProperty function, and have used the "print" property as our second argument. The output of this code is as follows:

object[print] = function () { console.log("" + this.id); }

Here, we can see that we are printing out the definition of the print function, which corresponds to the value of obj1["print"]. Note that this code does not invoke the function; it simply logs the value of the property, which happens to be a function.

Generic interfaces

In the same manner that functions and classes can use generics, we are also able to create interfaces that use generic syntax. Let's take a look at an example, as follows:

interface IPrint {
    print(): void;
}
interface ILogInterface<T extends IPrint> {
    logToConsole(iPrintObj: T): void;
}
class LogClass<T extends IPrint>
    implements ILogInterface<T>
{
    logToConsole(iPrintObj: T): void {
        iPrintObj.print();
    }
}

Here, we have defined an interface named IPrint that has a single function named print that returns void. We then define an interface named IlogInterface that is using generic syntax to define a type T that extends the IPrint interface. This interface has a single function named logToConsole, which has a single parameter named iPrintObj, of type T. We then have a class definition for a class named LogClass, which is also using generic syntax to define a type T that extends from the IPrint interface. This class implements the ILogInterface interface, and as such, must define a logToConsole function.

Note that the ILogInterface interface requires the type of T to implement the IPrint interface, and uses the generic syntax to define this, that is, <T extends IPrint>. When we define the LogClass class, the type T must match the interface type definition exactly, that is, it must also be <T extends IPrint>.

We can now use this class definition as follows:

let printObject: IPrint = {
    print() { console.log(`printObject.print() called`) }
}
let logClass = new LogClass();
logClass.logToConsole(printObject);

Here, we have an object named printObject that implements the IPrint interface, as it has a print function. We then create an instance of the LogClass class, and call the logToConsole function with the printObject variable as the only argument. The output of this code is as follows:

printObject.print() called

Here, we can see that the logToConsole function of the LogClass instance is calling the print function of the printObject variable.

Creating new objects within generics

From time to time, generic classes may need to create an object of the type that was passed in as the generic type T. Consider the following code:

class ClassA { }
class ClassB { }
function createClassInstance<T>
    (arg1: T): T {
    return new arg1(); // error : see below
}
let classAInstance = createClassInstance(ClassA);

Here, we have defined two classes, named ClassA and ClassB. We then define a function named createClassInstance that is using generic syntax to define the type T. This function has a single parameter named arg1 of type T. This function returns a type T, and is intended to create a new instance of the class that was passed in as the single arg1 parameter.

The last line of this code snippet show how we intend to use the createClassInstance function. We are calling this function with the class definition of ClassA as the only argument.

Unfortunately, this code will generate the following error:

error TS2351: This expression is not constructable.
  Type 'unknown' has no construct signatures
return new arg1();

Here, we can see that the compiler will not allow us to construct a new instance of the type T in this way. This is because the type of T is really of type unknown to the function at this stage.

According to the TypeScript documentation, in order for a generic class to be able to construct an object of type T, we need to refer to type T by its constructor function. Our createClassInstance function therefore needs to be rewritten as follows:

function createClassInstance<T>
    (arg1: { new(): T }): T {
    return new arg1();
}

Here, we have modified the arg1 parameter, and are constructing an anonymous type that defines a new function, and returns the type T, that is, arg1: { new() : T }. In other words, the arg parameter is a type that overloads the new function, and returns an instance of T. Our code will now compile and work as expected.

This concludes our initial discussion on generics. We have discussed how to use generic syntax, how to constrain the type of T, and what is and what is not possible within generic code. Generic syntax, however, allows us to express types as a combination of other types, using mapped types and conditional types. We will discuss these language features in the next section of this chapter.

Advanced type inference

The TypeScript language has given us a large toolbox with which to define custom types, inherit types from each other, and use generic syntax to work with any number of different types. By combining these features, we can start to describe some seriously advanced type definitions, including types based on other types, or types based on some or all of the properties of another type. We can also completely modify a type by adding and removing properties as we see fit.

In this section of the chapter, we will explore more advance type inference, including conditional types, inferred types, and mapped types, or, as the author describes it, "type mathematics." Be warned that the syntax used with advance types can quickly become rather complicated to read, but if we apply some simple rules, it is easily understandable.

Remember that although types help us to describe our code, and also help to harden our code, they do not affect the generated JavaScript. Simply describing a type is a theoretical exercise, and much of the "type mathematics" in this section of the chapter will still only do exactly that – specify a type. We will still need to put these types to use in order to realize their benefit within our code base.

Mapped types

We already know that we can use a type alias to define a special named type, as discussed in Chapter 2, Exploring the Type System. Type aliases, however, can become even more powerful when combined with generic syntax, allowing us to create types based on other types. Add in the keyof keyword, and we can create new types based on the properties of another type. This is best illustrated with an example, as follows:

interface IAbRequired {
    a: number;
    b: string;
}
let ab: IAbRequired = {
    a: 1,
    b: "test"
}
type WeakInterface<T> = {
    [K in keyof T]?: T[K];
}
let allOptional: WeakInterface<IAbRequired> = {}

Here, we have defined an interface named IAbRequired that has two properties, named a of type number, and b of type string. We then create an instance of an object named ab, which is of type IAbRequired, and as such, must define both an a and b property, as both properties are required.

We then create a type alias name WeakInterface, which uses generic syntax to allow it to be used with any type named T. This type alias also specifies a second type, K, that is using the keyof keyword on the type T. The effect of the keyof keyword is that the WeakInterface type will contain a property for each property that the type T defines. Note, however, that the definition of the properties, that is [K in keyof T]?, is also using the optional property operator ?, and the type of each property has been defined as T[K]. In other words, return the type of the original property of type T, named K, but make it optional.

What this means is that we are defining a type named WeakInterface, which accepts a type named T, and we are transforming each property that is defined for T into an optional property.

The last line of this code snippet defines a variable named allOptional, which is of the type WeakInterface<IAbRequired>. This, therefore, makes all of the properties that were named on the IAbRequired interface optional, and our object can be constructed with no properties.

Note too that even though we are making each property in the type IAbRequired optional, we cannot define properties that are not available on this original type.

Partial, Readonly, Record, and Pick

Using mapped types that transform properties are seen as so fundamental that their definitions have been included in the standard TypeScript type definitions. The WeakType type alias that we created earlier is actually called Partial, which can be seen from the type definition in lib.es5.d.ts, as follows:

/**
 * Make all properties in T optional
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};

Here, we can see the type definition for a type named Partial, which will transform each property in the type named T into an optional property. There is also a mapped type named Required, which will do the opposite of Partial, and mark each property as required.

Similarly, we can use the Readonly mapped type to mark each property as readonly, as follows:

/**
 * Make all properties in T readonly
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};

Here, we can see the definition of the Readonly mapped type that will mark each property found in the type named T as readonly. We can use this predefined type as follows:

let readonlyVar: Readonly<IAbRequired> = 
{
    a: 1, 
    b: "test"
}
readonlyVar.a = 1;

Here, we have used the Readonly mapped type with our previous interface named IAbRequired to create a variable named readonlyVar. As the IAbRequired interface needs both an a and a b property, we have provided values for both of these properties. Note, however, the last line of this code snippet, which is attempting to assign the value of 1 to the a property of our readonlyVar variable. This line of code will generate the following error:

error TS2540: Cannot assign to 'a' because it is a read-only property.

Here, we can see that by using the Readonly mapped type, all properties of our original IAbInterface interface have been marked as readonly, and we therefore cannot modify their values. Note that these mapped types are shallow, and will only apply to top-level properties.

Outside of the standard mapped types of Partial and Readonly, there are two other interesting mapped types, named Pick and Record. The Pick mapped type is used to construct a type based on a subset of properties of another type, as follows:

interface IAbc {
    a: number;
    b: string;
    c: boolean
}
type PickAb = Pick<IAbc, "a" | "b">;
let pickAbObject: PickAb = {
    a: 1,
    b: "test"
}

Here, we have an interface named IAbc that defines three properties, named a of type number, b of type string, and c of type boolean. We then construct a new type named PickAb using the Pick mapped type, and provide two generic types. The first generic type is our interface name IAbc, and the second generic type is a string literal, which is constrained to match a property name of the original type. In essence, the Pick mapped type will pick a set of properties from the original type that are to be applied to the new type. In this example, therefore, only the a and b properties from the IAbc interface will be applied to the new type.

We can see an example of the resultant type in the last line of this code snippet. Here, we are creating a variable named pickAbObject that is of the type PickAb. As the PickAb mapped type only uses the "a" and "b" properties in its string literal, we only need to specify an a and b property for this variable.

The final mapped type that we will explore is the Record mapped type, which is used to construct a type on the fly. It is almost the opposite of the Pick mapped type, and uses a provided list of properties as a string literal to define what properties the type must have. Consider the following example:

type RecordedCd = Record<"c" | "d", number>;
let recordedCdVar: RecordedCd = {
    c: 1,
    d: 1
};

Here, we have defined a type named RecordedCd that is using the Record mapped type, and has provided two generic arguments. The first generic argument is a string literal with the values of "c" and "d", and the second generic argument is the type number. The Record mapped type will then create a new type with the properties of c and d, both of type number.

We can see an example of using this new type named RecordedCd in the last line of the code snippet. As the variable named recordedCdVar is of type RecordedCd, it must provide a property named c, and a property named d, both of type number.

Conditional types

In Chapter 2, Exploring the Type System, we introduced the concept of conditional expressions, with the following format:

(conditional) ? ( true statement )  :  ( false statement );

Here, we have a condition, followed by a question mark (?), and then either a true expression or a false expression, separated by a colon (:). We can use this syntax with types as well, to form what is known as a conditional type, as follows:

type NumberOrString<T> = T extends number ? number : string;

Here, we have defined a type named NumberOrString that is using the generic syntax to define a type named T. The type of T is set to the result of the conditional type statement to the right of the assignment operator (=).

This conditional type statement is checking whether the type of T extends the type number. If it does, it will return the number type, and if not, it will return the string type.

We can use this conditional type within a function as follows:

function logNumberOrString<T>(input: NumberOrString<T>) {
    console.log(`logNumberOrString : ${input}`);
}

Here, we have a function named logNumberOrString that is using generic syntax to define a type named T. This function has a single parameter that is of type NumberOrString, which is our conditional type. The function simply logs the value of the input parameter to the console. We can now use this function as follows:

logNumberOrString<number>(1);
logNumberOrString<string>("test");
logNumberOrString<boolean>(true);

Here, we are calling the logNumberOrString function three times. The first call is using the number type for the type of T, and the second call is using the string type for the type of T. The third call is using the boolean type for the type of T, and this line will generate the following error:

error TS2345: Argument of type 'true' is not assignable to parameter of type 'string'.

Let's break down what is causing this error. Remember that our conditional type checks whether the type we used for T extends number. If it does, then the conditional type returns the type number. If it does not extend number, then the conditional type will return the type string. As boolean does not extend number, the returned type in this case will be the type string. We therefore need to use a string in this case, as follows:

logNumberOrString<boolean>("boolean does not extend number");

Here, we have modified our function argument to be of type string, which then matches the result of the conditional type, NumberOrString. When given a boolean type, it will return the type string.

Conditional type chaining

In a similar way to how conditional statements can be chained together, conditional types can also be chained together to form a logic tree that will return a specific type. Let's take a look at a slightly more complex example that uses this technique, as follows:

interface IA {
    a: number;
}
interface IAb {
    a: number;
    b: string;
}
interface IAbc {
    a: number;
    b: string;
    c: boolean;
}

Here, we have three interfaces, named IA, IAb, and IAbc. The IA interface has a single property named a, the IAb interface has two properties named a and b, and the IAbc interface has three properties named a, b, and c. Let's now create a conditional type that uses conditional type chaining as follows:

type abc_ab_a<T> = 
    T extends IAbc ? [number, string, boolean] :
    T extends IAb ? [number, string] :
    T extends IA ? [number] :
    never;

Here, we have created a conditional type named abc_ab_a, which uses generic syntax for the type named T. If T extends the IAbc interface, then the conditional type will return a tuple of the type number, string, and boolean. If T does not extend the IAbc interface, then a new condition is checked. If T extends the IAb interface, the conditional type will return a tuple of type number and string. The final condition checks whether the type T extends the IA interface. If it does, it will return a tuple of type number, and if not, it will return never.

We can now write a function that uses this conditional type, as follows:

function getTupleStringAbc<T>
    (tupleValue: abc_ab_a<T>): string 
{
    let [...tupleDestructured] = tupleValue;
    let returnString = "|";
    for (let value of tupleDestructured) {
        returnString += `${value}|`;
    }
    return returnString;
}

Here, we have a function named getTupleStringAbc that uses generic type syntax to define a type named T. The function accepts a single parameter named tupleValue that is the result of our conditional type named abc_ab_a<T>. This function destructures the tupleValue parameter into an array, and then generates a string by looping through all elements of the array. We can now call this function as follows:

let keyA = getTupleStringAbc<IA>([1]);
console.log(`keyA = ${keyA}`);
let keyAb = getTupleStringAbc<IAb>([1, "test"]);
console.log(`keyAb = ${keyAb}`);
let keyAbc = getTupleStringAbc<IAbc>([1, "test", true]);
console.log(`keyAbc = ${keyAbc}`);

Here, we are creating three variables, named keyA, keyAb, and keyAbc, by calling the getTupleStringAbc function with three different types, namely, IA, IAb, and IAbc. Note that the first call to the getTupleStringAbc function has a tuple with a single numeric value. This matches the output of our conditional type, as the type of T will be IA, and the conditional statement T extends IA ? [number] will return true. The results of the conditional type evaluation will drive the structure of the tuple that is needed in the function call. The results of this code are as follows:

keyA = |1|
keyAb = |1|test|
keyAbc = |1|test|true|

Here, we can see that the getTupleStringAbc function will return a string value that is the concatenation of the values passed in as the tuple argument.

Distributed conditional types

When defining conditional types, instead of returning only a single type as part of our conditional statements, we can also return a number of types, or distributed conditional types. As an example of this, consider the following code:

type dateOrNumberOrString<T> =
    T extends Date ? Date :
    T extends number ? Date | number :
    T extends string ? Date | number | string :
    never;
function compareValues
    <T extends string | number | Date | boolean>
(
    input: T,
    compareTo: dateOrNumberOrString<T>
) {
    // do comparison
}

Here, we have a conditional type named dateOrNumberOrString that is using generic syntax to define a type named T. If the type of T is a Date, then the conditional type will return a Date type. If the type of T is a number, then the conditional type will return a type of Date or number. If the type of T is a string, then the conditional type will return a Date type or a number or a string. If the type of T is neither a date nor a number or string, the conditional type will return never.

We then define a function named compareValues that also uses generic syntax to define a type named T. This function has two parameters, named input, of type T, and compareTo, which uses our conditional type named dateOrNumberOrString. This introduces some interesting logic when using this function as follows:

  • If the input parameter is of type Date, then the compareTo parameter may only be of type Date.
  • If the input parameter is of type number, then the compareTo parameter may be either a Date or a number.
  • If the input parameter is of type string, then the compareTo parameter may be either a Date or a number or a string.
  • If the input parameter is not of type Date or number or string, then do not allow this function to be called.

All of this type inference is handled purely by the distributed conditional type. As an example of using this function, each of the following function calls are valid:

compareValues(new Date(), new Date());
compareValues(1, new Date());
compareValues(1, 2)
compareValues("test", new Date());
compareValues("test", 1);
compareValues("test", "test");

Here, we can see the various combinations that can be used with our compareValues function. Based on the type of the first argument, the second argument can be one of a range of different types.

There are a number of situations where distributed conditional types can be used to either limit or expand what types are allowed in these cases. They can also be useful, as we have seen, when defining what types can be compared against each other, or what types can be used in a computational algorithm.

Conditional type inference

There is a further, and more esoteric version of the conditional type syntax, where we are able to infer a new type as part of a conditional type statement. The simplest form of these inferred types can best be explained by an example, as follows:

type inferFromPropertyType<T> =
    T extends { id: infer U } ? U : never;

Here, we have defined a type named inferFromPropertyType that is using the generic syntax to define a type named T. We are then using a conditional type to check whether the type of T extends an object that has a property named id. If the type of T is an object that has a property named id, then we will return the type of the id property itself. This is done by introducing a new type name, which in this case is U, and using the infer keyword. In other words, we are inferring a new generic type named U that is the type of the id property of the object T. If the object T does not have an id property, then we simply return never. Let's now take a look at how we would use this inferred conditional type, as follows:

function testInferFromPropertyType<T>
(
    arg: inferFromPropertyType<T>
) { }
testInferFromPropertyType<{ id: string }>("test");
testInferFromPropertyType<{ id: number }>(1);

Here, we have defined a function named testInferFromPropertyType that is using the generic syntax to define a type named T. This function has a single parameter named arg that is using our conditional type, inferFromPropertyType. Note that in order to use this function, we must specify the type of T when we call the function, as seen in the final two lines of the code snippet.

In the first call to our testInferFromPropertyType function, we are specifying that the type of T is an object that has a property name id, which is of type string. Our inferred type, therefore, takes its type from the type of the id property. As the type of the id property is of type string, the argument named arg must be of type string.

The second call to the testInferFromPropertyType function specifies that the type of T is an object that has a property named id of type number. The inferred type U, therefore, is of type number.

Remember that a conditional type is a computed type based on the original type that is given as an input. This means that in order to use a conditional type, we need to supply an input type, and the conditional type will be computed for us, based on the input type.

Type inference from function signatures

In the same way that we can define inferred types based on object properties, we can also infer types based on function signatures. These inferred types can be inferred from either the function arguments, or from the function return type. Let's take a look at an example of this, as follows:

type inferredFromFnParam<T> =
    T extends (a: infer U) => void ? U : never;

Here, we have a conditional type named inferredFromFnParam, which will infer the type of U from the argument named a of a function signature that has a single parameter, and returns void. If the function signature does not match what is specified by the extends clause, that is, it does not take a single parameter, and does not return void, then the inferred type will be never. We can use this inferred type as follows:

function testInferredFromFnParam<T>(
    arg: inferredFromFnParam<T>
) { }
testInferredFromFnParam<(a: number) => void>(1);
testInferredFromFnParam<(a: string) => void>("test");

Here, we have a function name testInferredFromFnParam that accepts a single argument of the type that is the result of our conditional inferred type named inferredFromFnParam. We then call this function twice, providing two function signatures with which to compute the conditional inferred type.

The first call to the testInferredFromFnParam function specifies a function signature that accepts a single argument of type number, and returns void. Our inferred type, therefore, is the type of the argument named a, which in this case is of type number. Therefore, the testInferredFromFnParam function takes a single parameter of type number.

The second call to the testInferredFromFnParam function specifies a function signature that accepts a single argument named a of type string, and returns void. Our inferred conditional type will therefore resolve to the type of the argument a, which in this case is of type string.

In a similar manner, we can also infer a type from the return type of a function, as seen in the following example:

type inferredFromFnReturnType<T> =
    T extends (a: string) => infer U ? U : never;
function testInferredFromReturnType<T>(
    arg: inferredFromFnReturnType<T>
) { }
testInferredFromReturnType<(a: string) => number>(1);
testInferredFromReturnType<(a: string) => boolean>(false);

Here, we have an inferred conditional type named inferredFromFnReturnType, that will infer the type U to be the return type of a function signature that takes a single argument named a of type string. If the function signature does not match the extends clause, then the inferred type will be never.

We have then defined a function named testInferredFromReturnType that defines a type T, and derives the type of its only parameter, named arg, to be the type returned from our inferred conditional type named inferredFromFnReturnType.

We then have two examples of calling this function, which use a function signature as the type of T. The first call uses a function signature that returns a type number, and therefore the arg argument must be of type number. The second call uses a function signature that returns a type string, and therefore the arg argument must be of type string.

Type inference from arrays

There is one other syntax that can be used for an inferred type, which is used when inferring a type from an array. This is best described with an example, as follows:

type inferredTypeFromArray<T> = 
    T extends (infer U)[] ? U : never;
function testInferredFromArray<T>
    (args: inferredTypeFromArray<T>) 
{ }
testInferredFromArray<string[]>("test");
testInferredFromArray<number[]>(1);

Here, we have an inferred conditional type named inferredTypeFromArray that is checking whether the type of T extends an array, that is, T extends []. Note that this extends clause injects an inferred type named U within the extends clause itself by wrapping the inferred type name in braces, that is (infer U). What this means is that the type of U will be inferred from the type of the array itself.

The second line of this code snippet defines a function named testInferredFromArray that uses our inferred conditional type to narrow the type of T to the result of the conditional type. We then call this function with a string array as the type of T. This means that the inferred conditional type will resolve to string, and the type of the args parameter is therefore a string.

Finally, we call this function with a number array as the type of T, and, therefore, the args argument must be of type number as well.

Standard conditional types

There are some handy conditional type combinations that have been included as part of the standard TypeScript library, in much the same way as we saw with the mapped types of Partial and Readonly. Let's explore three of these conditional types, named Exclude, Extract, and NonNullable, as follows:

type ExcludeStringAndNumber = Exclude<
    string | number | boolean,
    string | number>;
let boolValue: ExcludeStringAndNumber = true;

Here, we have defined a type named ExcludeStringAndNumber, which is using the standard conditional type named Exclude. The Exclude conditional type takes two generic parameters. It will exclude those types given in the second generic parameter from the types given in the first generic parameter. In this example, we have specified that we wish to exclude the types of number and string from the list of types number | string | boolean. Logically, this only leaves boolean as a valid type, as seen on the last line of this code snippet. The variable boolValue, of type ExcludeStringAndNumber, only allows assignment of a boolean value.

In a similar manner, the Extract standard conditional type will extract a set of types from another set of types, as can be seen in the following example:

type StringOrNumber = Extract<
    string | boolean | never,
    string | number>;
let stringValue: StringOrNumber = "test";

Here, we have defined a type named StringOrNumber that is using the standard conditional type named Extract, which also takes two types as its generic parameters. The Extract conditionals type will return all matching types given in the second generic parameter from the list given in the first parameter. In our preceding example, we are extracting either a string or a number type from the list of string | boolean | never. Logically, the only matching type in this case is string, as seen by the usage of this type on the last line of the code snippet, where the stringValue variable, of type StringOrNumber, can only be assigned a string value.

Another standard conditional type will exclude null and undefined from a type union. This conditional type is named NonNullable, as follows:

type NotNullOrUndef = NonNullable<number | undefined | null>;
let numValue: NotNullOrUndef = 1;

Here, we have defined a type named NotNullOrUndef, that is using the conditional type named NonNullable to extract the types from a given type union that are not null or undefined. Removing null and undefined from the given type union, which was number | undefined | null, only leaves type number. Our NotNullOrUndef type, therefore, will resolve to a type of number, as can be seen in the usage of this type on the last line of the code snippet.

Summary

In this chapter, we have explored the concepts of generics, including how TypeScript defines a specific syntax for generics, and how we can constrain generic types in certain circumstances. We then discussed how we can use generics with interfaces, and how to create new objects within generic code. The second section of this chapter explored advance type inference, starting with mapped types, and then moving on to conditional types. We discussed distributed conditional types, conditional type inference, and finally took a look at standard conditional types that are available to use with a standard TypeScript installation.

In the next chapter, we will explore asynchronous language features, and how we can use specific TypeScript language constructs to help with the asynchronous nature of JavaScript programming.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.218.196.182