Categories
JavaScript TypeScript

Merging Declarations in TypeScript

Spread the love

With TypeScript, we can declare many entities that don’t exist in vanilla JavaScript like interfaces, enums, and type alias. Sometimes, these types have to be merged together into one, like when we try to derive a new class from multiple classes or interfaces. This means that there are rules for merging them together. The process of declaration merging is the combining of multiple members from different sources with overlapping member names or types.

Basic Rules of Merging Interfaces

Merging interfaces is the simplest and most common operation type of declaration merging. The most basic case is that 2 interfaces have no overlapping members. In this case, they just get merged together mechanically into one. For example, if we have 2 Person interfaces like we do in the following code:

interface Person {  
  name: string;  
}

interface Person {  
  age: number;  
}

Then they’ll just be merged together into one when we declare a variable with the Person type. For example, we can write something like:

let person: Person = {  
  name: 'Jane',  
  age: 20  
}

Then TypeScript would accept this code and compile and run the code.

If there’re any overlap of non-function members, then they must be unique. Otherwise, they must have the same type. For example, if we have:

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

interface Person {  
  age: number;  
}

Then we get the following errors:

Subsequent property declarations must have the same type.  Property 'age' must be of type 'string', but here has type 'number'.(2717)input.ts(3, 5): 'age' was also declared here.

Also when 2 interfaces are being merged, whatever is in the second interface will overwrite the members of the first one if the first one has overlapping members with the second one. For function members, they’ll just be combined together as overloads of the same function. For example, if we have the following interface declarations:

interface Animal { };  
interface Sheep { };  
interface Dog { };  
interface Cat { };

interface Eater {  
  eat(animal: Animal): Animal;  
}

interface Eater {  
  eat(animal: Sheep): Sheep;  
}

interface Eater {  
  eat(animal: Dog): Dog;  
  eat(animal: Cat): Cat;  
}

Then we get the Eater interface, would be:

interface Eater {    
  eat(animal: Dog): Dog;  
  eat(animal: Cat): Cat;  
  eat(animal: Sheep): Sheep;  
  eat(animal: Animal): Animal;  
}

The elements of each group maintain the same order but the group of functions that are in the later interfaces is ordered first in the merged interface. The only exception is that when the signature of a function member is composed of a single string literal, then that’ll be moved to the top. For example, if we have:

interface Animal { };  
interface Sheep { };  
interface Dog { };  
interface Cat { };
interface Eater {  
  eat(animal: 'Animal'): Animal;  
}

interface Eater {  
  eat(animal: Sheep): Sheep;  
}

interface Eater {  
  eat(animal: Dog): Dog;  
  eat(animal: Cat): Cat;  
}

Then the merged Eater interface would be:

interface Eater {    
  eat(animal: 'Animal'): Animal;  
  eat(animal: Dog): Dog;  
  eat(animal: Cat): Cat;  
  eat(animal: Sheep): Sheep;  
}

Namespaces

Namespaces of the same name will have their members merged together. All the exported interfaces and classes are merged into one namespace. For example, if we have the following namespaces:

namespace Animals {  
  export class Cat { }  
}

namespace Animals {  
  export interface Mammal {  
      name: string;          
  }  
  export class Dog { }  
}

Then the merged namespace will be:

namespace Animals {  
  export interface Mammal {  
      name: string;          
  } 
  export class Cat { }  
  export class Dog { }  
}

Non-exported members, on the other hand, are only visible in their own un-merged namespace. For example, if we have:

namespace Animal {  
  export class Cat { }  
  export const getAnimal = () => {  
    return animal;  
  }  
}

namespace Animal {  
  let animal = {};  
  export interface Mammal {  
    name: string;          
  }  
  export class Dog { }  
}

Then we get the following error messages:

Cannot find name 'animal'. Did you mean 'Animal'?(2552)

since the animal variable isn’t exported.

We can’t have 2 namespace members with the same name since we would have duplicate declarations that aren’t allowed. For example, if we have:

namespace Animal {  
    export class Cat { }  
    export let animal = {};  
    export const getAnimal = () => {  
        return animal;  
    }  
}

namespace Animal {  
    export let animal = {};  
    export interface Mammal {  
      name: string;          
    }  
    export class Dog { }  
}

Then we get the error:

Cannot redeclare block-scoped variable 'animal'.(2451)

Merging Namespaces with Classes

The rules for merging namespaces with classes is the same as merging any other namespace members. They have to be exported for us to merge them. We can write something like the following to define inner classes by referencing a class inside a namespace from an outer class:

class Person {  
    label: Person.PersonName;      
}

namespace Person {  
    export class PersonName { }  
}

We can also reference members from a namespace from a function that has the same name as the namespace. For example, we can write the following code:

function buildName(middleName: string): string {  
    return `${buildName.firstName} ${middleName} ${buildName.lastName}`;  
}

namespace buildName {  
    export let firstName = "Jane";  
    export let lastName = "Smith";  
}
buildName('Mary');

We can access the members of the buildName namespace inside the buildName function as long as we export the members. Likewise we can reference enum members from inside a namespace with the same name as the enum. For example, we can write something like:

enum Fruit {  
  Orange = 1,  
  Apple = 2,  
  Banana = 4  
}

namespace Fruit {  
  export const mixFruit = (fruit: string) => {  
    if (fruit == "Orange and Apple") {  
      return Fruit.Orange + Fruit.Apple;  
    }  
    else if (fruit == "Apple and Banana") {  
      return Fruit.Apple + Fruit.Banana  
    }  
    return 0;  
  }  
}

As we can see, we can access the members of the Fruit enum in the Fruit namespace inside the mixFruit function.

Disallowed Merges

Currently, we can merge classes with other classes or variables.

Module Augmentation

We can use the declare keyword to declare that a module has members with properties that the TypeScript compiler doesn’t know about. For example, we can write something like:

// fruit.ts  
export class Fruit<T> {  
  // ...  
}

// setColor.ts

import { Fruit } from "./fruit";  
declare module "./fruit" {  
  interface Fruit<T> {  
    setColor(f: (x: string) => string): Fruit;  
  }  
}  
Fruit.prototype.setColor = function (f) {  
  // ...  
}

In the code above, we used the declare module keywords to declare the items in the Fruit class that the TypeScript compiler can’t see. It lets us manipulate things that are defined but TypeScript compiler can’t spot.

We can use the method above to patch existing declarations. It doesn’t work with new top-level declarations. Also, default exports can’t be augmented since it has no name.

Global Augmentation

We can also add declarations in the global scope from inside a module. For example, we can write something like the following code:

// fruit.ts  
export class Fruit {  
   
}

declare global {  
  interface Array<T> {  
    toFruitArray(): Fruit[];  
  }  
}

Array.prototype.toFruitArray = function () {  
  return this.map(a => <Fruit>{ ... });  
}

In the code above, we made the TypeScript compiler aware of the global Array object and then added a toFruitArray to its prototype. Without the declare clause, we would get an error since the TypeScript compiler doesn’t know that the toFruitArray method exists in the Array global object.

Many things can be merged together in TypeScript. Merging interfaces is the simplest and most common operations types of declaration merging. The most basic case is that 2 interfaces have no overlapping members. In this case, they just get merged together mechanically into one. Also when 2 interfaces are being merged, whatever is in the second interface will overwrite the members of the first one if the first one has overlapping members with the second one. For function members, they’ll just be combined together as overloads of the same function. Namespaces of the same name will have their members merged together. All the exported interfaces and classes are merged into one namespace.

We can use the declare keyword to declare that a module has members with properties that the TypeScript compiler doesn’t know about. We can also add declarations in the global scope from inside a module with global module augmentation. Classes and variables currently can’t be merged together.

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 *