Categories
TypeScript

How does TypeScript Know Which Types are Compatible with Each Other? — Enums and More

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 continue to look at how TypeScript determines which data types are compatible with each other.

Determining Compatible Function Parameter Types

When passing in values into functions as arguments, the parameter assignment will succeed if either the source parameter is assignable to the target parameter or vice versa. This involves having actions that can’t be determined at compile-time but it’s still allowed. This means that we can pass in arguments that are of a more specialized type than the one specified by a parameter to maintain compatibility with JavaScript. For example, we can do something like in the following code:

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

interface Employee {
  name: string;
  age: number;
  employeeCode: string;
}

const fn = (person: Person) => person;
const employee: Employee = {
  name: 'Jane',
  age: 20,
  employeeCode: '123'
}

fn(employee);

In the code above, we passed in a variable of the type Employee , which has more properties than the person parameter, which has the type Person . This means that, like in JavaScript, we can choose to ignore properties of objects that are passed into functions. Of course, the employeeCode property can’t be accessed inside the fn function, so if we write:

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

interface Employee {
  name: string;
  age: number;
  employeeCode: string;
}

const fn = (person: Person) => console.log(person.employeeCode);
const employee: Employee = {
  name: 'Jane',
  age: 20,
  employeeCode: '123'
}

fn(employee);123'
}

fn(employee);

We would get the error message:

Property 'employeeCode' does not exist on type 'Person'.(2339)

To disallow this behavior, we can enable the strictFunctionTypes flag when we compile the code.

Optional Parameters and Rest Parameters

In TypeScript, optional and required parameters are interchangeable for the sake of function parameter type compatibility checks. Extra parameters of the source type isn’t an error, optional parameters of the target type without corresponding parameters of the source type is also not an error. For example, the following code is allowed:

const runLater = (callbackFn: (...args: any[]) => void) => 0;
runLater((a, b) => { });
runLater((a, b, c) => { });

We can pass in any function as a callback function since we used the rest operation to specify that we can pass in any number of arguments in the callback function that we pass to the runLater function. Functions with rest parameters are treated as if it has an infinite number of parameters. This is OK since not passing in anything to a function if the same as passing in undefined in JavaScript.

Overloaded Functions

Functions overloads, where there’re multiple signatures for a function with the same name, must have arguments that match at least one of the signatures for TypeScript to accept the function call as valid. For example, if we have the following function overload:

function fn(a: { b: number, c: string, d: boolean }): number
function fn(a: { b: number, c: string }): number
function fn(a: any): number {
  return a.b;
};

Then we have to call the fn function by passing in at a number for the first argument and a string for the second argument like we do below:

fn({ b: 1, c: 'a' });

TypeScript will check against each signature to see if the property structure of the object we pass in as the argument matches any of the overloads. If it doesn’t match like if we have the following:

fn({ b: 1 });

Then we get the following error message:

No overload matches this call.

Overload 1 of 2, '(a: { b: number; c: string; d: boolean; }): number', gave the following error.

Argument of type '{ b: number; }' is not assignable to parameter of type '{ b: number; c: string; d: boolean; }'.

Type '{ b: number; }' is missing the following properties from type '{ b: number; c: string; d: boolean; }': c, d

Overload 2 of 2, '(a: { b: number; c: string; }): number', gave the following error.

Argument of type '{ b: number; }' is not assignable to parameter of type '{ b: number; c: string; }'.

Property 'c' is missing in type '{ b: number; }' but required in type '{ b: number; c: string; }'.(2769)
input.ts(2, 29): 'c' is declared here.

Enums

In TypeScript, enums are considered to be compatible with numbers and vice versa. However, enum values from different enum types are considered incompatible. For example, if we have:

enum Fruit { Orange, Apple };
enum Bird { Chicken, Duck, Goose };

let fruit = Fruit.Orange;
fruit = Bird.Chicken;

Then in the last line, we’ll get the error message:

Type 'Bird.Chicken' is not assignable to type 'Fruit'.(2322)

Classes

Type compatibility of classes works like object literal types. However, only instance members of each class are checked for compatibility. Static members are ignored for type compatibility checks. For example, if we have:

class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  static getNumPersons() {
    return 1;
  }
}

let animal: Animal = new Animal('Jane');
let person: Person = new Person('Joe');
animal = person;

Then the code would be accepted by the TypeScript compiler since animal and person are considered to be variables with compatible types. Static members like constructor and the getNumPersons method is ignored when the TypeScript compiler checks for type compatibility.

Private and protected members are checked during type compatibility checks when they’re included in a class. For example, if we have the following code:

class Animal {
  name: string;
  private age: number;
  constructor(name: string, age: number) {
    this.name = name;
    this.age = age;
  }
}

class Person {
  name: string;
  constructor(name: string) {
    this.name = name;
  }

  static getNumPersons() {
    return 1;
  }
}

let animal: Animal = new Animal('Jane');
let person: Person = new Person('Joe');
animal = person;

Then the TypeScript will reject the code with the following error:

Property 'age' is missing in type 'Person' but required in type 'Animal'.(2741)

input.ts(3, 11): 'age' is declared here.

TypeScript will only accept the code if the private member of both classes are derived from the same source. For example, if we have:

class Animal {
  private age: number = 20;
}

class Dog extends Animal {
  name: string;
  constructor(name: string) {
    super();
    this.name = name;
  }
}

class Cat extends Animal{
  name: string;
  constructor(name: string) {
    super();
    this.name = name;
  }
}

let dog: Dog = new Dog('Jane');
let cat: Cat = new Cat('Joe');
dog = cat;

Then the TypeScript compiler will accept the code when we try to compile it since the private member age is in the Animal class which the Dog and Cat classes are sub-classes of. This also to apply to protected members.

Generics

Type compatibility checks for generics are also derived from their structure. For example, if we have:

interface Person<T> {
  name: string;
}
let x: Person<number> = { name: 'Joe' };
let y: Person<string> = { name: 'Jane' };
x = y;

Then they’re compatible since both x and y are of type Person and we didn’t reference the generic type marker in any member of the interface. However, if we do reference a member in the Person interface, as we do in the following code:

interface Person<T> {
  name: string;
  age: T
}
let x: Person<number> = { name: 'Joe', age: 10 };
let y: Person<string> = { name: 'Jane', age: '20' };
x = y;

Then the type we pass into the <> does matter since we’re using it to determine the type of age . If we try to compile the code above, we would get the error:

Type 'Person<string>' is not assignable to type 'Person<number>'.

Type 'string' is not assignable to type 'number'.(2322)

For them to be considered to be compatible types, the type we pass in have to be the same in both variable declarations as we have below:

interface Person<T> {
  name: string;
  age: T
}

let x: Person<number> = { name: 'Joe', age: 10 };
let y: Person<number> = { name: 'Jane', age: 20 };
x = y;

In TypeScript code, we can pass in arguments that are of a more specialized type than the one specified by a parameter to maintain compatibility with JavaScript. For functions with overloads, then the arguments that we pass in have to match the parameter types that are declared in the function overloads. For classes, only instance members are considered to when determining if 2 classes are of compatible types. This applies to public members. For private and protected members, they have to be derived from the same super-class for 2 classes to be determined to have compatible types. Enums are compatible with numbers and vice versa, but enum values from 2 different enums aren’t compatible with each other. Generics are considered compatible with each by following the same rules as objects, functions, and classes. The types that are passed into a generic will be considered like any other code for type compatibility.

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 *