In this blog post, we will explore how objects and properties are typed statically in TypeScript.
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.
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 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:
Object
defines the properties of Object.prototype
.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 } };
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.
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
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;
}
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',
};
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];
// };
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).
One of the responsibilities of a static type system is to determine if two static types are compatible:
U
of an actual parameter (provided, e.g., via a function call)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.
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
.
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
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.
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');
Index signature keys must be either string
or number
:
any
is not allowed.string|type
) are not allowed. However, multiple index signatures can be used per interface.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] };
}
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;
}
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;
}
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:
.x
and .y
with the specified types. On other words: Those objects must not have excess properties (more than the required 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 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.
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
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.
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;
}
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.
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.
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'};
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.
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;
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.