Enforce generic and extending types to have the same properties

Viewed 28

I have the following interface that allows to create an object with whatever key as long as the value is another object that contains the value and error properties:

interface FormFields {
    [key: string]: {
        value: string;
        error: boolean;
    };
}

Then I have the following function that based on the object that we pass in, it creates the type guard according to the keys contained by that object:

// Returns form fields object with type guard
const getFormFields = <T extends FormFields>(formFields: T) => formFields;

It works as expected if I put whatever key and assign as a value the object containing the value and error properties:

// Works as expected if i pass all the necessary properties
const formFields2 = getFormFields({
    email: { value: 'user@gmail.com', error: false },
});

console.log(formFields2.email);
console.log(formFields2.password); // Property 'password' does not exist on type '{ email: { value: string; error: false; }; }'.ts(2339)

Also it fails as expected if I omit one of the properties that nested object should contain:

// Fails as expected if i don't pass error property
const formFields = getFormFields({
    email: { value: 'user@gmail.com' }, // Property 'error' is missing in type '{ value: string; }' but required in type '{ value: string; error: boolean; }'.ts(2741)
});

But the problem comes when inside the nested objects I put any property other than value or error because TypeScript will accept it:

// But doesn't prevent of adding more properties
const formFields3 = getFormFields({
    email: {
        value: 'user@gmail.com',
        error: false,

        // I want typescript yelling an error when attempting to add
        // any property other than value or error
        extraProperty: "extra property shouldn't be allowed",
    },
});

I understand that when we extend a type <T extends FormFields> we're not telling typescript that the generic T object will be the same than FormFields but we're telling that it should satisfy at least the FormFields structure. So I was wondering if there is any way by modifying the interface or the constraint to ensure that T contains objects with value and error properties and not any other.

1 Answers

I don't think you can do this with a generic, but I don't see any need for a generic if you don't want extra properties; use FormFields as the type of the parameter:

const getFormFields = (formFields: FormFields) => formFields;

That fails in the places you said you wanted it to fail, and works with exactly value and error properties — if you're using an object literal as the argument (as you are in your examples). That's because TypeScript does "excess property checks" on object literals as an exception to the general rule that an object subtype is allowed where the object type is expected.

You may need to adjust the return type of the function. You said it adds a type guard to the object (I'm guessing you left that out of the question for simplicity), so the return type will need to allow for that.

Here's that third example and the error it now causes:

// Prevents adding more properties
const formFields3 = getFormFields({
    email: {
        value: "user@gmail.com",
        error: false,

        // I want typescript yelling an error when attempting to add
        // any property other than value or error
        extraProperty: "extra property shouldn't be allowed",
//      ^^^^^^^^^^^^^−−−−−−−−
//      Type '{ value: string; error: false; extraProperty: string; }' is not
//      assignable to type '{ value: string; error: boolean; }'.
//      Object literal may only specify known properties, and 'extraProperty'
//      does not exist in type '{ value: string; error: boolean; }'.(2322)
    },
});

Playground link

Related