Home > database >  Make key required only in certain cases in a TS interface
Make key required only in certain cases in a TS interface

Time:10-05

I'm trying to create an interface for an "order". An order for a "book" is a special case where it should have a required "title" field. So how do I create the IBookOrder and combine it with IOtherOrder to make a generic order interface?

type productType = "book" | "pen" | "notebook"

interface IOtherOrder {
    product: productType, //all productType(s) except book
    amount: number,
    productCode: number,
    description: string
}

interface IBookOrder {
    product: productType, //how do I fix this to "book" type
    amount: number,
    productCode: number,
    description: string,
    title: string,        //required field only for order of "book"
}

//need to combine these here
interface IOrderValues extends IBookOrder, IOtherOrder {} 

const penOrder: IOrderValues = {
    product: "pen",
    amount: 5,
    description: "ink pen",
    productCode: 123,
    title: "This should not be allowed" // <<= incorrect
}

const bookOrder: IOrderValues = {
    product: "book",
    amount: 15,
    description: "Awesome TS book",
    productCode: 321,
    title: "Typescript 101" // <<= this should be required
}

CodePudding user response:

You can explicitly mark the product properties of IOtherOrder and IBookOrder to be the subsets of the ProductType union you want:

interface IOtherOrder {
    product: "pen" | "notebook" // or Exclude<ProductType, "book">
    amount: number,
    productCode: number,
    description: string
}

interface IBookOrder {
    product: "book"
    amount: number,
    productCode: number,
    description: string,
    title: string,
}

Note that you don't need to use ProductType at all in these definitions, but if you think you might dynamically add more string literal types to the union later, then something like Exclude<ProductType, "book"> (using the Exclude<T, U> utility type) will automatically add them as well.


When it comes to your IOrderValues type, you cannot make this a specific (non-generic) interface type; interface types are required to have statically known members, and the conditional inclusion of title is not possible. You certainly don't want it to extend both IBookOrder and IOtherOrder, because then every IOrderValues would be required to be both of them (which is impossible now since their product properties are mutually exclusive).

Instead you probably want it to be a union of the IBookOrder and IOtherOrder interfaces; if you want to give it a name, you can do so via a type alias:

type IOrderValues = IBookOrder | IOtherOrder;

(Note that this makes your I prefix a misnomer; it's up to you whether you need such prefixes, and naming conventions are probably out of scope for this question so I won't go on about that here.)


Now your example code behaves as desired:

const penOrder: IOrderValues = {
    product: "pen",
    amount: 5,
    description: "ink pen",
    productCode: 123,
    title: "This should not be allowed" // error!
//  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//  Object literal may only specify known properties, 
//  and 'title' does not exist in type 'IOtherOrder'
}

const bookOrder: IOrderValues = {
    product: "book",
    amount: 15,
    description: "Awesome TS book",
    productCode: 321,
    title: "Typescript 101" 
} // okay

Keep in mind though that the error you're seeing on penOrder relies on excess property checking, which only kicks in when you assign an object literal directly to a place expecting a type with fewer properties. You can end up with extra properties if you do things differently:

const obj = {
    product: "pen" as const,
    amount: 5,
    description: "ink pen",
    productCode: 123,
    title: "My wonderful rainbow ink pen",
    numberOfColors: 7
}
const penOrder: IOrderValues = obj; // no error

Excess properties are not, by themselves, a violation of the type system; TypeScript has structural subtyping, so while IOtherOrder is required to have the properties in the declaration, it is not prohibited from having extra properties. Indeed, such allowance for extra properties it what supports interface and class extension; without that, interface and class hierarchies would not form type hierarchies, and things would be a lot harder to use.

Personally I think this is fine and you should not worry about excess properties sneaking in there; you cannot really prevent all possible excess properties, so you should probably make sure your code does not rely on their absence (e.g., it should simply ignore them if they are there; it should not expect that Object.keys() will return only the known keys... see Why doesn't Object.keys return a keyof type in TypeScript?).

If you want to explicitly exclude title from appearing in IOtherOrder, you could change the IOtherOrder declaration:

interface IOtherOrder {
    product: Exclude<ProductType, "book">
    amount: number,
    productCode: number,
    description: string
    title?: never; // <-- add this
}

to say that title is an optional property whose value would be the impossible never type. And thus the only valid value of title would be undefined. This is probably overkill, though.

Playground link to code

  • Related