This isn't a flaw in the system.
When it comes to TypeScript, it utilizes a structural type system, which means that two object types are considered compatible if their properties align, regardless of whether the types have different names or originate from distinct classes/interfaces.
It's essential to understand that Customer
can be assigned to Product
, given that every instance of Customer
includes a number
-based id
attribute and a string
-based name
attribute. The reverse is not true; assigning Product
to Customer
is not feasible because not all Product
instances include the mandatory address
property.
Is this considered an error? Should you be concerned that the compiler views a Customer
as a specialized version of a Product
? If so, the simplest solution would be to include a distinguishing property in each type for the compiler to differentiate between them. For example:
class Product {
id!: number;
name!: string;
type?: "product"
}
class Customer {
id!: number;
name!: string;
address!: string;
type?: "customer"
}
By doing so, the code will prompt an error when desired:
export abstract class BaseClass<TParam> {
protected abstract process(param: TParam): void;
}
export class Class1 extends BaseClass<Customer> {
protected process(param: Product): void { // error!
// ~~~~~~~ <-- Type 'Customer' is not assignable to type 'Product'.
console.log(param);
}
}
Alternatively, you may find it acceptable that the compiler sees a Customer
as a specialized form of Product
. In such cases, retaining your original types while examining why process()
doesn't return a compiler error could prove enlightening:
export class Class1 extends BaseClass<Customer> {
protected process(param: Product): void { // no error
console.log(param);
}
}
In this scenario, BaseClass<Customer>
should possess a process()
method capable of accepting a Customer
. Nonetheless, process()
actually accepts the broader Product
type instead. Is this permissible? Absolutely! If process()
functions correctly with any Product
argument, then it most certainly handles any Customer
argument (since a Customer
falls under the category of a unique Product
type, effectively allowing Class1
to extend BaseClass<Customer>
successfully). This concept showcases how method arguments are contravariant; subclass methods are permitted to accept broader arguments than those on their supertype. TypeScript acknowledges this contravariance feature, hence the absence of any errors.
Conversely, employing covariant method arguments (where subclass methods demand more specific argument types compared to their superclass counterparts) isn't deemed entirely secure, but certain programming languages - including TypeScript - allow it to handle common scenarios. Essentially, TypeScript supports both contravariant and covariant method argument settings, also termed bivariant, despite lacking complete type safety. Consequently, if the roles were reversed, there still wouldn't be any issues:
export class Class2 extends BaseClass<Product> {
protected process(param: Customer): void { // no error, bivariant
console.log(param);
}
}
To summarize: introducing properties to Customer
and Product
to establish structural independence or maintaining the current setup where Class1.process()
compiles without any errors are valid choices. Either way, the compiler operates according to its intended design.
Hopefully, this clarifies matters for you. Best of luck!