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.