Chapter 4. Objects

Object literals

A set of keys and values

Each with their own type

Chapter 3, “Unions and Literals” fleshed out union and literal types: working with primitives such as boolean and literal values of them such as true. Those primitives only scratch the surface of the complex object shapes JavaScript code commonly uses. TypeScript would be pretty unusable if it weren’t able to represent those objects. This chapter will cover how to describe complex object shapes and how TypeScript checks their assignability.

Object Types

When you create an object literal with {...} syntax, TypeScript will consider it to be a new object type, or type shape, based on its properties. That object type will have the same property names and primitive types as the object’s values. Accessing properties of the value can be done with either value.member or the equivalent value['member'] syntax.

TypeScript understands that the following poet variable’s type is that of an object with two properties: born, of type number, and name, of type string. Accessing those members would be allowed, but attempting to access any other member name would cause a type error for that name not existing:

const poet = {
    born: 1935,
    name: "Mary Oliver",
};

poet['born']; // Type: number
poet.name; // Type: string

poet.end;
//   ~~~
// Error: Property 'end' does not exist on
// type '{ born: number; name: string; }'.

Object types are a core concept for how TypeScript understands JavaScript code. Every value other than null and undefined has a set of members in its backing type shape, and so TypeScript must understand the object type for every value in order to type check it.

Declaring Object Types

Inferring types directly from existing objects is all fine and good, but eventually you’ll want to be able to declare the type of an object explicitly. You’ll need a way to describe an object shape separately from objects that satisfy it.

Object types may be described using a syntax that looks similar to object literals but with types instead of values for fields. It’s the same syntax that TypeScript shows in error messages about type assignability.

This poetLater variable is the same type from before with name: string and born: number:

let poetLater: {
    born: number;
    name: string;
};

// Ok
poetLater = {
    born: 1935,
    name: "Mary Oliver",
};

poetLater = "Sappho";
// Error: Type 'string' is not assignable to
// type '{ born: number; name: string; }'

Aliased Object Types

Constantly writing out object types like { born: number; name: string; } would get tiresome rather quickly. It’s more common to use type aliases to assign each type shape a name.

The previous code snippet could be rewritten with a type Poet, which comes with the added benefit of making TypeScript’s assignability error message a little more direct and readable:

type Poet = {
    born: number;
    name: string;
};

let poetLater: Poet;

// Ok
poetLater = {
    born: 1935,
    name: "Sara Teasdale",
};

poetLater = "Emily Dickinson";
// Error: Type 'string' is not assignable to type 'Poet'.
Note

Most TypeScript projects prefer using the interface keyword to describe object types, which is a feature I won’t cover until Chapter 7, “Interfaces”. Aliased object types and interfaces are almost identical: everything in this chapter applies to interfaces as well.

I bring these object types up now because understanding how TypeScript interprets object literals is an important part of learning about TypeScript’s type system. These concepts will continue to be important once we switch over to features in the next section of this book.

Structural Typing

TypeScript’s type system is structurally typed: meaning any value that happens to satisfy a type is allowed to be used as a value of that type. In other words, when you declare that a parameter or variable is of a particular object type, you’re telling TypeScript that whatever object(s) you use, they need to have those properties.

The following WithFirstName and WithLastName aliased object types both only declare a single member of type string. The hasBoth variable just so happens to have both of them—even though it wasn’t declared as such explicitly—so it can be provided to variables that are declared as either of the two aliased object types:

type WithFirstName = {
  firstName: string;
};

type WithLastName = {
  lastName: string;
};

const hasBoth = {
  firstName: "Lucille",
  lastName: "Clifton",
};

// Ok: `hasBoth` contains a `firstName` property of type `string`
let withFirstName: WithFirstName = hasBoth;

// Ok: `hasBoth` contains a `lastName` property of type `string`
let withLastName: WithLastName = hasBoth;

Structural typing is not the same as duck typing, which comes from the phrase “If it looks like a duck and quacks like a duck, it’s probably a duck.”

  • Structural typing is when there is a static system checking the type—in TypeScript’s case, the type checker.

  • Duck typing is when nothing checks object types until they’re used at runtime.

In summary: JavaScript is duck typed whereas TypeScript is structurally typed.

Usage Checking

When providing a value to a location annotated with an object type, TypeScript will check that the value is assignable to that object type. To start, the value must have the object type’s required properties. If any member required on the object type is missing in the object, TypeScript will issue a type error.

The following FirstAndLastNames aliased object type requires that both the first and last properties exist. An object containing both of those is allowed to be used in a variable declared to be of type FirstAndLastNames, but an object without them is not:

type FirstAndLastNames = {
  first: string;
  last: string;
};

// Ok
const hasBoth: FirstAndLastNames = {
  first: "Sarojini",
  last: "Naidu",
};

const hasOnlyOne: FirstAndLastNames = {
  first: "Sappho"
};
// Property 'last' is missing in type '{ first: string; }'
// but required in type 'FirstAndLastNames'.

Mismatched types between the two are not allowed either. Object types specify both the names of required properties and the types those properties are expected to be. If an object’s property doesn’t match, TypeScript will report a type error.

The following TimeRange type expects the start member to be of type Date. The hasStartString object is causing a type error because its start is type string instead:

type TimeRange = {
  start: Date;
};

const hasStartString: TimeRange = {
  start: "1879-02-13",
  // Error: Type 'string' is not assignable to type 'Date'.
};

Excess Property Checking

TypeScript will report a type error if a variable is declared with an object type and its initial value has more fields than its type describes. Therefore, declaring a variable to be of an object type is a way of getting the type checker to make sure it has only the expected fields on that type.

The following poetMatch variable has exactly the fields described in the object type aliased by Poet, while extraProperty causes a type error for having an extra property:

type Poet = {
    born: number;
    name: string;
}

// Ok: all fields match what's expected in Poet
const poetMatch: Poet = {
  born: 1928,
  name: "Maya Angelou"
};

const extraProperty: Poet = {
    activity: "walking",
    born: 1935,
    name: "Mary Oliver",
};
// Error: Type '{ activity: string; born: number; name: string; }'
// is not assignable to type 'Poet'.
//   Object literal may only specify known properties,
//   and 'activity' does not exist in type 'Poet'.

Note that excess property checks only trigger for object literals being created in locations that are declared to be an object type. Providing an existing object literal bypasses excess property checks.

This extraPropertyButOk variable does not trigger a type error with the previous example’s Poet type because its initial value happens to structurally match Poet:

const existingObject = {
    activity: "walking",
    born: 1935,
    name: "Mary Oliver",
};

const extraPropertyButOk: Poet = existingObject; // Ok

Excess property checks will trigger anywhere a new object is being created in a location that expects it to match an object type—which as you’ll see in later chapters includes array members, class fields, and function parameters. Banning excess properties is another way TypeScript helps make sure your code is clean and does what you expect. Excess properties not declared in their object types are often either mistyped property names or unused code.

Nested Object Types

As JavaScript objects can be nested as members of other objects, TypeScript’s object types must be able to represent nested object types in the type system. The syntax to do so is the same as before but with a { ... } object type instead of a primitive name.

Poem type is declared to be an object whose author property has firstName: string and lastName: string. The poemMatch variable is assignable to Poem because it matches that structure, while poemMismatch is not because its author property includes name instead of firstName and lastName:

type Poem = {
    author: {
        firstName: string;
        lastName: string;
    };
    name: string;
};

// Ok
const poemMatch: Poem = {
    author: {
        firstName: "Sylvia",
        lastName: "Plath",
    },
    name: "Lady Lazarus",
};

const poemMismatch: Poem = {
    author: {
        name: "Sylvia Plath",
    },
    // Error: Type '{ name: string; }' is not assignable
    // to type '{ firstName: string; lastName: string; }'.
    //   Object literal may only specify known properties, and 'name'
    //   does not exist in type '{ firstName: string; lastName: string; }'.
    name: "Tulips",
};

Another way of writing the type Poem would be to extract out the author property’s shape into its own aliased object type, Author. Extracting out nested types into their own type aliases also helps TypeScript give more informative type error messages. In this case, it can say 'Author' instead of '{ firstName: string; lastName: string; }':

type Author = {
    firstName: string;
    lastName: string;
};

type Poem = {
    author: Author;
    name: string;
};

const poemMismatch: Poem = {
    author: {
        name: "Sylvia Plath",
    },
    // Error: Type '{ name: string; }' is not assignable to type 'Author'.
    //     Object literal may only specify known properties,
    //     and 'name' does not exist in type 'Author'.
    name: "Tulips",
};
Tip

It is generally a good idea to move nested object types into their own type name like this, both for more readable code and for more readable TypeScript error messages.

You’ll see in later chapters how object type members can be other types such as arrays and functions.

Optional Properties

Object type properties don’t all have to be required in the object. You can include a ? before the : in a type property’s type annotation to indicate that it’s an optional property.

This Book type requires only a pages property and optionally allows an author. Objects adhering to it may provide author or leave it out as long as they provide pages:

type Book = {
  author?: string;
  pages: number;
};

// Ok
const ok: Book = {
    author: "Rita Dove",
    pages: 80,
};

const missing: Book = {
    author: "Rita Dove",
};
// Error: Property 'pages' is missing in type
// '{ author: string; }' but required in type 'Book'.

Keep in mind there is a difference between optional properties and properties whose type happens to include undefined in a type union. A property declared as optional with ? is allowed to not exist. A property declared as required and | undefined must exist, even if the value is undefined.

The editor property in the following Writers type may be skipped in declaring variables because it has a ? in its declaration. The author property does not have a ?, so it must exist, even if its value is just undefined:

type Writers = {
  author: string | undefined;
  editor?: string;
};

// Ok: author is provided as undefined
const hasRequired: Writers = {
  author: undefined,
};

const missingRequired: Writers = {};
//    ~~~~~~~~~~~~~~~
// Error: Property 'author' is missing in type
// '{}' but required in type 'Writers'.

Chapter 7, “Interfaces” will cover more on other kinds of properties, while Chapter 13, “Configuration Options” will describe TypeScript’s strictness settings around optional properties.

Unions of Object Types

It is reasonable in TypeScript code to want to be able to describe a type that can be one or more different object types that have slightly different properties. Furthermore, your code might want to be able to type narrow between those object types based on the value of a property.

Inferred Object-Type Unions

If a variable is given an initial value that could be one of multiple object types, TypeScript will infer its type to be a union of object types. That union type will have a constituent for each of the possible object shapes. Each of the possible properties on the type will be present in each of those constituents, though they’ll be ? optional types on any type that doesn’t have an initial value for them.

This poem value always has a name property of type string, and may or may not have pages and rhymes properties:

const poem = Math.random() > 0.5
  ? { name: "The Double Image", pages: 7 }
  : { name: "Her Kind", rhymes: true };
// Type:
// {
//   name: string;
//   pages: number;
//   rhymes?: undefined;
// }
// |
// {
//   name: string;
//   pages?: undefined;
//   rhymes: boolean;
// }

poem.name; // string
poem.pages; // number | undefined
poem.rhymes; // boolean | undefined

Explicit Object-Type Unions

Alternately, you can be more explicit about your object types by being explicit with your own union of object types. Doing so requires writing a bit more code but comes with the advantage of giving you more control over your object types. Most notably, if a value’s type is a union of object types, TypeScript’s type system will only allow access to properties that exist on all of those union types.

This version of the previous poem variable is explicitly typed to be a union type that always has the name property along with either pages or rhymes. Accessing name is allowed because it always exists, but pages and rhymes aren’t guaranteed to exist:

type PoemWithPages = {
    name: string;
    pages: number;
};

type PoemWithRhymes = {
    name: string;
    rhymes: boolean;
};

type Poem = PoemWithPages | PoemWithRhymes;

const poem: Poem = Math.random() > 0.5
  ? { name: "The Double Image", pages: 7 }
  : { name: "Her Kind", rhymes: true };

poem.name; // Ok

poem.pages;
//   ~~~~~
// Property 'pages' does not exist on type 'Poem'.
//   Property 'pages' does not exist on type 'PoemWithRhymes'.

poem.rhymes;
//   ~~~~~~
// Property 'rhymes' does not exist on type 'Poem'.
//   Property 'rhymes' does not exist on type 'PoemWithPages'.

Restricting access to potentially nonexistent members of objects can be a good thing for code safety. If a value might be one of multiple types, properties that don’t exist on all of those types aren’t guaranteed to exist on the object.

Just as how unions of literal and/or primitive types must be type narrowed to access properties that don’t exist on all type constituents, you’ll need to narrow those object type unions.

Narrowing Object Types

If the type checker sees that an area of code can only be run if a union typed value contains a certain property, it will narrow the value’s type to only the constituents that contain that property. In other words, TypeScript’s type narrowing will apply to objects if you check their shape in code.

Continuing the explicitly typed poem example, check whether "pages" in poem acts as a type guard for TypeScript to indicate that it is a PoemWithPages. If poem is not a PoemWithPages, then it must be a PoemWithRhymes:

if ("pages" in poem) {
    poem.pages; // Ok: poem is narrowed to PoemWithPages
} else {
    poem.rhymes; // Ok: poem is narrowed to PoemWithRhymes
}

Note that TypeScript won’t allow truthiness existence checks like if (poem.pages). Attempting to access a property of an object that might not exist is considered a type error, even if used in a way that seems to behave like a type guard:

if (poem.pages) { /* ... */ }
//       ~~~~~
// Property 'pages' does not exist on type 'Poem'.
//   Property 'pages' does not exist on type 'PoemWithRhymes'.

Discriminated Unions

Another popular form of union typed objects in JavaScript and TypeScript is to have a property on the object indicate what shape the object is. This kind of type shape is called a discriminated union, and the property whose value indicates the object’s type is a discriminant. TypeScript is able to perform type narrowing for code that type guards on discriminant properties.

For example, this Poem type describes an object that can be either a new PoemWithPages type or a new PoemWithRhymes type, and the type property indicates which one. If poem.type is "pages", then TypeScript is able to infer that the type of poem must be PoemWithPages. Without that type narrowing, neither property is guaranteed to exist on the value:

type PoemWithPages = {
    name: string;
    pages: number;
    type: 'pages';
};

type PoemWithRhymes = {
    name: string;
    rhymes: boolean;
    type: 'rhymes';
};

type Poem = PoemWithPages | PoemWithRhymes;

const poem: Poem = Math.random() > 0.5
  ? { name: "The Double Image", pages: 7, type: "pages" }
  : { name: "Her Kind", rhymes: true, type: "rhymes" };

if (poem.type === "pages") {
    console.log(`It's got pages: ${poem.pages}`); // Ok
} else {
    console.log(`It rhymes: ${poem.rhymes}`);
}

poem.type; // Type: 'pages' | 'rhymes'

poem.pages;
//   ~~~~~
// Error: Property 'pages' does not exist on type 'Poem'.
//   Property 'pages' does not exist on type 'PoemWithRhymes'.

Discriminated unions are my favorite feature in TypeScript because they beautifully combine a common elegant JavaScript pattern with TypeScript’s type narrowing. Chapter 10, “Generics” and its associated projects will show more around using discriminated unions for generic data operations.

Intersection Types

TypeScript’s | union types represent the type of a value that could be one of two or more different types. Just as JavaScript’s runtime | operator acts as a counterpart to its & operator, TypeScript allows representing a type that is multiple types at the same time: an & intersection type. Intersection types are typically used with aliased object types to create a new type that combines multiple existing object types.

The following Artwork and Writing types are used to form a combined WrittenArt type that has the properties genre, name, and pages:

type Artwork = {
    genre: string;
    name: string;
};

type Writing = {
    pages: number;
    name: string;
};

type WrittenArt = Artwork & Writing;
// Equivalent to:
// {
//   genre: string;
//   name: string;
//   pages: number;
// }

Intersection types can be combined with union types, which is sometimes useful to describe discriminated unions in one type.

This ShortPoem type always has an author property, then is also a discriminated union on a type property:

type ShortPoem = { author: string } & (
    | { kigo: string; type: "haiku"; }
    | { meter: number; type: "villanelle"; }
);

// Ok
const morningGlory: ShortPoem = {
    author: "Fukuda Chiyo-ni",
    kigo: "Morning Glory",
    type: "haiku",
};

const oneArt: ShortPoem = {
    author: "Elizabeth Bishop",
    type: "villanelle",
};
// Error: Type '{ author: string; type: "villanelle"; }'
// is not assignable to type 'ShortPoem'.
//   Type '{ author: string; type: "villanelle"; }' is not assignable to
//   type '{ author: string; } & { meter: number; type: "villanelle"; }'.
//     Property 'meter' is missing in type '{ author: string; type: "villanelle"; }'
//     but required in type '{ meter: number; type: "villanelle"; }'.

Dangers of Intersection Types

Intersection types are a useful concept, but it’s easy to use them in ways that confuse either yourself or the TypeScript compiler. I recommend trying to keep code as simple as possible when using them.

Long assignability errors

Assignability error messages from TypeScript get much harder to read when you create complex intersection types, such as one combined with a union type. This will be a common theme with TypeScript’s type system (and typed programming languages in general): the more complex you get, the harder it will be to understand messages from the type checker.

In the case of the previous code snippet’s ShortPoem, it would be much more readable to split the type into a series of aliased object types to allow TypeScript to print those names:

type ShortPoemBase = { author: string };
type Haiku = ShortPoemBase & { kigo: string; type: "haiku" };
type Villanelle = ShortPoemBase & { meter: number; type: "villanelle" };
type ShortPoem = Haiku | Villanelle;

const oneArt: ShortPoem = {
    author: "Elizabeth Bishop",
    type: "villanelle",
};
// Type '{ author: string; type: "villanelle"; }'
// is not assignable to type 'ShortPoem'.
//   Type '{ author: string; type: "villanelle"; }'
//   is not assignable to type 'Villanelle'.
//     Property 'meter' is missing in type
//     '{ author: string; type: "villanelle"; }'
//     but required in type '{ meter: number; type: "villanelle"; }'.

never

Intersection types are also easy to misuse and create an impossible type with. Primitive types cannot be joined together as constituents in an intersection type because it’s impossible for a value to be multiple primitives at the same time. Trying to & two primitive types together will result in the never type, represented by the keyword never:

type NotPossible = number & string;
// Type: never

The never keyword and type is what programming languages refer to as a bottom type, or empty type. A bottom type is one that can have no possible values and can’t be reached. No types can be provided to a location whose type is a bottom type:

let notNumber: NotPossible = 0;
//  ~~~~~~~~~
// Error: Type 'number' is not assignable to type 'never'.

let notString: never = "";
//  ~~~~~~~~~
// Error: Type 'string' is not assignable to type 'never'.

Most TypeScript projects rarely—if ever—use the never type. It comes up once in a while to represent impossible states in code. Most of the time, though, it’s likely to be a mistake from misusing intersection types. I’ll cover it more in Chapter 15, “Type Operations”.

Summary

In this chapter, you expanded your grasp of the TypeScript type system to be able to work with objects:

  • How TypeScript interprets types from object type literals

  • Describing object literal types, including nested and optional properties

  • Declaring, inferring, and type narrowing with unions of object literal types

  • Discriminated unions and discriminants

  • Combining object types together with intersection types

Tip

Now that you’ve finished reading this chapter, practice what you’ve learned on https://learningtypescript.com/objects.

How does a lawyer declare their TypeScript type?

“I object!”

Get Learning TypeScript now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.