Categories
TypeScript

Introduction to TypeScript Interfaces

The big advantage of TypeScript over plain JavaScript is that it extends the features of JavaScript by adding type safety to our program’s objects. It does this by checking the shape of the values that objects can take on. Checking the shape is called duck typing or structural typing. It’s very useful for defining contracts within our code in TypeScript programs. In this article, we’ll look at how to define a TypeScript interface and add required or optional properties to it.

Defining Interfaces

To define a basic interface, we use the interface keyword in TypeScript. This keyword is exclusive to TypeScript and it’s not available in JavaScript. We can define a TypeScript interface like in the code below:

interface Person {
  name: string
}

In the code above, if a variable or parameter has been designated with this interface, then all objects with the type using it will have the name property. Object literals assigned to a variable with the type cannot have any other property. Arguments that are passed in as parameters that are of this type also can only have this property. For example, the following code will be compiled successfully and run with the TypeScript compiler:

interface Person{
  name: string
}

const greet = (person: Person) => {
  console.log(`Hello, ${person.name}`);
}
greet({ name: 'Joe' });

The code above will compile and run since it only has the name property and the value of it is a string exactly like it’s specified in the Person interface. However, the following code wouldn’t be compiled by the TypeScript compiler and throw an error:

interface Person {
  name: string
}

const greet = (person: Person) => {
  console.log(`Hello, ${person.name}`);
}

greet({ name: 'Joe', foo: 'abc' });

This is because we specified that the type of the person parameter in the greet function has the type Person. The object passed in as the argument can only have the name property and that the value of it can only be a string. We can’t assign an object literal with different properties than what’s listed in the interface if a variable has been designated by the type of the interface:

const p: Person = { name: 'Joe', foo: 'abc' };

The code above also wouldn’t compile since the object literal has an extra property in it that is not listed in the Person interface. In editors that support TypeScript like Visual Studio Code, we would get the error message “Type ‘{ name: string; foo: string; }’ is not assignable to type ‘Person’. Object literal may only specify known properties, and ‘foo’ does not exist in type ‘Person’.”

With interfaces, when designating the types of variables, we get auto-completion of our code when we write object literals.

Optional Properties

TypeScript interfaces can have optional properties. This makes interfaces much more flexible than just adding required properties to them. We can designate a property as optional with the question mark ? after the property name. For example, we can write the following code to define an interface with an optional property:

interface Person {
  name: string,
  age?: number
}

In the code above, age is an optional property since we put a question mark after the property name. We can use it as the following code:

interface Person {
  name: string,
  age?: number
}

const greet = (person: Person) => {
  console.log(`Hello, ${person.name}. ${person.age ? `You're ${person.age} years old.` : ''}`);
}

greet({ name: 'Joe', age: 10 });

In the object we passed into the greet function into the code above, we passed in the age property and a number for its value. Then we used it inside our code by checking if it’s defined first and then we add additional text to the main string if it is. We can also omit optional properties like in the following code:

interface Person {
  name: string,
  age?: number
}

const greet = (person: Person) => {
  console.log(`Hello, ${person.name}. ${person.age ? `You're ${person.age} years old.` : ''}`);
}

greet({ name: 'Joe' });

The code above would still run since the age property has been designated as being optional. The rules outlined by the interface still apply if we assign an object literal to it. For example, if we write:

interface Person {
  name: string,
  age?: number
}

const greet = (person: Person) => {
  console.log(`Hello, ${person.name}. ${person.age ? `You're ${person.age} years old.` : ''}`);
}

const person: Person = { name: 'Joe' };
greet(person);

This would still work since we stick to the property descriptions defined in the Person interface. If we add the age property with a number property it would still compile and run:

interface Person {
  name: string,
  age?: number
}

const greet = (person: Person) => {
  console.log(`Hello, ${person.name}. ${person.age ? `You're ${person.age} years old.` : ''}`);
}

const person: Person = { name: 'Joe', age: 20 }
greet(person);

Optional properties are useful in that we can define properties that can possibly be used while preventing the use of properties that aren’t part of the interface. This prevents bugs that arise because of typos in our code.

Readonly Properties

To make properties that are non-modifiable after the object is first created, we can use the readonly keyword in front of a property to designate that the property can only be written once when the object is being created and not any time after. For example, we can write the following code:

interface Person {
  readonly name: string,
  age?: number
}

const greet = (person: Person) => {
  console.log(`Hello, ${person.name}. ${person.age ? `You're ${person.age} years old.` : ''}`);
}

const person: Person = { name: 'Joe', age: 20 };
greet(person);

We added the readonly keyword in front of the name property so that it can only be changed once and only once. So if we try to assign something new to the name property, the TypeScript compiler wouldn’t compile and code and the code wouldn’t run:

interface Person{
  readonly name: string,
  age?: number
}

const greet = (person: Person) => {
  console.log(`Hello, ${person.name}. ${person.age ? `You're ${person.age} years old.` : ''}`);
}

let person: Person = { name: 'Joe', age: 20 };
person.name = 'Jane';
greet(person);

The code above will get us the error message “ Cannot assign to ‘name’ because it is a read-only property” when we try to compile it or in text editors that support TypeScript. However, we can reassign the whole object to a different value like in the following code:

interface Person {
  readonly name: string,
  age?: number
}

const greet = (person: Person) => {
  console.log(`Hello, ${person.name}. ${person.age ? `You're ${person.age} years old.` : ''}`);
}

let person: Person = { name: 'Joe', age: 20 };
person = { name: 'Jane', age: 20 };
greet(person);

Then instead of logging ‘Hello, Joe. You’re 20 years old.’, we get ‘Hello, Jane. You’re 20 years old.’

If we want to designate an array as read-only, that is, only be changed when it’s created, we can use the ReadonlyArray type that’s the same as Array, but all the mutating methods are removed from it so that we can’t accidentally change anything in the array. For example, we can use it as in the following code:

let readOnlyArray: ReadonlyArray<number> = [1,2,3];

Then if we try to write the following code, the TypeScript compiler would give errors:

readOnlyArray[0] = 12;
readOnlyArray.push(5);
readOnlyArray.length = 100;

Then we get the following errors:

Index signature in type 'readonly number[]' only permits reading.(2542)

Property 'push' does not exist on type 'readonly number[]'.(2339)

Cannot assign to 'length' because it is a read-only property.(2540)

If we want to convert a ReadonlyArray back to a writable array, we can use the type assertion as operator to convert it back to a regular array:

let arr = readOnlyArray as number[];

Conclusion

TypeScript interfaces are very handy for defining contracts within our code. We can use it to designate types of variables and function parameters. They let us know what properties a variable can take on, and whether they’re required, optional, or read-only. We can define interfaces with the interfaces keyword, optional properties with the question mark after a variable name, and the readonly keyword for read-only properties.

Categories
TypeScript

Introduction to TypeScript Enums

We look at how to define and use enums in TypeScript.

We look at how to define and use enums in TypeScript

If we want to define constants in JavaScript, we can use the const keyword. With TypeScript, we have another way to define a set of constants call the enums. Enums let us define a list of named constants. It’s handy for defining an entity that can take on a few possible values. TypeScript provides both numeric and string-based enums.

Numeric Enums

TypeScript has an enum type that’s not available in JavaScript. An enum type is a data type that has a set named values called elements, members, enumeral or enumerator of the type. They’re identifiers that act like constants in the language. In TypeScript, a numeric enum has a corresponding index associated with it. The members start with the index 0 by default, but it can be changed to start at any index we like and the subsequent members will have indexes that increment from that starting number instead. For example, we can write the following code to define a simple enum in TypeScript:

enum Fruit { Orange, Apple, Grape };

We can use enums by accessing the members like any other property. For example, in the Fruit enum, we can accept the members like in the following code:

console.log(Fruit.Orange);
console.log(Fruit.Apple);
console.log(Fruit.Grape);

Then console.log from the code above should get us 0 since we didn’t specify a starting index for the enum. We can specify the starting index of an enum with something like in the following code:

enum Fruit { Orange = 1, Apple, Grape };
console.log(Fruit.Orange);
console.log(Fruit.Apple);
console.log(Fruit.Grape);

Then we get the following logged from each console.log statement in order:

1
2
3

We can specify the same index for each member, but it wouldn’t be very useful:

enum Fruit { Orange = 1, Apple = 1, Grape };
console.log(Fruit.Orange);
console.log(Fruit.Apple);
console.log(Fruit.Grape);

Then we get:

1
1
2

from the console.log . As we can see, we specify the index pretty much however we want to change it. We can even have negative indexes:

enum Fruit { Orange = -1, Apple, Grape };
console.log(Fruit.Orange);
console.log(Fruit.Apple);
console.log(Fruit.Grape);

Then we get:

-1
0
1

from the console.log . To get an enum member by its index, we can just use the bracket notation like we access array entries by its index. For example, we can write the following code:

enum Fruit { Orange, Apple, Grape };
console.log(Fruit[0]);
console.log(Fruit[1]);
console.log(Fruit[2]);

Then we get:

Orange
Apple
Grape

Numeric enums can have computed values assigned to their members. For example, we can write a function to get a value for each enum member like in the following code:

const getValue = () => 2;

enum Fruit {
  Orange = getValue(),
  Apple = getValue(),
  Grape = getValue()
};

Note that we assigned a return value for each member. If we don’t do that for all of them like in the following code:

const getValue = () => 2;

enum Fruit {
  Orange = getValue(),
  Apple = getValue(),
  Grape
};

Then the TypeScript compiler won’t compile the code and will give an error “Enum member must have initializer.(1061)“. We can mix both constant and computed values in one enum, so we can write something like:

const getValue = () => 2;

enum Fruit {
  Orange = getValue(),
  Apple = 3,
  Grape = getValue()
};

String Enums

TypeScript enum members can also have string values. We can set the values of each member to a string by assigning strings to them like in the following code:

enum Fruit {
  Orange = 'Orange',
  Apple = 'Apple',
  Grape = 'Grape'
};

However, unlike numeric enums, we can’t assign computed values to them. For example, if we have the following:

const getValue = () => 'Orange';

enum Fruit {
  Orange = getValue(),
  Apple = 'Apple',
  Grape = 'Grape'
};

Then we would get the TypeScript compiler error message “Computed values are not permitted in an enum with string-valued members. (2553)” since computed values aren’t allowed for string-valued enums. String enums don’t have auto-incrementing behavior like numeric enums since they don’t have numerical values, but the values of the enum members are much clearer since each value is a meaningful value that’s clear to any human reading it.

In a single enum, we can have some members having numeric values and others having string values like in the following code:

enum Fruit {
  Orange = 2,
  Apple = 'Apple',
  Grape = 'Grape'
};

However, this is more confusing than having a single type of value for all enum members, so it’s not recommended to have mixed value types for different members for an enum.

Computed and Constant Members

Each enum member has a value associated with it which can either be constant or computed. An enum member is constant if the first member in the enum has no value explicitly assigned to it, which means that it’s assigned the value 0 by default. It can also be considered constant if it doesn’t have an explicit value assigned to it and the preceding enum member was a numeric constant, which means that it’ll have the value of the preceding member plus one. For example, if we have:

enum Fruit { Orange = 1, Apple, Grape };

Then Apple and Grape are both constant members since it’ll be automatically assigned the values 2 and 3 respectively. They’re also considered constant if each member has string values assigned to them. Also, if an enum references a previously defined enum member which can be from the same or a different enum. The return value of any operation assigned to constant enums like surrounding an enum expression with parentheses, doing unary arithmetic or bitwise operations to an enum expression like +, -, ~ , or doing binary arithmetic or bitwise operations like, -, *, /, %, <<, >>, >>>, &, |, ^ with enum expressions as operands are all considered constant enum expressions.

For example, the following enum is an enum with constant enum expressions:

enum Fruit {
  Orange = 1 + 2,
  Apple =  1 + 3,
  Grape = 1 + 4
};

The expressions are constant since they’re computed from any variable or return values of functions. Each member has values that’s computed from number values, rather than numbers assigned to variables or returned from functions.

The following is also an example of an enum with constant members:

enum Fruit {
  Orange = 1 + 2,
  Apple =  1 + 3,
  Grape = Orange + Apple
};

All the members including the last one are constant since the value of Grape is computed from Orange and Apple which are constant. Bitwise operations with both operands being constant values are also considered constants as we have in the following code:

enum Fruit {
  Orange = 1 | 2,
  Apple =  1 + 3,
  Grape = 'abc'.length
};

Anything else not described above is considered computed values. For example, if we have:

enum Fruit {
  Orange = 1 + 2,
  Apple =  1 + 3,
  Grape = 'abc'.length
};

Then Grape is a computed member since the expression we assigned to Grape is not computed from any constant member, and it involves getting a property from an object, which isn’t computed from a constant value.

If we want to define constants in JavaScript, we can use the const keyword. With TypeScript, we have another way to define a set of constants call the enums. Enums let us define a list of named constants. It’s handy for defining an entity that can take on a few possible values. TypeScript provides both numeric and string-based enums. TypeScript allows enum members to have numeric and string values. They can also be computed from values for other enum members or from any other expression we wish to assign. Constant enums are the ones that are computed from actual numerical values as operands or with actual values assigned to the member. All other values are computed member values.

Categories
TypeScript

Cool New Features Released with TypeScript 3.6

Lots of new features are released with TypeScript 3.6. It includes features for iterables like stricter type checking for generators, more accurate array spreading, allow get and set in declare statements, and more.

In this article, we’ll look at each of them.

Stricter Type Check for Generators

With TypeScript 3.6, the TypeScript compiler has more checks for data types in generators.

Now we have a way to differentiate whether our code yield or return from a generator.

For example, if we have the following generator:

function* bar() {
    yield 1;
    yield 2;
    return "Finished!"
}

let iterator = bar();
let curr = iterator.next();
curr = iterator.next();

if (curr.done) {
    curr.value
}

The TypeScript 3.6 compiler knows automatically that curr.value is a string since we returned a string at the end of the function.

Also, yield isn’t assumed to be of any type when we assign yield to something.

For instance, we have the following code:

function* bar() {
    let x: { foo(): void } = yield;
}

let iterator = bar();
iterator.next();
iterator.next(123);

Now the TypeScript compiler knows that the 123 isn’t assignable to something with the type { foo(): void } , which is the type of x . Whereas in earlier versions, the compiler doesn’t check the type of the code above.

So in TypeScript 3.6 or later, we get the error:

Argument of type '[123]' is not assignable to parameter of type '[] | [{ foo(): void; }]'.

Type '[123]' is not assignable to type '[{ foo(): void; }]'.

Type '123' is not assignable to type '{ foo(): void; }'.

Also, now the type definitions for Generator and Iterator have the return and throw methods present and iterable.

TypeScript 3.6 converts the IteratorResult to the IteratorYieldResult<T> | IteratorReturnResult<TReturn> union type.

It can also infer the value that’s returned from next() from where it’s called.

For example, the following would compile and run since we passed in the right type of value into next() , which is a string:

function* bar() {
    let x: string = yield;
    console.log(x.toUpperCase());
}

let x = bar();
x.next();
x.next('foo');

However, the following would fail to compile because of type mismatch between the argument and the type of x :

function* bar() {
    let x: string = yield;
    console.log(x.toUpperCase());
}

let x = bar();
x.next();
x.next(42);

We would have to pass in a string to fix the that arises from x.next(42); . Also, the TypeScript compiler knows that the first call to next() does nothing.

More Accurate Array Spread

With TypeScript 3.6, the transformation of some array spread operators now produce equivalent results when the code is transpiler to ES5 or earlier targets with the --downlevelIteration on.

The flag’s purpose is to transform ES6 iteration constructs like the spread operator and for...of loop to add support for ES6 iteration constructs into code that’s transpiled to something older than ES6.

For example, if we have:

[...Array(3)]

We should get:

[undefined, undefined, undefined]

However, TypeScript versions earlier than 3.6 changes […Array(3)] to Array(3).slice();

Which gets us an empty array with length property set to 3.

Raise Error with Bad Promise Code

TypeScript 3.6 compiler will let us know if we forget to put await before promises in async functions or forget to call then after promises.

For example, if we have:

interface Person {
    name: string;
}

let promise: Promise<Person> = Promise.resolve(<Person>{ name: 'Joe' });
(async () => {
    const person: Person = promise;
})();

Then we get the error:

Property 'name' is missing in type 'Promise<Person>' but required in type 'Person'.

Putting in await before promise would fix the problem:

interface Person {
    name: string;
}

let promise: Promise<Person> = Promise.resolve(<Person>{ name: 'Joe' });
(async () => {
    const person: Person = await promise;
})();

Something writing something like:

(async () => {
      fetch("https://reddit.com/r/javascript.json")
        .json()
})();

will get us the error:

Property 'json' does not exist on type 'Promise<Response>'.(2339)

input.ts(3, 10): Did you forget to use 'await'?

The following will fix the error:

(async () => {
  const response = await fetch("[https://reddit.com/r/javascript.json](https://reddit.com/r/javascript.json)")
  const responseJson = response.json()
})();

Unicode Character Identifiers

Now we can use Unicode characters for identifiers with TypeScript. For example:

const 😚 = 'foo';

would work with TypeScript 3.6 compiler or later.

get and set Accessors Are Allowed in Declare Statements

We can add get and set to declare statements now. For example, we can write:

declare class Bar {
  get y(): number;
  set y(val: number);
}

The generated type definitions will also emit get and set accessors in TypeScript 3.7 or later.

Merging Class and Constructor Function Declare Statements

With TypeScript 3.6 or later, the compiler is smart enough to merge function constructors and class declare statements with the same name. For example, we can write:

export declare function Person(name: string, age: number): Person;
export declare class Person {
    name: string;
    age: number;
    constructor(name: string, age: number);
}

It knows that the function is a constructor and the class is the same as the function.

The signatures of the constructors in the function and class constructor don’t have to match, so the following:

export declare function Person(name: string): Person;
export declare class Person {
    name: string;
    age: number;
    constructor(name: string, age: number);
}

still works.

Semicolons

Now TypeScript is smart enough to add semicolons automatically to places that requires it by style conventions instead of adding it automatically to every statement.

TypeScript 3.6 is another feature-packed release. It focuses on improving features like inferring types and type checks in generators, more accurate array spread for code emitted in ES5 or earlier.

Also, bad promise code will raise errors, like the ones that missed await or then .

Merging function constructor and class code in declare statements are also supported now.

Unicode characters are now supported in identifiers, and semicolons won’t be added automatically on every line.

Categories
TypeScript

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

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.

Categories
TypeScript

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

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.