Typing objects in TypeScript

[2020-01-30] dev, javascript, typescript
(Ad, please don’t block)
Warning: This blog post is outdated. Instead, read chapter “Typing objects” in “Tackling TypeScript”.

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

Roles played by objects  

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

  • Records: A fixed amount of properties that are known at development time. Each property can have a different type.

  • Dictionaries: An arbitrary amount of properties whose names are not known at development time. All property keys (strings and/or symbols) have the same type, as do the property values.

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

Types for objects  

There are two different general types for objects:

  • Object with an uppercase “O” is the type of all instances of class Object:
    let obj1: Object;
    
  • object with a lowercase “o” is the type of all non-primitive values:
    let obj2: object;
    

Objects can also be described 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.

Object vs. object in TypeScript  

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:

  • Object.prototype is in their prototype chains:

    > Object.prototype.isPrototypeOf(obj1)
    true
    
  • They inherit its properties.

    > obj1.toString === Object.prototype.toString
    true
    

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

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

In TypeScript, Object is the type of all instances of class Object. It is defined by two interfaces:

  • Interface Object defines the properties of Object.prototype.
  • Interface ObjectConstructor defines the properties of class Object (i.e., the object pointed to by that global variable).
interface Object {
  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;

  getPrototypeOf(o: any): any;

  // ···
}
declare var Object: ObjectConstructor;

All instances of Object inherit the properties of interface Object. We can see that if we create a function that returns its parameter: If an instance of Object comes in, it always satisfies the return type – which requires it to have a method .toString().

function f(x: Object): { toString(): string } {
  return x; // OK
}

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.

Object vs. object: primitive values  

Interestingly, type Object includes primitive values:

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

Why? The properties of Object.prototype can also be accessed via primitive values:

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

Conversely, object does not include primitive values:

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

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-ignore: 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 has no properties and there can’t be any conflicts):

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

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.

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.


Source of this section: GitHub issue “TypeScript: types vs. interfaces” by Johannes Ewald


Inlining  

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

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

function f2(x: ObjectInterface) {} // referenced interface
interface ObjectInterface {
  prop: number;
}

Duplicate names  

Type aliases with duplicate names are illegal:

// @ts-ignore: Duplicate identifier 'PersonAlias'. (2300)
type PersonAlias = {first: string};
// @ts-ignore: 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',
};

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];
// };

Polymorphic this types  

Only possible in interfaces:

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

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

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


Nominal type systems vs. structural type systems  

One of the responsibilities of a static type system is to determine if two static types are compatible:

  • The static type U of an actual parameter (provided, e.g., via a function call)
  • The static type T of the corresponding formal parameter (specified as part of a function definition)

This often means checking if U is a subtype of T. Two approaches for this check are (roughly):

  • In a nominal or nominative type system, two static types are equal if they have the same identity (“name”). One type is a subtype of another if their subtype relationship was declared explicitly.

    • Languages with nominal typing are C++, Java, C#, Swift, and Rust.
  • In a structural type system, two static types are equal if they have the same structure (if their parts have the same names and the same types). One type U is a subtype of another type T if U has all parts of T (and possibly others) and each part of U has a subtype of the corresponding part of T.

    • Languages with structural typing are OCaml/ReasonML, Haskell, and TypeScript.

The following code produces a type error (line A) in nominal type systems, but is legal in TypeScript’s structural type system because class A and class B have the same structure:

class A {
  name = 'A';
}
class B {
  name = 'B';
}
const someVariable: A = new B(); // (A)

TypeScript’s interfaces also 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

Members of interfaces and object type literals  

interface ExampleInterface {
  // Property signature
  myProperty: boolean;

  // Method signature
  myMethod(x: string): void;

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

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

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

Members of interfaces and object type literals can be:

  • Property signatures define properties:

    myProperty: boolean;
    
  • Method signatures define methods:

    myMethod(x: string): void;
    

    Note that the names of parameters (in this case: x) help with documenting how things work, but have no other purpose.

  • Index signatures help when interfaces describe Arrays or objects that are used as dictionaries.

    [prop: string]: any;
    

    Note: The property key name prop is only there for documentation purposes. I often use key or k.

  • Call signatures enable interfaces to describe functions:

    (x: number): string;
    
  • Constructor signatures enable interfaces to describe classes and constructor functions:

    new(x: string): ExampleInstance; 
    

Property signatures and method signatures should be self-explanatory. Call and constructor signatures are beyond the scope of this blog post. We’ll take a closer look at index signatures next.

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');

Typing index signature keys  

Index signature keys must be either string or number:

  • Symbols are not allowed.
  • any is not allowed.
  • Type unions (e.g. string|type) are not allowed. However, multiple index signatures can be used per interface.

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] };
}

Index signatures vs. property 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-ignore: Property 'myProp' of type 'number' is not assignable to string index type 'boolean'.(2411)
  myProp: number;
  
  // @ts-ignore: 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;
}

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 result type Object:

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

Similarly, {} is understood to have a method .toString():

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

Excess property checks  

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:

  • Closed interpretation: It could describe all objects that have exactly the properties .x and .y with the specified types. On other words: Those objects must not have excess properties (more than the required properties).
  • Open interpretation: It could describe all objects that have at least the properties .x and .y. In other words: Excess properties are allowed.

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-ignore: 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 });

computeDistance({x: 1, y: 2}); // OK

Why are excess properties forbidden in object literals?  

Why the restriction? The open interpretation that allows excess properties is reasonably safe when the data comes from somewhere else. However, if we create the data ourselves, then we profit from the extra protection against typos that the closed interpretation gives us – for example:

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

Property .middle is optional and can be omitted (we’ll examine optional properties in more detail later). If we mistype its name in an object literal, TypeScript will assume that we created an excess property and left out .middle. Thankfully, we get a warning because excess properties are not allowed in object literals:

// @ts-ignore: 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'});

If an object with the same typo came from somewhere else, it would be accepted.

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-ignore: 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

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

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

// @ts-ignore: Type 'number' is not assignable to type 'never'.(2322)
const a: WithoutProperties = { prop: 1 };
const b: WithoutProperties = {}; // OK

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-ignore: 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.

Excess properties: example 1  

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-ignore: 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-ignore: 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-ignore: 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;
}
Excess properties: example 2  

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 work-arounds.

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. I assume that it is any to be backward compatible with old code.

Other features of interfaces  

Optional properties  

If we put a question mark (?) after the name of a property, that property is declared to be optional. For example, in the following example, property .middle is optional:

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

That means that it’s OK to omit it (line A):

const john = {first: 'Doe', last: 'Doe'}; // (A)
const jane = {first: 'Jane', middle: 'Cecily', last: 'Doe'};

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-ignore: Property 'prop2' is missing in type '{}' but required in type 'Interf'.(2741)
const obj3: Interf = { };

Types such as undefined|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.

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-ignore: Cannot assign to 'prop' because it is a read-only property.(2540)
obj.prop = 2;

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,
};

The downside of this approach is that there are some JavaScript phenomena that can’t be typed statically. Its upside is that the type system is simpler.

Sources of this blog post