Extends interface and type in typescript

JavaScript/TypeScript

We often need to build similar structures in different objects. Some functions require the same variables but want to have additional variables. If the requirement changes or we want to rename its name we have to do it in different functions. Does Typescript/Javascript offer better ways to solve this problem? Yes, extends interface and type alias can be used for that purpose. Let’s see how to use them.

Sponsored links

Use case

Let’s consider this case that you want to work with database. Your software needs to search, insert, update and delete items. The primary key is id and it has to be used for all commands. If we don’t use extends interface or type alias it looks like the following. By the way, each command doesn’t return anything in this example for brevity.

enum Operation {
    SearchById = 1,
    SearchAll = 2,
    Insert = 3,
    Update = 4,
    Delete = 5,
}

interface CommandArgs {
    id: number;
    name?: string;
    numberOfStock?: number;
}

function run(command: Operation, args?: CommandArgs) {
    try {
        startTransaction();
        switch (command) {
            case Operation.SearchAll:
                searchAll();
                break;
            case Operation.SearchById:
                if (!args) {
                    throw new Error("args must be provided for searchById.")
                }
                searchById(args);
                break;
            case Operation.Insert: {
                if (args && args.name) {
                    insert({ id: args.id, name: args.name });
                    break;
                }
                throw new Error("name is required to insert.");
            }
            case Operation.Update: {
                if (args && args.numberOfStock) {
                    update({ id: args.id, numberOfStock: args.numberOfStock });
                    break;
                }
                throw new Error("numberOfStock is required to update.");
            }
            case Operation.Delete: {
                if (!args) {
                    throw new Error("args must be provided to delete.")
                }
                deleteById(args);
                break;
            }
            default:
                throw new Error("Undefined command");
        }
        commit();
    } catch (e) {
        rollback();
    }
}

function startTransaction(): void {
    console.log("Start transaction.");
}
function commit(): void {
    console.log("Commit transaction.");
}
function rollback(): void {
    console.log("Rollback completed.");
}

function searchAll(): void {
    console.log("Show all items.");
}
function searchById(args: { id: number }): void {
    console.log(`Found an item. {id: ${args.id}}`);
}

function update(args: { id: number, numberOfStock: number }): void {
    console.log(`Updated an item. {id: ${args.id}, numberOfStock: ${args.numberOfStock}}`);
}

function insert(args: { id: number, name: string }): void {
    console.log(`Inserted an item. {id: ${args.id}, name: ${args.name}}`);
}

function deleteById(args: { id: number }): void {
    console.log(`Deleted an item (interface). {id: ${args.id}}`);
}

We have to change the run function whenever we need to add a new command. It has many roles. It’s a bit hard to follow the process at a glance because the database operation process has long lines. I probably refactor this code in the following way if I don’t use interface.

function run(command: Operation, args?: CommandArgs) {
    try {
        startTransaction();
        execute();
        commit();
    } catch (e) {
        rollback();
    }

    function execute() {
        switch (command) {
            case Operation.SearchAll:
                searchAll();
                break;
            case Operation.SearchById:
                ...
            default:
                throw new Error("Undefined command");
        }
    }
}

It looks nicer than before because we can catch the all processes at a glance. However, its role is the same as before. I want to change this structure by using interface. Let’s check the usage first before changing this implementation.

Interface usage

Interface in Typescript can be used not only for class implementation but also argument data type. Let’s see how to use it.

Interface for class implementation

This Person interface defines two variables and a function. A caller can call those desired variables and functions if the interface type is assigned to the variable since the class has to implement all of them.

interface Person {
    readonly name: string;
    readonly age: number;
    sayHello(): void;
}

class Yuto implements Person {
    public readonly name = "Yuto";
    public get age(): number {
        return 34;
    };
    public sayHello(): void {
        console.log(`Hi, I'm ${this.name}.`);
    }
    public sayGoodMorning():void{
        console.log(`Good morning.`);
    }
}
const yuto: Person = new Yuto();
console.log(`name: ${yuto.name}, age: ${yuto.age}`);
yuto.sayHello();
// yuto.sayGoodMorning(); // Error -> Property 'sayGoodMorning' does not exist on type 'Person'

// --- output ---
// name: Yuto, age: 34
// Hi, I'm Yuto.

However, a caller can’t call sayGoodMorning() function because it’s not defined in Person interface. The variable yuto is defined as Person interface but not the class Yuto at coding time. Therefore, the compiler shows the error above.
name and age are readonly. It doesn’t matter how they are implemented in the concrete class. Define them by either public readonly or getter. The compiler doesn’t complain as far as those variables are available from outside but they should be readonly in the class as described in the interface. Otherwise, its variable can be updated from outside if its instance is directly used but not via the interface.

Interface for argument data type

Another interface usage is for argument data type. Let’s see an example.

function introduce(person: Person): void {
    console.log(`I'm ${person.name}. I'm ${person.age} years old.`);
    person.sayHello();
}

const yuto: Person = new Yuto();
const john = { name: "John", age: 40, sayHello: () => console.log("I'm John.") };
introduce(yuto);
introduce(john);
// I'm Yuto. I'm 34 years old.
// Hi, I'm Yuto.
// I'm John. I'm 40 years old.
// I'm John.

introduce function requires an argument that is Person data type. It doesn’t matter if it’s class or just an object if it has the desired members (variables and functions). This is useful when a function requires many arguments because interface can replace all arguments with a single object as shown in the example above.

Type usage

Type can be used in the same way. I didn’t know that type can be used with implements for class. The following code is basically the same as interface but I replaced Person interface with PersonType.

type PersonType = { // instead of interface
    readonly name: string;
    readonly age: number;
    sayHello(): void;
}

class Yuto implements PersonType {  // implement type !!!!
    public readonly name = "Yuto";
    public get age(): number {
        return 34;
    };
    public sayHello(): void {
        console.log(`Hi, I'm ${this.name}.`);
    }
    public sayGoodMorning(): void {
        console.log(`Good morning.`);
    }
}

function introduce(person: PersonType): void {  // instead of interface
    console.log(`I'm ${person.name}. I'm ${person.age} years old.`);
    person.sayHello();
}

const yuto: PersonType = new Yuto(); // instead of interface
console.log(`name: ${yuto.name}, age: ${yuto.age}`);
yuto.sayHello();

Define extended interface and type

Not only class but also interface can be extended. We can create empty interface as well and extends it.

Extended interface

interface CommandBaseArg { }
interface IdBase extends CommandBaseArg {
    id: string;
}
interface UpdateCommandArgs extends IdBase {
    numberOfStock: number;
}

UpdateCommandArgs has id and numberOfStock in this example. We don’t have to define id again. If you want to change the data type you have to define it without extends keyword because typescript complains about it.

interface ErrorExtend extends IdBase {
    id: number;
}
// Interface 'ErrorExtend' incorrectly extends interface 'IdBase'.
//   Types of property 'id' are incompatible.
//     Type 'number' is not assignable to type 'string'.ts(2430)

Extended type

If you prefer using type you can write like this below.

type InsertCommandArgs = IdBase & {
    name: string;
}

typescript compiler doesn’t complain on the definition even when we define the same property name and different data type but an error message appears when using it.

interface IdBase extends CommandBaseArg {
    id: string;
}
type InsertCommandArgs = IdBase & {
    name: string;
    id: number;
}
let tes: InsertCommandArgs = { name: "123", id: 1 } // error message on 'id'
// Type 'number' is not assignable to type 'never'.ts(2322)
// error-example.ts(3, 5): The expected type comes from property 'id' which is declared here on type 'InsertCommandArgs'

This is because id is defined as string type and number type but no data type exists to match both of them at the same time. If the variable defined in the base interface is unknown or any type this way works.

interface IdBase extends CommandBaseArg {
    id: unknown;
}
type InsertCommandArgs = IdBase & {
    name: string;
    id: number;
}
let tes: InsertCommandArgs = { name: "123", id: 1 } // it works

Alias comparison

Both interface and type can be used to define an alias. In this example below, DeleteCommandArgsByInterface and DeleteCommandArgsByType are the same as IdBase. We can make our code more readable if we use an alias. It’s up to you which to use.

interface DeleteCommandArgsByInterface extends IdBase { }
type DeleteCommandArgsByType = IdBase;

Usage extended interface or type in real

Let’s take a look at the example shown above again. The current code looks like this.

function run(command: Operation, args?: CommandArgs) {
    try {
        startTransaction();
        execute();
        commit();
    } catch (e) {
        rollback();
    }
}

There are several ways to refactor this. What I come up with the following ways.

// First
function run(execute: () => void) {
    try {
        startTransaction();
        execute();
        commit();
    } catch (e) {
        rollback();
    }
}
// Second
function run(command: Operation, args?: CommandArgs) {
    try {
        startTransaction();
        const commandExecutor = createCommand(command);
        commandExecutor.execute(args);
        commit();
    } catch (e) {
        rollback();
    }
}

The first one is just extract execute function and it’s can be done without interface or type. A caller has to set the function in advance and you can write a test for each operation but I will choose the second way this time.

Define an interface for operation command

Firstly, we need to create an interface that has execute function. You can create as many classes as you want but you don’t have to make any changes to the run function. createCommand used in the example above returns a class that implements this interface.

interface CommandBaseArg { }
interface DatabaseCommand {
    public abstract execute(args: CommandBaseArg): void;
}

Define concrete class that implements the interface

Next, implement all necessary commands with the interface.

interface CommandBaseArg { }
interface IdBase extends CommandBaseArg {
    id: string;
}
interface UpdateCommandArgs extends IdBase {
    numberOfStock: number;
}

type InsertCommandArgs = IdBase & {
    name: string;
}

interface DeleteCommandArgsByInterface extends IdBase { }
type DeleteCommandArgsByType = IdBase;

class SearchAllCommand implements DatabaseCommand {
    public execute(): void {
        console.log("Show all items.");
    }
}
class SearchByIdCommand implements DatabaseCommand {
    public execute(args: IdBase): void {
        console.log(`Found an item. {id: ${args.id}}`);
    }
}

class Updateommand implements DatabaseCommand {
    public execute(args: UpdateCommandArgs): void {
        console.log(`Updated an item. {id: ${args.id}, numberOfStock: ${args.numberOfStock}}`);
    }
}

class InsertCommand implements DatabaseCommand {
    public execute(args: InsertCommandArgs): void {
        console.log(`Inserted an item. {id: ${args.id}, name: ${args.name}}`);
    }
}

class DeleteCommandByInterface implements DatabaseCommand {
    public execute(args: DeleteCommandArgsByInterface): void {
        console.log(`Deleted an item (interface). {id: ${args.id}}`);
    }
}
class DeleteCommandByType implements DatabaseCommand {
    public execute(args: DeleteCommandArgsByType): void {
        console.log(`Deleted an item (type). {id: ${args.id}}`);
    }
}

You can use both interface and type as data type, as you can see in the last two classes. One uses an interface and another uses type.

Define a function or class to returns desired command class

I define createCommand function. If you want to stub this function you need to wrap it or move it to a class.

function createCommand(command: Operation): DatabaseCommand {
    switch (command) {
        case Operation.SearchById: return new SearchByIdCommand();
        case Operation.SearchAll: return new SearchAllCommand();
        case Operation.Insert: return new InsertCommand();
        case Operation.Update: return new Updateommand();
        case Operation.Delete: return new DeleteCommandByInterface();
        default:
            console.error(`Undefined command.`);
            return new EmptyCommand();
    }
}

The final code looks like this.

function run(command: Operation, args?: CommandBaseArg) {
    try {
        startTransaction();
        const commandExecutor = createCommand(command);
        commandExecutor.execute(args);
        commit();
    } catch (e) {
        rollback();
    }
}

const args: InsertCommandArgs = {
    id: "product-1",
    name: "first-item",
}
run(Operation.Insert, args);
// Start transaction.
// Inserted an item. {id: product-1, name: first-item}
// Commit transaction.

commandExecutor is DatabaseCommand data type that has execute function that requires CommandBaseArg data type. All execute functions defined in the extended classes use a data type based on CommandBaseArg. Therefore, all functions can accept the argument args required by the parent function.

We don’t have to write many tests for this function. In the previous version, switch case might have a side effect on other case clauses if break keyword is missing or if it updates a variable used in other places, so we needed many tests to check if it worked as expected. However, we can reduce the number of tests for this function in this way and we can write tests against each command separately. It means that the tests are smaller and more concise.

Summary

Extended interface and type can be used to avoid defining the same variable in different places.
With command pattern and extended interface/type, we can make our code more readable and testable.

Comments

Copied title and URL