Categories
TypeScript

How does TypeScript Know Which Types are Compatible with Each Other? — Objects and Functions

Spread the love

The advantage of TypeScript over JavaScript is that we can annotate data types of our variables, functions, and other entities with it. This lets us get auto-completion in our text editor and it also adds a compile step to building apps which means that compile-time checks can catch more errors like undefined error and unexpected data type errors that would otherwise be caught at runtime.

To do this, TypeScript checks if different variables have the same type according to the structure and other aspects of an entity, like the number of parameters and the type of each parameter and so on. In this article, we’ll look at how TypeScript determines which data types are compatible with each other.

TypeScript’s type system allows some operations that can’t be known at compile-time to be safe. A type system is sound when it doesn’t allow actions that aren’t known at compile time. However, since TypeScript is a superset of JavaScript, it has to allow some unsound actions to be done at compile-time.

Basic Type Compatibility

The most basic rule that TypeScript used to determine type compatibility is that if both types have the same structure, then it’s considered to be compatible types. Having the same structure means that both types have the same member names and each member has the same type. For example, if we have:

interface A {
  name: string;
  age: number;
}

interface B {
  name: string;
  age: number;
}

let x: A = { name: 'Joe', age: 10 };
let y: B = { name: 'Jane', age: 20 };
x = y;

Then TypeScript would accept the code and compile it since both interfaces A and B both have name and age as their fields and both name and age in both interfaces have the same type. Also, if an object has all the properties of a type and extra properties that aren’t in the interface, then the object can also be assigned to a variable without a type annotation, which can then be assigned to a variable with a set type. For instance, if we have the following code:

interface A {
  name: string;
  age: number;
}

let y: A;
let x = { name: 'Joe', age: 10, gender: 'male' };
y = x;

Where y has the data type A , then we can assign an object to a variable with the without a data type annotation, which is x , and then we can assign to y which has the type A . This is because only object literals have the excess property check. Variables do not have this check.

Likewise, data types of functions are also checked when assigning a function to a variable. We can pass in an object that has more properties than the type that type of the parameter. For example, we can write something like the following code:

interface Person {
  name: string;
  age: number;
}

let person = { name: 'Joe', age: 10, gender: 'male' };

function echo(person: Person) {
  return person;
}

echo(person);

The person object has a gender property that isn’t in the Person interface. but the person parameter in the echo function is of type Person . Given that, we can still pass in person into as the first argument of the echo function call. TypeScript doesn’t care if there’re extra properties in the argument that we pass in. On the other hand, if our argument has missing properties, then TypeScript will give us an error and won’t compile the code. For instance, if we have the following code:

interface Person {
  name: string;
  age: number;
}

let person = { name: 'Joe' };

function echo(person: Person) {
  return person;
}

echo(person);

Then we would get the error:

Argument of type '{ name: string; }' is not assignable to parameter of type 'Person'.

Property 'age' is missing in type '{ name: string; }' but required in type 'Person'.(2345)
input.ts(3, 3): 'age' is declared here.

The structural check for type compatibility is done recursively. So the same checks apply to objects with whatever nesting level it has. For example, if we have:

interface Person {
  name: string;
  age: number;
  address: {
    street: string;
  }
}

let person = { name: 'Joe', age: 20, address: {street: '123 A St.'} };

function echo(person: Person) {
  return person;
}

echo(person);

This would pass the structural type checking done by TypeScript because all the properties at each level are present. On the other hand, if we have:

interface Person {
  name: string;
  age: number;
  address: {
    street: string;
  }
}

let person = { name: 'Joe', age: 20, address: { } };

function echo(person: Person) {
  return person;
}

echo(person);

Then the TypeScript compiler would give us the following error:

Argument of type '{ name: string; age: number; address: {}; }' is not assignable to parameter of type 'Person'.

Types of property 'address' are incompatible.

Property 'street' is missing in type '{}' but required in type '{ street: string; }'.(2345)

input.ts(5, 5): 'street' is declared here.

since the street property is missing from the object assigned to the address property.

Comparing Functions

Comparing objects and primitive values for their types are pretty straightforward. However, determining which functions have types that are compatible with each other is harder. In TypeScript, we can assign a function that has a function that has fewer parameters to one with a function that has more parameters but otherwise have the same function signature. For example, if we have:

let x = (a: number) => 0;
let y = (a: number, b: string) => 0;

Then we can assign x to y like we do in the following code:

y = x;

This is because, in JavaScript, we can often assign functions that should have more parameters by default by ones with fewer parameters. For example, the array map method takes a callback function which has 2 parameters which are the array entry that’s being processed and the array index of the entry. However, we sometimes just pass in a callback function that only has the first parameter like in the following code:

let arr = ['a', 'b', 'c'];
arr.map(a => a);

TypeScript has to allow for the discarding of parameters to maintain compatibility with JavaScript. Likewise, for comparing return types, TypeScript determines that a function with a return type that has more properties is compatible with ones with fewer properties but otherwise has the same structure. For example, if we have 2 functions like in the following code:

let fn1 = () => ({ name: "Alice" });
let fn2 = () => ({ name: "Joe", age: 20 });

Then we can assign fn2 to fn1 like in the following code:

fn1 = fn2;

However, the other way around wouldn’t work:

fn2 = fn1;

If we try to compile the code above, we would get the following error from the TypeScript compiler:

Type '() => { name: string; }' is not assignable to type '() => { name: string; age: number; }'.

Property 'age' is missing in type '{ name: string; }' but required in type '{ name: string; age: number; }'.(2322)

input.ts(2, 33): 'age' is declared here.

As we can see, TypeScript accepts a return type that has more properties as ones that have fewer properties but otherwise have the same structure.

TypeScript checks the data type of objects and functions by their structure. Generally, if 2 types have the same properties and data types for each, then they’re considered to be the same types. Otherwise, objects that have more properties than another one, but otherwise have the same structure are also considered to be compatible. Likewise, if a function has more parameters than another one with fewer parameters, but otherwise have the same structure, than they’re considered the same. This also applies to the structure of objects that are returned by functions. If one function returns an object has more properties than another function which returns an object with fewer properties than the other function, but otherwise have the same structure, then the 2 return types are considered to be compatible.

By John Au-Yeung

Web developer specializing in React, Vue, and front end development.

Leave a Reply

Your email address will not be published. Required fields are marked *