We've recently started using typescript for our web platform projects.
One of the great advantages was supposed to be the powerful typing system, which allows compile-time checks of all kinds of correctness (assuming we put in the effort to model and declare our types properly).
Currently, I seem to have found the limits of what the type system is able to achieve, but it seems inconsistent and I may also just be using wrong syntax.
I'm trying to model the types of objects our app will be receiving from the backend, and to use the type system to have the compiler checks everywhere in the app for:
Here is a minimized version of my approach (or take a direct link to TS playground )
interface DataObject<T extends string> {
fields: {
[key in T]: any // Restrict property keys to finite set of strings
}
}
// Enumerate type's DB field names, shall be used as constants everywhere
// Advantage: Bad DB names because of legacy project can thus be hidden in our app :))
namespace Vehicle {
export enum Fields {
Model = "S_MODEL",
Size = "SIZE2"
}
}
// CORRECT ERROR: Property "SIZE2" is missing
interface Vehicle extends DataObject<Vehicle.Fields> {
fields: {
[Vehicle.Fields.Model]: string,
}
}
// CORRECT ERROR: Property "extra" is not assignable
interface Vehicle2 extends DataObject<Vehicle.Fields> {
fields: {
extra: string
}
}
// NO ERROR: Property extra is now accepted!
interface Vehicle3 extends DataObject<Vehicle.Fields> {
fields: {
[Vehicle.Fields.Model]: string,
[Vehicle.Fields.Size]: number,
extra: string // Should be disallowed!
}
}
Why is the third interface declaration not throwing an error, when the compiler seems perfectly able to disallow the invalid property name in the second case?
Use the Omit utility type to exclude a property from a type, e.g. type WithoutCountry = Omit<Person, 'country'> . The Omit utility type constructs a new type by removing the specified keys from the existing type. Copied!
To make a single property in a type optional, create a utility type that takes a type and the property name as parameters and constructs a new type with the specific property marked as optional.
Use the Partial utility type to make all of the properties in a type optional, e.g. const emp: Partial<Employee> = {}; . The Partial utility type constructs a new type with all properties of the provided type set to optional. Copied!
To add a property to an object in TypeScript, set the property as optional on the interface you assign to the object using a question mark. You can then add the property at a later point in time without getting a type error. Copied!
This is the expected behavior. The base interface only specifies what the minimum requirements for field
are, there is not requirement in typescript for an exact match between the implementing class field and the interface field. The reason you get an error on Vehicle2
is not the presence of extra
but rather that the other fields are missing. (The bottom error is Property 'S_MODEL' is missing in type '{ extra: string; }'.
)
You can do some type trickery to get an error if those extra properties are present using conditional types:
interface DataObject<T extends string, TImplementation extends { fields: any }> {
fields: Exclude<keyof TImplementation["fields"], T> extends never ? {
[key in T]: any // Restrict property keys to finite set of strings
}: "Extra fields detected in fields implementation:" & Exclude<keyof TImplementation["fields"], T>
}
// Enumerate type's DB field names, shall be used as constants everywhere
// Advantage: Bad DB names because of legacy project can thus be hidden in our app :))
namespace Vehicle {
export enum Fields {
Model = "S_MODEL",
Size = "SIZE2"
}
}
// Type '{ extra: string; [Vehicle.Fields.Model]: string; [Vehicle.Fields.Size]: number; }' is not assignable to type '"Extra fields detected in fields implementation:" & "extra"'.
interface Vehicle3 extends DataObject<Vehicle.Fields, Vehicle3> {
fields: {
[Vehicle.Fields.Model]: string,
[Vehicle.Fields.Size]: number,
extra: string //
}
}
If you imagine that fields
is an interface like so:
interface Fields {
Model: string;
Size: number;
}
(It's done anonymously, but it does match this interface because of your [key in Vehicle.Fields]: any
)
Then this fails, because it doesn't match that interface - it doesn't have a Model
or a Size
property:
fields: {
extra: string
}
However, this passes:
fields: {
Model: string;
Size: number;
extra: string
}
Because the anonymous interface there is an extension of your Fields
interface. It would look something like this:
interface ExtendedFields extends Fields {
extra: string;
}
This is all done anonymously through the TypeScript compiler, but you can add properties to an interface and still have it match the interface, just like an extended class is still an instance of the base class
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With