© Adam Freeman 2019
A. FreemanEssential TypeScripthttps://doi.org/10.1007/978-1-4842-4979-6_10

10. Working with Objects

Adam Freeman1 
(1)
London, UK
 
In this chapter, I describe the way that TypeScript deals with objects. As explained in Chapters 3 and 4, JavaScript has a fluid and flexible approach to dealing with objects, and TypeScript aims to strike a balance between preventing the most common mistakes while allowing useful features to be preserved. This is a theme that is continued in Chapter 11, where I describe the TypeScript support for using classes. Table 10-1 summarizes the chapter.
Table 10-1.

Chapter Summary

Problem

Solution

Listing

Describe an object to the TypeScript compiler

Use a shape type

4–6, 8

Describe irregular shape types

Use optional properties

7, 9, 10

Use the same shape to describe multiple objects

Use a type alias

11

Prevent compiler errors when a type contains a superset of the properties in a shape

Enable the suppressExcessPropertyErrors compiler option

12, 13

Combine shape types

Use type unions or intersections

14, 15, 19–25

Type guard for object types

Check the properties defined by an object using the in keyword

16, 17

Reuse a type guard

Define a predicate function

18

For quick reference, Table 10-2 lists the TypeScript compiler options used in this chapter.
Table 10-2.

The TypeScript Compiler Options Used in This Chapter

Name

Description

target

This option specifies the version of the JavaScript language that the compiler will target in its output.

outDir

This option specifies the directory in which the JavaScript files will be placed.

rootDir

This option specifies the root directory that the compiler will use to locate TypeScript files.

declaration

This option produces type declaration files when enabled, which can be useful in understanding how types have been inferred. These files are described in more detail in Chapter 14.

strictNullChecks

This option prevents null and undefined from being accepted as values for other types.

suppressExcessPropertyErrors

This option prevents the compiler from generating errors for objects that define properties not in a specified shape.

Preparing for This Chapter

In this chapter, I continue to use the types project created in Chapter 7 and updated in the chapters since. To prepare for this chapter, replace the contents of the index.ts file in the src folder with the code shown in Listing 10-1.
let hat = { name: "Hat", price: 100 };
let gloves = { name: "Gloves", price: 75 };
let products = [hat, gloves];
products.forEach(prod => console.log(`${prod.name}: ${prod.price}`));
Listing 10-1.

Replacing the Contents of the index.ts File in the src Folder

Reset the configuration of the compiler by replacing the contents of the tsconfig.json file with those shown in Listing 10-2.
{
    "compilerOptions": {
        "target": "es2018",
        "outDir": "./dist",
        "rootDir": "./src",
        "declaration": true,
        //"strictNullChecks": true,
    }
}
Listing 10-2.

Configuring the Compiler in the tsconfig.json File in the types Folder

The compiler configuration includes the declaration setting, which means that the compiler will create type declaration files alongside the JavaScript files. The real purpose of declaration files is explained in Chapter 14, but they will be used in this chapter to explain how the compiler deals with data types.

Open a new command prompt, navigate to the types folder, and run the command shown in Listing 10-3 to start the TypeScript compiler so that it automatically executes code after it has been compiled.

Tip

You can download the example project for this chapter—and for all the other chapters in this book—from https://github.com/Apress/essential-typescript .

npm start
Listing 10-3.

Starting the TypeScript Compiler

The compiler will compile the project, execute the output, and then enter watch mode, producing the following output:
7:10:34 AM - Starting compilation in watch mode...
7:10:35 AM - Found 0 errors. Watching for file changes.
Hat: 100
Gloves: 75

Working with Objects

JavaScript objects are collections of properties that can be created using the literal syntax, constructor functions, or classes. Regardless of how they are created, objects can be altered once they have been created, adding or removing properties and receiving values of different types. To provide type features for objects, TypeScript focuses on an object’s “shape,” which is the combination of its property names and types.

The TypeScript compiler tries to make sure that objects are used consistently by looking for common shape characteristics. The best way to see how this works is to look at the declaration files that the compiler generates when its declarations option is enabled. If you examine the index.d.ts file in the dist folder, you will see that the compiler has used the shape of each object defined in Listing 10-1 as its type, like this:
declare let hat:      { name: string; price: number; };
declare let gloves:   { name: string; price: number; };
declare let products: { name: string; price: number; }[];

I have formatted the contents of the declaration file to make it easier to see how the compiler has identified the type of each object using its shape. When the objects are placed into an array, the compiler uses the shape of the objects to set the type of the array to match.

This may not seem like a useful approach, but it prevents many common mistakes. Listing 10-4 adds an object with a different shape.
let hat = { name: "Hat", price: 100 };
let gloves = { name: "Gloves", price: 75 };
let umbrella = { name: "Umbrella" };
let products = [hat, gloves, umbrella];
products.forEach(prod => console.log(`${prod.name}: ${prod.price}`));
Listing 10-4.

Adding an Object in the index.ts File in the src Folder

Even though the objects in Listing 10-1 are defined using the literal syntax, the TypeScript compiler is able to warn when the objects are used inconsistently. The umbrella object doesn’t have a price property, and the compiler produces the following error when the file is compiled:
src/index.ts(9,60): error TS2339: Property 'price' does not exist on type '{ name: string; }'.
The arrow function used with the forEach method reads a price property that isn’t present on all of the objects in the products array, leading to an error. The compiler correctly identifies the shape of the objects in the example, which can be seen in the index.d.ts file in the dist folder.
declare let hat:      { name: string; price: number; };
declare let gloves:   { name: string; price: number; };
declare let umbrella: { name: string; };
declare let products: { name: string; }[];

Notice that the type for the products array has changed. When objects of different shapes are used together, such as in an array, the compiler creates a type that has the common properties of the objects it contains because they are the only properties that are safe to work with. In the example, the only property common to all the objects in the array is the string property name, which is why the compiler reports an error for the statement that tries to read the price property.

Using Object Shape Type Annotations

For object literals, the TypeScript compiler infers the type of each property using the value that it has been assigned. Types can also be explicitly specified using type annotations, which are applied to individual properties, as shown in Listing 10-5.
let hat = { name: "Hat", price: 100 };
let gloves = { name: "Gloves", price: 75 };
let umbrella = { name: "Umbrella" };
let products: { name: string, price: number }[] = [hat, gloves, umbrella];
products.forEach(prod => console.log(`${prod.name}: ${prod.price}`));
Listing 10-5.

Using Object Shape Type Annotations in the index.ts File in the src Folder

The type annotation restricts the contents of the products array to objects that have name and price properties that are string and number values, as shown in Figure 10-1.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig1_HTML.jpg
Figure 10-1.

An object shape type

The compiler still reports an error for the code in Listing 10-5, but now the problem is that the umbrella object doesn’t conform to the shape specified by the type annotation for the products array, which provides a more useful description of the problem.
src/index.ts(5,64): error TS2741: Property 'price' is missing in type '{ name: string; }' but required in type '{ name: string; price: number; }'.

Understanding How Shape Types Fit

To match a type, an object must define all the properties in the shape. The compiler will still match an object if it has additional properties that are not defined by the shape type, as shown in Listing 10-6.
let hat = { name: "Hat", price: 100 };
let gloves = { name: "Gloves", price: 75 };
let umbrella = { name: "Umbrella", price: 30, waterproof: true };
let products: { name: string, price?: number }[] = [hat, gloves, umbrella];
products.forEach(prod => console.log(`${prod.name}: ${prod.price}`));
Listing 10-6.

Adding Properties in the index.ts File in the src Folder

The new properties allow the umbrella object to match the shape of the array type because it now defines name and price properties. The waterproof property is ignored because it is not part of the shape type. The code in Listing 10-6 produces the following code when it is compiled and executed:
Hat: 100
Gloves: 75
Umbrella: 30

Notice that type annotations are not required to indicate that individual objects have a specific shape. The TypeScript compiler automatically determines whether an object conforms to a shape by inspecting its properties and their values.

Using Optional Properties for Irregular Shapes

Optional properties make a shape type more flexible, allowing it to match objects that don’t have those properties, as shown in Listing 10-7. This can be important when dealing with a set of objects that don’t share the same shape but where you need to use a property when it is available.
let hat = { name: "Hat", price: 100 };
let gloves = { name: "Gloves", price: 75 };
let umbrella = { name: "Umbrella", price: 30, waterproof: true };
let products: { name: string, price?: number, waterproof?: boolean }[]
    = [hat, gloves, umbrella];
products.forEach(prod =>
    console.log(`${prod.name}: ${prod.price} Waterproof: ${ prod.waterproof }`));
Listing 10-7.

Using an Optional Property in the index.ts File in the src Folder

Optional properties are defined using the same syntax as optional function parameters, where a question mark follows the property name, as shown in Figure 10-2.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig2_HTML.jpg
Figure 10-2.

An optional property in a shape type

A shape type with optional properties is able to match objects that don’t define those properties, as long the required properties are defined. When the optional property is used, such as in the forEach function in Listing 10-7, the value of the optional property will be either the value defined by the object or undefined, as shown in the following output from the code when it is compiled and executed:
Hat: 100 Waterproof: undefined
Gloves: 75 Waterproof: undefined
Umbrella: 30 Waterproof: true

The hat and gloves objects don’t define the optional waterproof property, so the value received in the forEach function is undefined. The umbrella object does define this property, and its value is displayed.

Including Methods in Shape Types

Shape types can include methods as well as properties, giving greater control over how objects are matched by the type, as shown in Listing 10-8.
enum Feature { Waterproof, Insulated }
let hat = { name: "Hat", price: 100 };
let gloves = { name: "Gloves", price: 75 };
let umbrella = { name: "Umbrella", price: 30,
        hasFeature: (feature) => feature === Feature.Waterproof };
let products: { name: string, price?: number,
        hasFeature?(Feature): boolean }[]
    = [hat, gloves, umbrella];
products.forEach(prod => console.log(`${prod.name}: ${prod.price} `
    + `Waterproof: ${prod.hasFeature(Feature.Waterproof)}`));
Listing 10-8.

Including a Method in a Shape Type in the index.ts File in the src Folder

The type annotation for the products array includes an optional property called hasFeature that represents a method. A method property is similar to a regular property with the addition of parentheses that describe the types of the parameters, followed by a colon and then the result type, as shown in Figure 10-3.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig3_HTML.jpg
Figure 10-3.

A method in a shape type

The method included in the shape type in Listing 10-8 specifies a method called hasFeature that has one parameter, which must be a value from the Feature enum (also defined in Listing 10-8) and which returns a boolean result.

Tip

Methods in shape types don’t have to be optional, but when they are, as in Listing 10-8, the question mark comes after the method name and before the parentheses that denote the start of the parameter types.

The umbrella object defines the hasFeature method with the correct types, but since the method is optional, the hat and gloves object are also matched by the shape type. As with regular properties, optional methods are undefined when they are not present on an object, which means that the code in Listing 10-8 produces the following error when compiled and executed:
C: ypesdistindex.js:12  + `Waterproof: ${prod.hasFeature(Feature.Waterproof)}`));
TypeError: prod.hasFeature is not a function

As with regular properties, you must ensure that a method is implemented before it is invoked.

Enforcing Strict Checking for Methods
To help prevent errors like the one in the previous section, the TypeScript compiler can report errors when an optional method specified by a shape type is used without checking for undefined values. This check is enabled by the strictNullChecks setting, which has also been used in earlier chapters. Change the configuration of the compiler by enabling the settings as shown in Listing 10-9.
{
    "compilerOptions": {
        "target": "es2018",
        "outDir": "./dist",
        "rootDir": "./src",
        "declaration": true,
        "strictNullChecks": true
    }
}
Listing 10-9.

Configuring the Compiler in the tsconfig.json File in the types Folder

When the configuration file is saved, the compiler will rebuild the project and produce the following error:
src/index.ts(13,22): error TS2722: Cannot invoke an object which is possibly 'undefined'.
This error prevents the use of optional methods until they are checked to make sure they exist on an object, as shown in Listing 10-10.
enum Feature { Waterproof, Insulated }
let hat = { name: "Hat", price: 100 };
let gloves = { name: "Gloves", price: 75 };
let umbrella = { name: "Umbrella", price: 30,
        hasFeature: (feature) => feature === Feature.Waterproof };
let products: { name: string, price?: number, hasFeature?(Feature): boolean }[]
    = [hat, gloves, umbrella];
products.forEach(prod => console.log(`${prod.name}: ${prod.price} `
    + `${ prod.hasFeature ? prod.hasFeature(Feature.Waterproof) : "false" }`));
Listing 10-10.

Checking for an Optional Method in the index.ts File in the src Folder

The hasFeature method is invoked only if it has been defined, and the code in Listing 10-10 produces the following output when it is compiled and executed:
Hat: 100 false
Gloves: 75 false
Umbrella: 30 true

Using Type Aliases for Shape Types

A type alias can be used to give a name to a specific shape, making it easier to refer to the shape in code consistently, as shown in Listing 10-11.
enum Feature { Waterproof, Insulated }
type Product = {
    name: string,
    price?: number,
    hasFeature?(Feature): boolean
};
let hat = { name: "Hat", price: 100 };
let gloves = { name: "Gloves", price: 75 };
let umbrella = { name: "Umbrella", price: 30,
        hasFeature: (feature) => feature === Feature.Waterproof };
let products: Product[] = [hat, gloves, umbrella];
products.forEach(prod => console.log(`${prod.name}: ${prod.price} `
    + `${ prod.hasFeature ? prod.hasFeature(Feature.Waterproof) : "false" }`));
Listing 10-11.

Using an Alias for a Shape Type in the index.ts File in the src Folder

The alias assigns a name to the shape, which can be used in type annotations. In the listing, an alias named Product is created and used as the type for the array. Using an alias doesn’t change the output from the code when it is compiled and executed.
Hat: 100 false
Gloves: 75 false
Umbrella: 30 true

Dealing with Excess Properties

The TypeScript compiler is good at inferring types, which means that type annotations can often be omitted. There are times, however, when providing the compiler with information about types can change its behavior, as demonstrated in Listing 10-12.
enum Feature { Waterproof, Insulated }
type Product = {
    name: string,
    price?: number,
    hasFeature?(Feature): boolean
};
let hat = { name: "Hat", price: 100 };
let gloves = { name: "Gloves", price: 75 };
let umbrella = { name: "Umbrella", price: 30,
        hasFeature: (feature) => feature === Feature.Waterproof };
let mirrorShades = { name: "Sunglasses", price: 54, finish: "mirrored"};
let darkShades: Product = { name: "Sunglasses", price: 54, finish: "flat"};
let products: Product[] = [hat, gloves, umbrella, mirrorShades, darkShades];
products.forEach(prod => console.log(`${prod.name}: ${prod.price} `
    + `${ prod.hasFeature ? prod.hasFeature(Feature.Waterproof) : "false" }`));
Listing 10-12.

Defining Objects in the index.ts File in the src Folder

When the code is compiled, the compiler will report the following error:
src/index.ts(16,60): error TS2322: Type '{ name: string; price: number; finish: string; }' is not assignable to type 'Product'
  Object literal may only specify known properties, and 'finish' does not exist in type 'Product'.

The compiler treats the mirrorShades and darkShades objects differently, even though they have the same shape. The compiler reports errors when object literals with type annotations define additional properties, because this is likely to be a mistake. In the case of the example, the darkShades object has a Product type annotation. The finish property isn’t part of the Product shape and is known as an excess property, which the compiler reports as an error. Excess properties do not cause errors when an object is defined without a type annotation, which means the darkShades object can be used as a Product.

I can prevent the error by removing the excess property or by removing the type annotation, but my preference is to disable excess property checking entirely because I find it counterintuitive. Listing 10-13 shows the changes to the compiler configuration file.
{
    "compilerOptions": {
        "target": "es2018",
        "outDir": "./dist",
        "rootDir": "./src",
        "declaration": true,
        "strictNullChecks": true,
        "suppressExcessPropertyErrors": true
    }
}
Listing 10-13.

Configuring the Compiler in the tsconfig.json File in the types Folder

When the suppressExcessPropertyErrors setting is true, the compiler won’t report an error if an object literal defines properties that are not part of the type declared by the annotation. When the change to the configuration file is saved, the code will be compiled and executed and produce the following output:
Hat: 100 false
Gloves: 75 false
Umbrella: 30 true
Sunglasses: 54 false
Sunglasses: 54 false

Using Shape Type Unions

In Chapter 7, I described the type union feature that allows multiple types to be expressed together so that, for example, arrays or function parameters can accept multiple types. As I explained, type unions are types in their own right and contain the properties that are defined by all of their constituent types. This isn’t a useful feature when dealing with unions of primitive data types because there are few common properties, but it is a more useful feature when dealing with objects, as shown in Listing 10-14.
type Product = {
    id: number,
    name: string,
    price?: number
};
type Person = {
    id: string,
    name: string,
    city: string
};
let hat = { id: 1, name: "Hat", price: 100 };
let gloves = { id: 2, name: "Gloves", price: 75 };
let umbrella = { id: 3, name: "Umbrella", price: 30 };
let bob = { id: "bsmith", name: "Bob", city: "London" };
let dataItems: (Product | Person)[] = [hat, gloves, umbrella, bob];
dataItems.forEach(item => console.log(`ID: ${item.id}, Name: ${item.name}`));
Listing 10-14.

Using a Type Union in the index.ts File in the src Folder

The dataItems array in this example has been annotated with a union of the Product and Person types. These types have two properties in common, id and name, which means these properties can be used when processing the array without having to narrow to a single type.
...
dataItems.forEach(item => console.log(`ID: ${item.id}, Name: ${item.name}`));
...
These are the only properties that can be accessed because they are the only properties shared by all types in the union. Any attempt to access the price property defined by the Product type or the city property defined by the Person type will produce an error because these properties are not part of the Product | Person union. The code in Listing 10-14 produces the following output:
ID: 1, Name: Hat
ID: 2, Name: Gloves
ID: 3, Name: Umbrella
ID: bsmith, Name: Bob

Understanding Union Property Types

When a union of shape types is created, the types of each common property are combined, also using a union. This effect can be more easily understood by creating a type that is equivalent to the union, as shown in Listing 10-15.
type Product = {
    id: number,
    name: string,
    price?: number
};
type Person = {
    id: string,
    name: string,
    city: string
};
type UnionType = {
    id: number | string,
    name: string
};
let hat = { id: 1, name: "Hat", price: 100 };
let gloves = { id: 2, name: "Gloves", price: 75 };
let umbrella = { id: 3, name: "Umbrella", price: 30 };
let bob = { id: "bsmith", name: "Bob", city: "London" };
let dataItems: UnionType[] = [hat, gloves, umbrella, bob];
dataItems.forEach(item => console.log(`ID: ${item.id}, Name: ${item.name}`));
Listing 10-15.

Creating an Equivalent Type in the index.ts File in the src Folder

The UnionType shows the effect of the union between the Product and Person types. The id property type is a number | string union because the id property in the Product type is a number, but the id property in the Person type is a string. The name property in both types is a string, so this is the type for the name property in the union. The code in Listing 10-15 produces the following output when it is compiled and executed:
ID: 1, Name: Hat
ID: 2, Name: Gloves
ID: 3, Name: Umbrella
ID: bsmith, Name: Bob

Using Type Guards for Objects

The previous section demonstrated how unions of shape types can be useful in their own right, but type guards are still required to get to a specific type to access all of the features it defines.

In Chapter 7, I demonstrated how the typeof keyword can be used to create type guards. The typeof keyword is a standard JavaScript feature that the TypeScript compiler recognizes and uses during the type-checking process. But the typeof keyword cannot be used with objects because it will always return the same result, as demonstrated in Listing 10-16.
type Product = {
    id: number,
    name: string,
    price?: number
};
type Person = {
    id: string,
    name: string,
    city: string
};
let hat = { id: 1, name: "Hat", price: 100 };
let gloves = { id: 2, name: "Gloves", price: 75 };
let umbrella = { id: 3, name: "Umbrella", price: 30 };
let bob = { id: "bsmith", name: "Bob", city: "London" };
let dataItems: (Product | Person)[] = [hat, gloves, umbrella, bob];
dataItems.forEach(item => console.log(`ID: ${item.id}, Type: ${typeof item}`));
Listing 10-16.

Type Guarding in the index.ts File in the src Folder

This listing resets the type of the array to be a union of the Product and Person types and uses the typeof keyword in the forEach function to determine the type of each item in the array, producing the following results when the code is compiled and executed:
ID: 1, Type: object
ID: 2, Type: object
ID: 3, Type: object
ID: bsmith, Type: object

The shape type feature is provided entirely by TypeScript, and all objects have the type object as far as JavaScript is concerned, with the result that the typeof keyword isn’t useful for determining whether an object conforms to the Product and Person shapes.

Type Guarding by Checking Properties

The simplest way to differentiate between shape types is to use the JavaScript in keyword to check for a property, as shown in Listing 10-17.
type Product = {
    id: number,
    name: string,
    price?: number
};
type Person = {
    id: string,
    name: string,
    city: string
};
let hat = { id: 1, name: "Hat", price: 100 };
let gloves = { id: 2, name: "Gloves", price: 75 };
let umbrella = { id: 3, name: "Umbrella", price: 30 };
let bob = { id: "bsmith", name: "Bob", city: "London" };
let dataItems: (Product | Person)[] = [hat, gloves, umbrella, bob];
dataItems.forEach(item => {
    if ("city" in item) {
        console.log(`Person: ${item.name}: ${item.city}`);
    } else  {
        console.log(`Product: ${item.name}: ${item.price}`);
    }
});
Listing 10-17.

Type Guarding in the index.ts File in the src Folder

The goal is to be able to determine each object in the array conforms to the Product shape or the Person shape. We know these are the only types that the array can contain because its type annotation is (Product | Person)[].

A shape is a combination of properties, and a type guard must test for one or more properties that are included in one shape but not the other. In the case of Listing 10-17, any object that has a city property must conform to the Person shape since this property is not part of the Product shape. To create a type guard that checks for a property, the property name is expressed as a string literal, followed by the in keyword, followed by the object to test, as shown in Figure 10-4.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig4_HTML.jpg
Figure 10-4.

Using the in keyword

The in expression returns true for objects that define the specified property and false otherwise. The TypeScript compiler recognizes the significance of testing for a property and infers the type within the code blocks of the if/else statement. The code in Listing 10-17 produces the following output when compiled and executed:
Product: Hat: 100
Product: Gloves: 75
Product: Umbrella: 30
Person: Bob: London

Avoiding Common Type Guard Problems

It is important to create type guard tests that definitively and accurately differentiate between types. If the compiler gives you unexpected errors when you have used a type guard, then the likely cause is an inaccurate test.

There are two common problems to avoid. The first is creating an inaccurate test that doesn’t reliably differentiate between types, such as this test:
dataItems.forEach(item => {
    if ("id" in item && "name" in item) {
        console.log(`Person: ${item.name}: ${item.city}`);
    } else  {
        console.log(`Product: ${item.name}: ${item.price}`);
    }
});

This test checks for id and name properties, but these are defined by both the Person and Product types, and the test doesn’t give the compiler enough information to infer a type. The type inferred in the if block is the Product | Person union, which means the use of the city property will generate an error. The type inferred in the else block is never, since all the possible types have already been inferred, and the compiler will generate errors for the use of the name and price properties.

A related problem is testing for an optional property, like this:
dataItems.forEach(item => {
    if ("price" in item) {
        console.log(`Product: ${item.name}: ${item.price}`);
    } else  {
        console.log(`Person: ${item.name}: ${item.city}`);
    }
});

The test will match objects that define a price property, which means that the type inferred in the if block will be Product, as intended (notice that the statements in the code blocks are reversed in this example). The problem is that objects can still match the Product shape if they don’t have a price property, which means the type inferred in the else block is Product | Person and the compiler will report an error for the use of the city property.

Writing effective tests for types can require careful thought and thorough testing, although the process becomes easier with experience.

Type Guarding with a Type Predicate Function

The in keyword is a useful way to identify whether an object conforms to a shape, but it requires the same checks to be written each time types need to be identified. TypeScript also supports guarding object types using a function, as shown in Listing 10-18.
type Product = {
    id: number,
    name: string,
    price?: number
};
type Person = {
    id: string,
    name: string,
    city: string
};
let hat = { id: 1, name: "Hat", price: 100 };
let gloves = { id: 2, name: "Gloves", price: 75 };
let umbrella = { id: 3, name: "Umbrella", price: 30 };
let bob = { id: "bsmith", name: "Bob", city: "London" };
let dataItems: (Product | Person)[] = [hat, gloves, umbrella, bob];
function isPerson(testObj: any): testObj is Person {
    return testObj.city !== undefined;
}
dataItems.forEach(item => {
    if (isPerson(item)) {
        console.log(`Person: ${item.name}: ${item.city}`);
    } else  {
        console.log(`Product: ${item.name}: ${item.price}`);
    }
});
Listing 10-18.

Type Guarding with a Function in the index.ts File in the src Folder

Type guarding for objects is done with a function that uses the is keyword, as shown in Figure 10-5.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig5_HTML.jpg
Figure 10-5.

An object type guard function

The result of the function, which is a type predicate, tells the compiler which of the function’s parameters is being tested and the type that the function checks for. In Listing 10-18, the isPerson function tests its testObj parameter for the Person type. If the result of the function is true, then the TypeScript compiler will treat the object as the specified type.

Using a function for type guarding can be more flexible because the parameter type is any, allowing properties to be tested for without having to use string literals and the in keyword.

Tip

The are no restrictions on the name of the type guard function, but the convention is to prefix the guarded type with is, such that a function that tests for the Person type is named isPerson and a function that tests for the Product type is named isProduct.

The code in Listing 10-18 produces the following output when compiled and executed, showing that using the guard function has the same effect as the in keyword:
Product: Hat: 100
Product: Gloves: 75
Product: Umbrella: 30
Person: Bob: London

Using Type Intersections

Type intersections combine the features of multiple types, allowing all the features to be used. This is in contrast to type unions, which only allow the use of common features. Listing 10-19 shows an intersection type being defined and used.
type Person = {
    id: string,
    name: string,
    city: string
};
type Employee = {
    company: string,
    dept: string
};
let bob = { id: "bsmith", name: "Bob", city: "London",
    company: "Acme Co", dept: "Sales" };
let dataItems: (Person & Employee)[] = [bob];
dataItems.forEach(item => {
    console.log(`Person: ${item.id}, ${item.name}, ${item.city}`);
    console.log(`Employee: ${item.id}, ${item.company}, ${item.dept}`);
});
Listing 10-19.

Defining a Type Intersection in the index.ts File in the src Folder

The type of the dataItems array is set to the intersection of the Person and Employee types. Intersections are defined using the ampersand between two or more types, as shown in Figure 10-6.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig6_HTML.jpg
Figure 10-6.

Defining an intersection type

An object will conform to the shape of a type intersection only if it defines the properties defined by merging all the types in that intersection, as shown in Figure 10-7.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig7_HTML.jpg
Figure 10-7.

The effect of a type intersection

In Listing 10-19, the intersection between Person and Employee types has the effect that the dataItems array can contain only objects that define id, name, city, company, and dept properties.

The contents of the array are processed using the forEach method, which demonstrates that the properties from both types in the intersection can be used. The code in the listing produces the following output when compiled and executed:
Person: bsmith, Bob, London
Employee: bsmith, Acme Co, Sales

Using Intersections for Data Correlation

Intersections are useful when you receive objects from one source and need to introduce new functionality so they can be used elsewhere in the application or when objects from two data sources need to be correlated and combined. JavaScript makes it easy to introduce functionality from one object into another, and intersections allow the types that are used to be clearly described so they can be checked by the TypeScript compiler. Listing 10-20 shows a function that correlates two data arrays.
type Person = {
    id: string,
    name: string,
    city: string
};
type Employee = {
    id: string,
    company: string,
    dept: string
};
type EmployedPerson = Person & Employee;
function correlateData(peopleData: Person[], staff: Employee[]): EmployedPerson[] {
    const defaults = { company: "None", dept: "None"};
    return peopleData.map(p => ({ ...p,
        ...staff.find(e => e.id === p.id) || { ...defaults, id: p.id } }));
}
let people: Person[] =
    [{ id: "bsmith", name: "Bob Smith", city: "London" },
     { id: "ajones", name: "Alice Jones", city: "Paris"},
     { id: "dpeters", name: "Dora Peters", city: "New York"}];
let employees: Employee[] =
    [{ id: "bsmith", company: "Acme Co", dept: "Sales" },
     { id: "dpeters", company: "Acme Co", dept: "Development" }];
let dataItems: EmployedPerson[] = correlateData(people, employees);
dataItems.forEach(item => {
    console.log(`Person: ${item.id}, ${item.name}, ${item.city}`);
    console.log(`Employee: ${item.id}, ${item.company}, ${item.dept}`);
});
Listing 10-20.

Correlating Data in the index.ts File in the src Folder

In this example, the correlateData function receives an array of Person objects and an array of Employee objects and uses the id property they share to produce objects that combine the properties of both shape types. As each Person object is processed by the map method, the array find method is used to locate the Employee object with the same id value, and the object spread operator is used to create objects that match the intersection shape. Since the results from the correlateData function have to define all the intersection properties, I use default values when there is no matching Employee object.
...
const defaults = { company: "None", dept: "None"};
return peopleData.map(p => ({ ...p,
    ...staff.find(e => e.id === p.id) || { ...defaults, id: p.id } }));
...

I used type annotations in Listing 10-20 to make the purpose of the code easier to understand, but the code would work without them. The TypeScript compiler is adept at understanding the effect of code statements and is able to understand the effect of this statement is to create objects that conform to the shape of the type intersection.

The code in Listing 10-20 produces the following output when it is compiled and executed:
Person: bsmith, Bob Smith, London
Employee: bsmith, Acme Co, Sales
Person: ajones, Alice Jones, Paris
Employee: ajones, None, None
Person: dpeters, Dora Peters, New York
Employee: dpeters, Acme Co, Development

Understanding Intersection Merging

Because an intersection combines features from multiple types, an object that conforms to the intersection shape also conforms to each of the types in the intersection. For example, an object that conforms to Person & Employee can be used where the Person type or the Employee type are specified, as shown in Listing 10-21.
type Person = {
    id: string,
    name: string,
    city: string
};
type Employee = {
    id: string,
    company: string,
    dept: string
};
type EmployedPerson = Person & Employee;
function correlateData(peopleData: Person[], staff: Employee[]): EmployedPerson[] {
    const defaults = { company: "None", dept: "None"};
    return peopleData.map(p => ({ ...p,
        ...staff.find(e => e.id === p.id) || { ...defaults, id: p.id } }));
}
let people: Person[] =
    [{ id: "bsmith", name: "Bob Smith", city: "London" },
     { id: "ajones", name: "Alice Jones", city: "Paris"},
     { id: "dpeters", name: "Dora Peters", city: "New York"}];
let employees: Employee[] =
    [{ id: "bsmith", company: "Acme Co", dept: "Sales" },
     { id: "dpeters", company: "Acme Co", dept: "Development" }];
let dataItems: EmployedPerson[] = correlateData(people, employees);
function writePerson(per: Person): void {
    console.log(`Person: ${per.id}, ${per.name}, ${per.city}`);
}
function writeEmployee(emp: Employee): void {
    console.log(`Employee: ${emp.id}, ${emp.company}, ${emp.dept}`);
}
dataItems.forEach(item => {
    writePerson(item);
    writeEmployee(item);
});
Listing 10-21.

Using Underlying Types in an Intersection in the index.ts File in the src Folder

The compiler matches an object to a shape by ensuring that it defines all the properties in the shape and doesn’t care about excess properties (except when defining an object literal, as explained earlier in the chapter). The objects that conform to the EmployedPerson type can be used in the writePerson and writeEmployee functions because they conform to the types specified for the function’s parameters. The code in Listing 10-21 produces the following output:
Person: bsmith, Bob Smith, London
Employee: bsmith, Acme Co, Sales
Person: ajones, Alice Jones, Paris
Employee: ajones, None, None
Person: dpeters, Dora Peters, New York
Employee: dpeters, Acme Co, Development

It may seem obvious that an intersection type is compatible with each of its constituents, but it has an important effect when the types in the intersection define properties with the same name: the type of the property in the intersection is an intersection of the individual property types. That sentence is hard to make sense of, so the sections that follow provide a more useful explanation.

Merging Properties with the Same Type

The simplest situation is where there are properties with the same name and the same type, such as the id properties defined by the Person and Employee types, which are merged into the intersection without any changes, as shown in Figure 10-8.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig8_HTML.jpg
Figure 10-8.

Merging properties with the same type

There are no issues to deal with in this situation because any value assigned to the id property will be a string and will conform to the requirements of the object and intersection types.

Merging Properties with Different Types

If there are properties with the same name but different types, the compiler keeps the property name but intersects the type. To demonstrate, Listing 10-22 removes the functions and adds a contact property to the Person and Employee types.
type Person = {
    id: string,
    name: string,
    city: string,
    contact: number
};
type Employee = {
    id: string,
    company: string,
    dept: string,
    contact: string
};
type EmployedPerson = Person & Employee;
let typeTest = ({} as EmployedPerson).contact;
Listing 10-22.

Adding Properties with Different Types in the index.ts File in the src Folder

The last statement in Listing 10-22 is a useful trick for seeing what type the compiler assigns to a property in the intersection by looking at the declaration file created in the dist folder when the declaration compiler configuration option is true. The statement uses a type assertion to tell the compiler that an empty object conforms to the EmployedPeson type and assigns the contact property to the typeTest variable. When the changes to the index.ts file are saved, the compiler will compile the code, and the index.d.ts file in the dist folder will show the type for the contact property in the intersection.
declare let typeTest: number & string;
The compiler created an intersection between the type of the contact property defined by Person and the type of the contact property defined by Employee, as shown in Figure 10-9.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig9_HTML.jpg
Figure 10-9.

Merging properties with different types

Creating an intersection of the types is the only way the compiler can merge the properties, but it doesn’t produce a useful result because there are no values that can be assigned to the intersection of the primitive number and string types, as shown in Listing 10-23.
type Person = {
    id: string,
    name: string,
    city: string,
    contact: number
};
type Employee = {
    id: string,
    company: string,
    dept: string,
    contact: string
};
type EmployedPerson = Person & Employee;
let typeTest = ({} as EmployedPerson).contact;
let person1: EmployedPerson = {
    id: "bsmith", name: "Bob Smith", city: "London",
    company: "Acme Co", dept: "Sales", contact: "Alice"
};
let person2: EmployedPerson = {
    id: "dpeters", name: "Dora Peters", city: "New York",
    company: "Acme Co", dept: "Development", contact: 6512346543
};
Listing 10-23.

Assigning Values to the Intersection of Primitives in the index.ts File in the src Folder

An object has to assign a value to the contact property to conform to the shape, but doing so creates the following errors:
src/index.ts(19,40): error TS2322: Type 'string' is not assignable to type 'number & string'.
  Type 'string' is not assignable to type 'number'.
src/index.ts(24,46): error TS2322: Type 'number' is not assignable to type 'number & string'.
  Type 'number' is not assignable to type 'string'.

The intersection of number and string is an impossible type. There is no way to work around this problem for primitive types, and the only solution is to adjust the types used in the intersection so that shape types are used instead of primitives, as shown in Listing 10-24.

Note

It might seem odd that the TypeScript compiler allows impossible types to be defined, but the reason is that some of the advanced TypeScript features, described in later chapters, make it difficult for the compiler to deal with all situations consistently, and the Microsoft development team has chosen simplicity over exhaustively checking for every impossible type.

type Person = {
    id: string,
    name: string,
    city: string,
    contact: { phone: number }
};
type Employee = {
    id: string,
    company: string,
    dept: string,
    contact: { name: string }
};
type EmployedPerson = Person & Employee;
let typeTest = ({} as EmployedPerson).contact;
let person1: EmployedPerson = {
    id: "bsmith", name: "Bob Smith", city: "London",
    company: "Acme Co", dept: "Sales",
    contact: { name: "Alice" , phone: 6512346543 }
};
let person2: EmployedPerson = {
    id: "dpeters", name: "Dora Peters", city: "New York",
    company: "Acme Co", dept: "Development",
    contact: { name: "Alice" , phone: 6512346543 }
};
Listing 10-24.

Using Shape Types in an Intersection in the index.ts File in the src Folder

The compiler handles the property merge in the same way, but the result of the intersection is a shape that has name and phone properties, as shown in Figure 10-10.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig10_HTML.jpg
Figure 10-10.

Merging properties with shape types

The intersection of an object with a phone property and an object with a name property is an object with phone and name properties, which makes it possible to assign contact values that conform to the Person and Employee types and their intersection.

Merging Methods

If the types in an intersection define methods with the same name, then the compiler will create a function whose signature is an intersection, as shown in Listing 10-25.
type Person = {
    id: string,
    name: string,
    city: string,
    getContact(field: string): string
};
type Employee = {
    id: string,
    company: string,
    dept: string
    getContact(field: number): number
};
type EmployedPerson = Person & Employee;
let person: EmployedPerson = {
    id: "bsmith", name: "Bob Smith", city: "London",
    company: "Acme Co", dept: "Sales",
    getContact(field: string | number): any {
        return typeof field === "string" ? "Alice" : 6512346543;
    }
};
let typeTest = person.getContact;
let stringParamTypeTest = person.getContact("Alice");
let numberParamTypeTest = person.getContact(123);
console.log(`Contact: ${person.getContact("Alice")}`);
console.log(`Contact: ${person.getContact(12)}`);
Listing 10-25.

Merging Methods in the index.ts File in the src Folder

The compiler will merge the functions by creating an intersection of their signatures, which can produce impossible types or functions that cannot be usefully implemented. In the example, the getContact methods in the Person and Employee types are intersected, as shown in Figure 10-11.
../images/481342_1_En_10_Chapter/481342_1_En_10_Fig11_HTML.jpg
Figure 10-11

Merging methods

It can be difficult to work out the consequences of merging methods in an intersection, but the overall effect is similar to type overloading, described in Chapter 8. I often rely on the type declaration file to make sure that I have achieved the intersection I want, and there are three statements in Listing 10-25 that help show how the methods have been merged.
...
let typeTest = person.getContact;
let stringParamTypeTest = person.getContact("Alice");
let numberParamTypeTest = person.getContact(123);
...
When the index.ts file is saved and compiled, the index.d.ts file in the dist folder will contain statements that show the type the compiler has assigned to each of the variables:
declare let typeTest: ((field: string) => string) & ((field: number) => number);
declare let stringParamTypeTest: string;
declare let numberParamTypeTest: number;

The first statement shows the type of the intersected method, and the other statements show the type returned when string and number arguments are used. (I explain the intended purpose of the index.d.ts file in Chapter 14, but taking advantage of this feature to see the types that the compiler is working with is often useful.)

The implementation of an intersected method must preserve compatibility with the methods in the intersection. Parameters are usually easy to deal with, and in Listing 10-25, I used a type union to create a method that can receive string and number values. Method results are more difficult to deal with because it can be hard to find a type that preserves compatibility. I find the most reliable approach is to use any as the method result and use type guards to create the mappings between parameters and result types.
...
getContact(field: string | number): any {
    return typeof field === "string" ? "Alice" : 6512346543;
}
...
I try to avoid using any as much as possible, but there is no other type that can be specified in this example that allows an EmployedPerson object to be used both as a Person and an Employee object. The code in Listing 10-25 produces the following output when compiled and executed:
Contact: Alice
Contact: 6512346543

Summary

In this chapter, I describe the way that TypeScript uses an object’s shape to perform type checking. I explained how shapes are compared, how shapes can be used for aliases, and how shapes are combined into unions and intersections. In the next chapter, I explain how the shape features are used to provide type support for classes.

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

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