Search code examples
typescripttypescript-generics

Typescript exact types for objects in a lists


**update: made a minimal reproducible example and figured out that it is a pure typescript issue **

I want to have exact types of an object in my application. I created a helper generic type as I found out that it is not nativly supported by typescript.

The issue is however that only when i create the object in the variable it gives the error, but when I insert it as variable as the value it does not give the error.

I have created a playground. This is my code:

    type Exact<T, Struct> = T extends Struct ? (Exclude<keyof T, keyof Struct> extends never ? T : never) : never;

export type MultipleObjectSuccessResponse<T extends Record<string, any>> = {
    success: true
    code: 200;
    data: Array<Exact<T, T>>;
    pagination: {
        page: number;
        per_page: number;
        total_pages: number;
        total_records: number;
    };
};

type MyData = {
    prop1: string;
    prop2: number;
};


const test:MultipleObjectSuccessResponse<MyData> = {
    code: 200,
    success: true,
    data: [{
            prop1: "hello", 
            prop2: 123,
            //TS gives an error for prop3:
            //Object literal may only specify known properties, but 'prop3' does not exist in type 'MyData'. Did you mean to write 'prop1'?(2561)
            prop3: "this will cause an error", 
        }
        
    ],
    pagination: {
        page: 1,
        per_page: 10,
        total_pages: 1,
        total_records: 1,
    },
}

const tooManyArray = [{
            prop1: "hello",
            prop2: 123,
            prop3: "this will cause an error", 
        }]

const tooManyObject= {
            prop1: "hello",
            prop2: 123,
            prop3: "this will cause an error", 
        }


const test2:MultipleObjectSuccessResponse<MyData> = {
    code: 200,
    success: true,
    //Typescript gives no error here
    data: tooManyArray,
    pagination: {
        page: 1,
        per_page: 10,
        total_pages: 1,
        total_records: 1,
    },
}

const test3:MultipleObjectSuccessResponse<MyData> = {
    code: 200,
    success: true,
    //Typescript gives no error here as well
    data: [tooManyObject],
    pagination: {
        page: 1,
        per_page: 10,
        total_pages: 1,
        total_records: 1,
    },
}

Solution

  • TypeScript doesn't support so-called exact types (the terminology comes from Flow) where excess properties are prohibited. There is a longstanding feature request for it at microsoft/TypeScript#12936 but it has never been implemented.

    Therefore no specific type corresponds to Exact<MyData>. The closest you can get is to introduce a generic type like Exact<T, MyData> where T will be checked to see if it has any extra properties compared to Data. But this means you'll need everything to be generic that was specific before. You can't escape the generic and write Exact<MyData, MyData>; that will always just be MyData. Instead you need to carry that T around. And even this will only work in cases where nobody throws away information about extra properties. There is no way, even in principle, to stop this:

    const tooManyObject = {
      prop1: "hello",
      prop2: 123,
      prop3: "this will cause an error",
    }
    
    const myData: MyData = tooManyObject; // this will always succeed
    
    const myResponse = {
      code: 200,
      success: true,
      data: [myData],
      pagination: {
        page: 1,
        per_page: 10,
        total_pages: 1,
        total_records: 1,
      },
    }
    

    You can define MultipleObjectSuccessResponse however you'd like, but there's no way to have TypeScript distinguish between a "good" value and myResponse above. If that's a dealbreaker for you, then what you want is impossible because of the fundamental lack of support for exact types. In this case you should give up with worrying about the issue in TypeScript and just write runtime checks that give you runtime errors if there are too many properties. Or, even better, write your code so that extra properties don't actually hurt anything.


    Anyway, that caveat aside, the closest you can get is

    type MultipleObjectSuccessResponse<T extends U, U extends object> = {
      success: true
      code: 200;
      data: Array<Exact<T, U>>;
      pagination: {
        page: number;
        per_page: number;
        total_pages: number;
        total_records: number;
      };
    };
    

    and then you'd have to fill out both T and U when writing your value:

    const test: MultipleObjectSuccessResponse<??, MyData> = { ⋯ };
    

    The U is easy, that's MyData, but what would you plug in for T? It would have to be the type of the input, which means you'd be redundantly describing the extra properties multiple times.

    It would be wonderful if you could do something like

    const test: MultipleObjectSuccessResponse<infer, MyData> = { ⋯ };
    

    but that is not supported. See Typescript generics, infer object property type without a function call

    The only way to get TS to infer a generic type argument is to call a generic function. So we can write a helper function to make up for the missing feature above. Possibly like this:


    // helper function
    const multipleObjectSuccessResponse = <U extends object,>() => <T extends U,>(
      x: MultipleObjectSuccessResponse<T, U>
    ) => x;
    

    This function takes a type argument, like multipleObjectSuccessResponse<MyData>(), and returns another function which will be used for inference:

    const multipleObjectSuccessResponseMyData = multipleObjectSuccessResponse<MyData>();
    

    And now you call that as follows:

    const test = multipleObjectSuccessResponseMyData({
      code: 200,
      success: true,
      data: [{
        prop1: "hello",
        prop2: 123,
      }],
      pagination: {
        page: 1,
        per_page: 10,
        total_pages: 1,
        total_records: 1,
      },
    }); // okay
    

    That works, and produces a test of type MultipleObjectSuccessResponse<MyData, MyData>. Note that const test = multipleObjectSuccessResponseMyData({⋯}) isn't very different from const test: MultipleObjectSuccessResponse<MyData> = {⋯}, or at least they're close enough that you might be able to see the former in the latter. Now let's see what happens when you make mistakes:

    const test = multipleObjectSuccessResponseMyData({
      code: 200,
      success: true,
      data: [{ 
        prop1: "hello", 
        prop2: 123,
        prop3: "this will cause an error", // error, hereabouts
      }],
      pagination: {
        page: 1,
        per_page: 10,
        total_pages: 1,
        total_records: 1,
      },
    });
    
    const tooManyArray = [{
      prop1: "hello",
      prop2: 123,
      prop3: "this will cause an error",
    }]
    
    const tooManyObject = {
      prop1: "hello",
      prop2: 123,
      prop3: "this will cause an error",
    }
    
    
    const test2 = multipleObjectSuccessResponseMyData({
      code: 200,
      success: true,
      data: tooManyArray, // error!
      pagination: {
        page: 1,
        per_page: 10,
        total_pages: 1,
        total_records: 1,
      },
    });
    
    const test3 = multipleObjectSuccessResponseMyData({
      code: 200,
      success: true,
      data: [tooManyObject], // error!
      pagination: {
        page: 1,
        per_page: 10,
        total_pages: 1,
        total_records: 1,
      },
    });
    

    You get all the expected errors (although the exact wording and location of the error could probably be improved. I'd use a different implementation of Exact<T, U> than yours, but that's out of scope here).

    Playground link to code