Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why is this extra property allowed on my Typescript object?

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:

  1. structure, i.e. only existing (enumerated) properties are allowed by the TS compiler on objects of a type
  2. property typechecks, i.e. the type of every property is known to the TS compiler

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?

like image 838
MaxAxeHax Avatar asked Mar 13 '19 09:03

MaxAxeHax


People also ask

How do you omit a property in TypeScript?

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!

How do I make object properties optional in TypeScript?

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.

How do I get all properties optional TypeScript?

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!

How do I add a property in TypeScript?

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!


2 Answers

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 // 
    }
}
like image 158
Titian Cernicova-Dragomir Avatar answered Sep 30 '22 18:09

Titian Cernicova-Dragomir


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

like image 30
James Monger Avatar answered Sep 30 '22 18:09

James Monger