Search code examples
typescripttypesdry

Two identical TypeScript interfaces with different names - how to make it DRY / avoid repetition?


I'm working on a financial app that has "Bids" (offers to buy something) and "Asks" (offers to sell something).

interface Ask {
  price: number
  amount: number
}

interface Bid {
  price: number
  amount: number
}

How can I avoid repeating two identical type definitions? (They will always be identical) I could have only one interface and call it Offer but it loses meaning and clarity.

I tried this:

interface Offer {
  price: number
  amount: number
}

interface Bid extends Offer {}
interface Ask extends Offer {}

But I get an eslint error saying An interface declaring no members is equivalent to its supertype. and there is no point extending the interface if I don't add something to it.


Solution

  • You can use a type alias:

    interface Ask {
        price: number;
        amount: number;
    }
    
    type Bid = Ask;
    

    Playground link

    Note that because TypeScript's type system is structural (based on the shape of types), not nominative (based on the names/identities of types), objects with the type Ask will be assignable to Bid variables/properties/parameters and vice-versa. TypeScript doesn't distinguish between them:

    let a: Ask = { price: 42, amount: 2 };
    //  ^? −−− let a: Ask
    let b: Bid = { price: 67, amount: 4 };
    //  ^? −−− let b: Ask
    a = b; // <== This is allowed
    

    Note that the type hint shown for b is Ask, not Bid.

    If you want to maintain a distinction between the two types, you can use a technique described by Drew Colthorp in the article Flavoring: Flexible Nominal Typing for TypeScript (thank you VLAZ for pointing that out; see also VLAZ's answer to a related question). The core of it is that you can differentiate the types by using an optional branding property, like this (but keep reading):

    interface Offer {
        price: number;
        amount: number;
    }
    
    interface Bid extends Offer {
        __type__?: "Bid";
    }
    
    interface Ask extends Offer {
        __type__?: "Ask";
    }
    

    Since the property is optional, you don't have to specify it when creating instances, but it makes the types incompatible with each other (though you can easily convert from one to the other):

    let a: Ask = { price: 42, amount: 2 };
    //  ^? −−− let a: Ask
    let b: Bid = { price: 67, amount: 4 };
    //  ^? −−− let b: Bid
    a = b; // <== Error: Type 'Bid' is not assignable to type 'Ask'.
           //              Types of property '__type__' are incompatible.
           //                Type '"Bid" | undefined' is not assignable to type '"Ask" | undefined'.(2322)
    

    Playground link

    In the article, Colthorp takes it further by creating a reusable generic Flavor type:

    interface Flavoring<FlavorT> {
        _type?: FlavorT;
    }
    type Flavor<T, FlavorT> = T & Flavoring<FlavorT>;
    

    which you'd use in your code like this:

    interface Offer {
        price: number;
        amount: number;
    }
    
    type Bid = Flavor<Offer, "Bid">;
    
    type Ask = Flavor<Offer, "Ask">;
    

    Playground link

    The same tests with a and b show that you can't assign the one to the other.

    Finally, you could use fully-branded types by actually including the _type (or __type__) property in the instances and not making them optional. I find it handy to do that sometimes because I can see those properties in the debugger and I can use them for type narrowing in code that works with Offer instances. But sometimes you don't want the extra properties cluttering up the objects, hence the "flavor" approach above. Example of full branding:

    interface Offer {
        price: number;
        amount: number;
    }
    
    interface Bid extends Offer {
        __type__: "Bid";
    }
    
    interface Ask extends Offer {
        __type__: "Ask";
    }
    
    let a: Ask = { price: 42, amount: 2, __type__: "Ask" };
    //  ^? −−− let a: Ask
    let b: Bid = { price: 67, amount: 4, __type__: "Bid" };
    //  ^? −−− let b: Bid
    a = b; // <== Error: Type 'Bid' is not assignable to type 'Ask'.
           //              Types of property '__type__' are incompatible.
           //                Type '"Bid"' is not assignable to type '"Ask"'.(2322)
    

    Playground link