Tackling TypeScript
Please support this book: buy it or donate
(Ad, please don’t block.)

15 Typing objects



In this chapter, we will explore how objects and properties are typed statically in TypeScript.

15.1 Roles played by objects

In JavaScript, objects can play two roles (always at least one of them, sometimes mixtures):

First and foremost, we will explore objects as records. We will briefly encounter objects as dictionaries later in this chapter.

15.2 Types for objects

There are two different general types for objects:

Objects can also be typed via their properties:

// Object type literal
let obj3: {prop: boolean};

// Interface
interface ObjectType {
  prop: boolean;
}
let obj4: ObjectType;

In the next sections, we’ll examine all these ways of typing objects in more detail.

15.3 Object vs. object in TypeScript

15.3.1 Plain JavaScript: objects vs. instances of Object

In plain JavaScript, there is an important distinction.

On one hand, most objects are instances of Object.

> const obj1 = {};
> obj1 instanceof Object
true

That means:

On the other hand, we can also create objects that don’t have Object.prototype in their prototype chains. For example, the following object does not have any prototype at all:

> const obj2 = Object.create(null);
> Object.getPrototypeOf(obj2)
null

obj2 is an object that is not an instance of class Object:

> typeof obj2
'object'
> obj2 instanceof Object
false

15.3.2 Object (uppercase “O”) in TypeScript: instances of class Object

Recall that each class C creates two entities:

Similarly, TypeScript has two built-in interfaces:

These are the interfaces:

interface Object { // (A)
  constructor: Function;
  toString(): string;
  toLocaleString(): string;
  valueOf(): Object;
  hasOwnProperty(v: PropertyKey): boolean;
  isPrototypeOf(v: Object): boolean;
  propertyIsEnumerable(v: PropertyKey): boolean;
}

interface ObjectConstructor {
  /** Invocation via `new` */
  new(value?: any): Object;
  /** Invocation via function calls */
  (value?: any): any;

  readonly prototype: Object; // (B)

  getPrototypeOf(o: any): any;

  // ···
}
declare var Object: ObjectConstructor; // (C)

Observations:

15.3.3 object (lowercase “o”) in TypeScript: non-primitive values

In TypeScript, object is the type of all non-primitive values (primitive values are undefined, null, booleans, numbers, bigints, strings). With this type, we can’t access any properties of a value.

15.3.4 Object vs. object: primitive values

Interestingly, type Object also matches primitive values:

function func1(x: Object) { }
func1('abc'); // OK

Why is that? Primitive values have all the properties required by Object because they inherit Object.prototype:

> 'abc'.hasOwnProperty === Object.prototype.hasOwnProperty
true

Conversely, object does not match primitive values:

function func2(x: object) { }
// @ts-expect-error: Argument of type '"abc"' is not assignable to
// parameter of type 'object'. (2345)
func2('abc');

15.3.5 Object vs. object: incompatible property types

With type Object, TypeScript complains if an object has a property whose type conflicts with the corresponding property in interface Object:

// @ts-expect-error: Type '() => number' is not assignable to
// type '() => string'.
//   Type 'number' is not assignable to type 'string'. (2322)
const obj1: Object = { toString() { return 123 } };

With type object, TypeScript does not complain (because object does not specify any properties and there can’t be any conflicts):

const obj2: object = { toString() { return 123 } };

15.4 Object type literals and interfaces

TypeScript has two ways of defining object types that are very similar:

// Object type literal
type ObjType1 = {
  a: boolean,
  b: number;
  c: string,
};

// Interface
interface ObjType2 {
  a: boolean,
  b: number;
  c: string,
}

We can use either semicolons or commas as separators. Trailing separators are allowed and optional.

15.4.1 Differences between object type literals and interfaces

In this section, we take a look at the most important differences between object type literals and interfaces.

15.4.1.1 Inlining

Object type literals can be inlined, while interfaces can’t be:

// Inlined object type literal:
function f1(x: {prop: number}) {}

// Referenced interface:
function f2(x: ObjectInterface) {} 
interface ObjectInterface {
  prop: number;
}
15.4.1.2 Duplicate names

Type aliases with duplicate names are illegal:

// @ts-expect-error: Duplicate identifier 'PersonAlias'. (2300)
type PersonAlias = {first: string};
// @ts-expect-error: Duplicate identifier 'PersonAlias'. (2300)
type PersonAlias = {last: string};

Conversely, interfaces with duplicate names are merged:

interface PersonInterface {
  first: string;
}
interface PersonInterface {
  last: string;
}
const jane: PersonInterface = {
  first: 'Jane',
  last: 'Doe',
};
15.4.1.3 Mapped types

For Mapped types (line A), we need to use object type literals:

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

type PointCopy1 = {
  [Key in keyof Point]: Point[Key]; // (A)
};

// Syntax error:
// interface PointCopy2 {
//   [Key in keyof Point]: Point[Key];
// };

  More information on mapped types

Mapped types are beyond the current scope of this book. For more information, see the TypeScript Handbook.

15.4.1.4 Polymorphic this types

Polymorphic this types can only be used in interfaces:

interface AddsStrings {
  add(str: string): this;
};

class StringBuilder implements AddsStrings {
  result = '';
  add(str: string) {
    this.result += str;
    return this;
  }
}

  Source of this section

  From now on, “interface” means “interface or object type literal” (unless stated otherwise).

15.4.2 Interfaces work structurally in TypeScript

Interfaces work structurally – they don’t have to be implemented in order to match:

interface Point {
  x: number;
  y: number;
}
const point: Point = {x: 1, y: 2}; // OK

For more information on this topic, see [content not included].

15.4.3 Members of interfaces and object type literals

The constructs inside the bodies of interfaces and object type literals are called their members. These are the most common members:

interface ExampleInterface {
  // Property signature
  myProperty: boolean;

  // Method signature
  myMethod(str: string): number;

  // Index signature
  [key: string]: any;

  // Call signature
  (num: number): string;

  // Construct signature
  new(str: string): ExampleInstance; 
}
interface ExampleInstance {}

Let’s look at these members in more detail:

Property signatures should be self-explanatory. Call signatures and construct signatures are described later in this book. We’ll take a closer look at method signatures and index signatures next.

15.4.4 Method signatures

As far as TypeScript’s type system is concerned, method definitions and properties whose values are functions, are equivalent:

interface HasMethodDef {
  simpleMethod(flag: boolean): void;
}
interface HasFuncProp {
  simpleMethod: (flag: boolean) => void;
}

const objWithMethod: HasMethodDef = {
  simpleMethod(flag: boolean): void {},
};
const objWithMethod2: HasFuncProp = objWithMethod;

const objWithOrdinaryFunction: HasMethodDef = {
  simpleMethod: function (flag: boolean): void {},
};
const objWithOrdinaryFunction2: HasFuncProp = objWithOrdinaryFunction;

const objWithArrowFunction: HasMethodDef = {
  simpleMethod: (flag: boolean): void => {},
};
const objWithArrowFunction2: HasFuncProp = objWithArrowFunction;

My recommendation is to use whichever syntax best expresses how a property should be set up.

15.4.5 Index signatures: objects as dicts

So far, we have only used interfaces for objects-as-records with fixed keys. How do we express the fact that an object is to be used as a dictionary? For example: What should TranslationDict be in the following code fragment?

function translate(dict: TranslationDict, english: string): string {
  return dict[english];
}

We use an index signature (line A) to express that TranslationDict is for objects that map string keys to string values:

interface TranslationDict {
  [key:string]: string; // (A)
}
const dict = {
  'yes': 'sí',
  'no': 'no',
  'maybe': 'tal vez',
};
assert.equal(
  translate(dict, 'maybe'),
  'tal vez');
15.4.5.1 Typing index signature keys

Index signature keys must be either string or number:

15.4.5.2 String keys vs. number keys

Just like in plain JavaScript, TypeScript’s number property keys are a subset of the string property keys (see “JavaScript for impatient programmers”). Accordingly, if we have both a string index signature and a number index signature, the property type of the former must be a supertype of the latter. The following example works because Object is a supertype of RegExp:

interface StringAndNumberKeys {
  [key: string]: Object;
  [key: number]: RegExp;
}

// %inferred-type: (x: StringAndNumberKeys) =>
// { str: Object; num: RegExp; }
function f(x: StringAndNumberKeys) {
  return { str: x['abc'], num: x[123] };
}
15.4.5.3 Index signatures vs. property signatures and method signatures

If there are both an index signature and property and/or method signatures in an interface, then the type of the index property value must also be a supertype of the type of the property value and/or method.

interface I1 {
  [key: string]: boolean;

  // @ts-expect-error: Property 'myProp' of type 'number' is not assignable
  // to string index type 'boolean'. (2411)
  myProp: number;
  
  // @ts-expect-error: Property 'myMethod' of type '() => string' is not
  // assignable to string index type 'boolean'. (2411)
  myMethod(): string;
}

In contrast, the following two interfaces produce no errors:

interface I2 {
  [key: string]: number;
  myProp: number;
}

interface I3 {
  [key: string]: () => string;
  myMethod(): string;
}

15.4.6 Interfaces describe instances of Object

All interfaces describe objects that are instances of Object and inherit the properties of Object.prototype.

In the following example, the parameter x of type {} is compatible with the return type Object:

function f1(x: {}): Object {
  return x;
}

Similarly, {} has a method .toString():

function f2(x: {}): { toString(): string } {
  return x;
}

15.4.7 Excess property checks: When are extra properties allowed?

As an example, consider the following interface:

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

There are two ways (among others) in which this interface could be interpreted:

TypeScript uses both interpretations. To explore how that works, we will use the following function:

function computeDistance(point: Point) { /*...*/ }

The default is that the excess property .z is allowed:

const obj = { x: 1, y: 2, z: 3 };
computeDistance(obj); // OK

However, if we use object literals directly, then excess properties are forbidden:

// @ts-expect-error: Argument of type '{ x: number; y: number; z: number; }'
// is not assignable to parameter of type 'Point'.
//   Object literal may only specify known properties, and 'z' does not
//   exist in type 'Point'. (2345)
computeDistance({ x: 1, y: 2, z: 3 }); // error

computeDistance({x: 1, y: 2}); // OK
15.4.7.1 Why are excess properties forbidden in object literals?

Why the stricter rules for object literals? They provide protection against typos in property keys. We will use the following interface to demonstrate what that means.

interface Person {
  first: string;
  middle?: string;
  last: string;
}
function computeFullName(person: Person) { /*...*/ }

Property .middle is optional and can be omitted (optional properties are covered later in this chapter). To TypeScript, mistyping its name looks like omitting it and providing an excess property. However, it still catches the typo because excess properties are not allowed in this case:

// @ts-expect-error: Argument of type '{ first: string; mdidle: string;
// last: string; }' is not assignable to parameter of type 'Person'.
//   Object literal may only specify known properties, but 'mdidle'
//   does not exist in type 'Person'. Did you mean to write 'middle'?
computeFullName({first: 'Jane', mdidle: 'Cecily', last: 'Doe'});
15.4.7.2 Why are excess properties allowed if an object comes from somewhere else?

The idea is that if an object comes from somewhere else, we can assume that it has already been vetted and will not have any typos. Then we can afford to be less careful.

If typos are not an issue, our goal should be maximizing flexibility. Consider the following function:

interface HasYear {
  year: number;
}

function getAge(obj: HasYear) {
  const yearNow = new Date().getFullYear();
  return yearNow - obj.year;
}

Without allowing excess properties for most values that are passed to getAge(), the usefulness of this function would be quite limited.

15.4.7.3 Empty interfaces allow excess properties

If an interface is empty (or the object type literal {} is used), excess properties are always allowed:

interface Empty { }
interface OneProp {
  myProp: number;
}

// @ts-expect-error: Type '{ myProp: number; anotherProp: number; }' is not
// assignable to type 'OneProp'.
//   Object literal may only specify known properties, and
//   'anotherProp' does not exist in type 'OneProp'. (2322)
const a: OneProp = { myProp: 1, anotherProp: 2 };
const b: Empty = {myProp: 1, anotherProp: 2}; // OK
15.4.7.4 Matching only objects without properties

If we want to enforce that an object has no properties, we can use the following trick (credit: Geoff Goodman):

interface WithoutProperties {
  [key: string]: never;
}

// @ts-expect-error: Type 'number' is not assignable to type 'never'. (2322)
const a: WithoutProperties = { prop: 1 };
const b: WithoutProperties = {}; // OK
15.4.7.5 Allowing excess properties in object literals

What if we want to allow excess properties in object literals? As an example, consider interface Point and function computeDistance1():

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

function computeDistance1(point: Point) { /*...*/ }

// @ts-expect-error: Argument of type '{ x: number; y: number; z: number; }'
// is not assignable to parameter of type 'Point'.
//   Object literal may only specify known properties, and 'z' does not
//   exist in type 'Point'. (2345)
computeDistance1({ x: 1, y: 2, z: 3 });

One option is to assign the object literal to an intermediate variable:

const obj = { x: 1, y: 2, z: 3 };
computeDistance1(obj);

A second option is to use a type assertion:

computeDistance1({ x: 1, y: 2, z: 3 } as Point); // OK

A third option is to rewrite computeDistance1() so that it uses a type parameter:

function computeDistance2<P extends Point>(point: P) { /*...*/ }
computeDistance2({ x: 1, y: 2, z: 3 }); // OK

A fourth option is to extend interface Point so that it allows excess properties:

interface PointEtc extends Point {
  [key: string]: any;
}
function computeDistance3(point: PointEtc) { /*...*/ }

computeDistance3({ x: 1, y: 2, z: 3 }); // OK

We’ll continue with two examples where TypeScript not allowing excess properties, is an issue.

15.4.7.5.1 Allowing excess properties: example Incrementor

In this example, we’d like to implement an Incrementor, but TypeScript doesn’t allow the extra property .counter:

interface Incrementor {
  inc(): void
}
function createIncrementor(start = 0): Incrementor {
  return {
    // @ts-expect-error: Type '{ counter: number; inc(): void; }' is not
    // assignable to type 'Incrementor'.
    //   Object literal may only specify known properties, and
    //   'counter' does not exist in type 'Incrementor'. (2322)
    counter: start,
    inc() {
      // @ts-expect-error: Property 'counter' does not exist on type
      // 'Incrementor'. (2339)
      this.counter++;
    },
  };
}

Alas, even with a type assertion, there is still one type error:

function createIncrementor2(start = 0): Incrementor {
  return {
    counter: start,
    inc() {
      // @ts-expect-error: Property 'counter' does not exist on type
      // 'Incrementor'. (2339)
      this.counter++;
    },
  } as Incrementor;
}

We can either add an index signature to interface Incrementor. Or – especially if that is not possible – we can introduce an intermediate variable:

function createIncrementor3(start = 0): Incrementor {
  const incrementor = {
    counter: start,
    inc() {
      this.counter++;
    },
  };
  return incrementor;
}
15.4.7.5.2 Allowing excess properties: example .dateStr

The following comparison function can be used to sort objects that have the property .dateStr:

function compareDateStrings(
  a: {dateStr: string}, b: {dateStr: string}) {
    if (a.dateStr < b.dateStr) {
      return +1;
    } else if (a.dateStr > b.dateStr) {
      return -1;
    } else {
      return 0;
    }
  }

For example in unit tests, we may want to invoke this function directly with object literals. TypeScript doesn’t let us do this and we need to use one of the workarounds.

15.5 Type inference

These are the types that TypeScript infers for objects that are created via various means:

// %inferred-type: Object
const obj1 = new Object();

// %inferred-type: any
const obj2 = Object.create(null);

// %inferred-type: {}
const obj3 = {};

// %inferred-type: { prop: number; }
const obj4 = {prop: 123};

// %inferred-type: object
const obj5 = Reflect.getPrototypeOf({});

In principle, the return type of Object.create() could be object. However, any allows us to add and change properties of the result.

15.6 Other features of interfaces

15.6.1 Optional properties

If we put a question mark (?) after the name of a property, that property is optional. The same syntax is used to mark parameters of functions, methods, and constructors as optional. In the following example, property .middle is optional:

interface Name {
  first: string;
  middle?: string;
  last: string;
}

Therefore, it’s OK to omit that property (line A):

const john: Name = {first: 'Doe', last: 'Doe'}; // (A)
const jane: Name = {first: 'Jane', middle: 'Cecily', last: 'Doe'};
15.6.1.1 Optional vs. undefined|string

What is the difference between .prop1 and .prop2?

interface Interf {
  prop1?: string;
  prop2: undefined | string; 
}

An optional property can do everything that undefined|string can. We can even use the value undefined for the former:

const obj1: Interf = { prop1: undefined, prop2: undefined };

However, only .prop1 can be omitted:

const obj2: Interf = { prop2: undefined };

// @ts-expect-error: Property 'prop2' is missing in type '{}' but required
// in type 'Interf'. (2741)
const obj3: Interf = { };

Types such as undefined|string and null|string are useful if we want to make omissions explicit. When people see such an explicitly omitted property, they know that it exists but was switched off.

15.6.2 Read-only properties

In the following example, property .prop is read-only:

interface MyInterface {
  readonly prop: number;
}

As a consequence, we can read it, but we can’t change it:

const obj: MyInterface = {
  prop: 1,
};

console.log(obj.prop); // OK

// @ts-expect-error: Cannot assign to 'prop' because it is a read-only
// property. (2540)
obj.prop = 2;

15.7 JavaScript’s prototype chains and TypeScript’s types

TypeScript doesn’t distinguish own and inherited properties. They are all simply considered to be properties.

interface MyInterface {
  toString(): string; // inherited property
  prop: number; // own property
}
const obj: MyInterface = { // OK
  prop: 123,
};

obj inherits .toString() from Object.prototype.

The downside of this approach is that some phenomena in JavaScript can’t be described via TypeScript’s type system. The upside is that the type system is simpler.

15.8 Sources of this chapter