Search code examples
typescriptvalidationjsonschemacontentful

How to use interface to make API responses type safe/declared? TypeScript


I am using TypeScript and Contentful in the same project and am having issue getting my head around how to map out the API response within an interface in a way that is clean and sensible and dynamic some sort as the API responses from contentful are massive and ugly - and for each different content type they could be different.

So these are my main issues:

  1. actually mapping out the API response in an interface - its so big and there is so much unnecessary data do I need to put it all in an interface?
  2. What do I do if say the client later down the line creates new content types so then the api response coming would be structured slightly differently and at which point the interfaces would be wrong?

All help appreciated, really just need advice down the right way to investigate the solutions.

This is an example of one of the contentful API responses:

{
   "sys":{
      "type":"Array"
   },
   "total":2,
   "skip":0,
   "limit":100,
   "items":[
      {
         "metadata":{
            "tags":[
               
            ]
         },
         "sys":{
            "space":{
               "sys":{
                  "type":"Link",
                  "linkType":"Space",
                  "id":"rikydtrxnc79"
               }
            },
            "id":"1w9AMDdCqRHRMEfzif3J0V",
            "type":"Entry",
            "createdAt":"2023-06-22T14:17:28.969Z",
            "updatedAt":"2023-06-22T14:17:28.969Z",
            "environment":{
               "sys":{
                  "id":"master",
                  "type":"Link",
                  "linkType":"Environment"
               }
            },
            "revision":1,
            "contentType":{
               "sys":{
                  "type":"Link",
                  "linkType":"ContentType",
                  "id":"blogPost"
               }
            },
            "locale":"en-US"
         },
         "fields":{
            "title":"Second Test",
            "body":{
               "data":{
                  
               },
               "content":[
                  {
                     "data":{
                        
                     },
                     "content":[
                        {
                           "data":{
                              
                           },
                           "marks":[
                              
                           ],
                           "value":"This is a test.",
                           "nodeType":"text"
                        }
                     ],
                     "nodeType":"paragraph"
                  }
               ],
               "nodeType":"document"
            },
            "tags":[
               {
                  "sys":{
                     "type":"Link",
                     "linkType":"Entry",
                     "id":"5vuJaOnrVvh34oAiBt8qgh"
                  }
               },
               {
                  "sys":{
                     "type":"Link",
                     "linkType":"Entry",
                     "id":"6feqnd9taR55ruluGBsp8h"
                  }
               }
            ]
         }
      },
      {
         "metadata":{
            "tags":[
               
            ]
         },
         "sys":{
            "space":{
               "sys":{
                  "type":"Link",
                  "linkType":"Space",
                  "id":"rikydtrxnc79"
               }
            },
            "id":"32eWu0P7zXgbxqum5nbdqP",
            "type":"Entry",
            "createdAt":"2023-06-22T14:14:34.053Z",
            "updatedAt":"2023-06-22T14:14:34.053Z",
            "environment":{
               "sys":{
                  "id":"master",
                  "type":"Link",
                  "linkType":"Environment"
               }
            },
            "revision":1,
            "contentType":{
               "sys":{
                  "type":"Link",
                  "linkType":"ContentType",
                  "id":"blogPost"
               }
            },
            "locale":"en-US"
         },
         "fields":{
            "title":"Test",
            "body":{
               "data":{
                  
               },
               "content":[
                  {
                     "data":{
                        
                     },
                     "content":[
                        {
                           "data":{
                              
                           },
                           "marks":[
                              
                           ],
                           "value":"This is a test",
                           "nodeType":"text"
                        }
                     ],
                     "nodeType":"heading-1"
                  },
                  {
                     "data":{
                        
                     },
                     "content":[
                        {
                           "data":{
                              
                           },
                           "marks":[
                              
                           ],
                           "value":"",
                           "nodeType":"text"
                        }
                     ],
                     "nodeType":"paragraph"
                  },
                  {
                     "data":{
                        
                     },
                     "content":[
                        {
                           "data":{
                              
                           },
                           "marks":[
                              
                           ],
                           "value":"This is a test",
                           "nodeType":"text"
                        }
                     ],
                     "nodeType":"heading-3"
                  },
                  {
                     "data":{
                        
                     },
                     "content":[
                        {
                           "data":{
                              
                           },
                           "marks":[
                              
                           ],
                           "value":"",
                           "nodeType":"text"
                        }
                     ],
                     "nodeType":"paragraph"
                  },
                  {
                     "data":{
                        
                     },
                     "content":[
                        {
                           "data":{
                              
                           },
                           "marks":[
                              
                           ],
                           "value":"This is a test.",
                           "nodeType":"text"
                        }
                     ],
                     "nodeType":"paragraph"
                  },
                  {
                     "data":{
                        
                     },
                     "content":[
                        {
                           "data":{
                              
                           },
                           "marks":[
                              
                           ],
                           "value":"",
                           "nodeType":"text"
                        }
                     ],
                     "nodeType":"paragraph"
                  }
               ],
               "nodeType":"document"
            },
            "tags":[
               {
                  "sys":{
                     "type":"Link",
                     "linkType":"Entry",
                     "id":"63QnQZsSKUUI3TNAvsI19J"
                  }
               },
               {
                  "sys":{
                     "type":"Link",
                     "linkType":"Entry",
                     "id":"1O7OQ1YFLMshW7BwBTL3ER"
                  }
               }
            ],
            "images":[
               {
                  "sys":{
                     "type":"Link",
                     "linkType":"Asset",
                     "id":"35LgxsKv96DyW51Uu8DrnM"
                  }
               },
               {
                  "sys":{
                     "type":"Link",
                     "linkType":"Asset",
                     "id":"4bxRR1bBIknnVzRjeqvUIr"
                  }
               }
            ]
         }
      }
   ],
   "includes":{
      "Entry":[
         {
            "metadata":{
               "tags":[
                  
               ]
            },
            "sys":{
               "space":{
                  "sys":{
                     "type":"Link",
                     "linkType":"Space",
                     "id":"rikydtrxnc79"
                  }
               },
               "id":"1O7OQ1YFLMshW7BwBTL3ER",
               "type":"Entry",
               "createdAt":"2023-06-22T14:13:23.918Z",
               "updatedAt":"2023-06-22T14:13:23.918Z",
               "environment":{
                  "sys":{
                     "id":"master",
                     "type":"Link",
                     "linkType":"Environment"
                  }
               },
               "revision":1,
               "contentType":{
                  "sys":{
                     "type":"Link",
                     "linkType":"ContentType",
                     "id":"countryTag"
                  }
               },
               "locale":"en-US"
            },
            "fields":{
               "countryTag":"usa"
            }
         },
         {
            "metadata":{
               "tags":[
                  
               ]
            },
            "sys":{
               "space":{
                  "sys":{
                     "type":"Link",
                     "linkType":"Space",
                     "id":"rikydtrxnc79"
                  }
               },
               "id":"5vuJaOnrVvh34oAiBt8qgh",
               "type":"Entry",
               "createdAt":"2023-06-22T10:50:36.561Z",
               "updatedAt":"2023-06-22T10:50:36.561Z",
               "environment":{
                  "sys":{
                     "id":"master",
                     "type":"Link",
                     "linkType":"Environment"
                  }
               },
               "revision":1,
               "contentType":{
                  "sys":{
                     "type":"Link",
                     "linkType":"ContentType",
                     "id":"countryTag"
                  }
               },
               "locale":"en-US"
            },
            "fields":{
               "countryTag":"australia"
            }
         },
         {
            "metadata":{
               "tags":[
                  
               ]
            },
            "sys":{
               "space":{
                  "sys":{
                     "type":"Link",
                     "linkType":"Space",
                     "id":"rikydtrxnc79"
                  }
               },
               "id":"63QnQZsSKUUI3TNAvsI19J",
               "type":"Entry",
               "createdAt":"2023-06-22T14:13:11.295Z",
               "updatedAt":"2023-06-22T14:13:11.295Z",
               "environment":{
                  "sys":{
                     "id":"master",
                     "type":"Link",
                     "linkType":"Environment"
                  }
               },
               "revision":1,
               "contentType":{
                  "sys":{
                     "type":"Link",
                     "linkType":"ContentType",
                     "id":"regionTag"
                  }
               },
               "locale":"en-US"
            },
            "fields":{
               "title":"americas"
            }
         },
         {
            "metadata":{
               "tags":[
                  
               ]
            },
            "sys":{
               "space":{
                  "sys":{
                     "type":"Link",
                     "linkType":"Space",
                     "id":"rikydtrxnc79"
                  }
               },
               "id":"6feqnd9taR55ruluGBsp8h",
               "type":"Entry",
               "createdAt":"2023-06-22T10:41:10.693Z",
               "updatedAt":"2023-06-22T10:41:10.693Z",
               "environment":{
                  "sys":{
                     "id":"master",
                     "type":"Link",
                     "linkType":"Environment"
                  }
               },
               "revision":1,
               "contentType":{
                  "sys":{
                     "type":"Link",
                     "linkType":"ContentType",
                     "id":"regionTag"
                  }
               },
               "locale":"en-US"
            },
            "fields":{
               "title":"apac"
            }
         }
      ],
      "Asset":[
         {
            "metadata":{
               "tags":[
                  
               ]
            },
            "sys":{
               "space":{
                  "sys":{
                     "type":"Link",
                     "linkType":"Space",
                     "id":"rikydtrxnc79"
                  }
               },
               "id":"35LgxsKv96DyW51Uu8DrnM",
               "type":"Asset",
               "createdAt":"2023-06-22T14:14:11.979Z",
               "updatedAt":"2023-06-22T14:14:11.979Z",
               "environment":{
                  "sys":{
                     "id":"master",
                     "type":"Link",
                     "linkType":"Environment"
                  }
               },
               "revision":1,
               "locale":"en-US"
            },
            "fields":{
               "title":"Test1",
               "description":"",
               "file":{
                  "url":"//images.ctfassets.net/rikydtrxnc79/35LgxsKv96DyW51Uu8DrnM/d8a11b6dc57a416a143af10b3569a7e0/pexels-photo-3844788.jpeg",
                  "details":{
                     "size":83381,
                     "image":{
                        "width":500,
                        "height":667
                     }
                  },
                  "fileName":"pexels-photo-3844788.jpeg",
                  "contentType":"image/jpeg"
               }
            }
         },
         {
            "metadata":{
               "tags":[
                  
               ]
            },
            "sys":{
               "space":{
                  "sys":{
                     "type":"Link",
                     "linkType":"Space",
                     "id":"rikydtrxnc79"
                  }
               },
               "id":"4bxRR1bBIknnVzRjeqvUIr",
               "type":"Asset",
               "createdAt":"2023-06-22T14:14:26.979Z",
               "updatedAt":"2023-06-22T14:14:26.979Z",
               "environment":{
                  "sys":{
                     "id":"master",
                     "type":"Link",
                     "linkType":"Environment"
                  }
               },
               "revision":1,
               "locale":"en-US"
            },
            "fields":{
               "title":"Test2",
               "description":"",
               "file":{
                  "url":"//images.ctfassets.net/rikydtrxnc79/4bxRR1bBIknnVzRjeqvUIr/4ffca3cdb4db84b6ee2129cf05f06cdc/premium_photo-1661964088064-dd92eaaa7dcf.jpeg",
                  "details":{
                     "size":46936,
                     "image":{
                        "width":1000,
                        "height":714
                     }
                  },
                  "fileName":"premium_photo-1661964088064-dd92eaaa7dcf.jpeg",
                  "contentType":"image/jpeg"
               }
            }
         }
      ]
   }
}

Now the items array and includes array could have different data sets in the objects so this is why I am finding it hard to wrap my head around how to solve the issue of new content types and existing interfaces/schemas not working for them.

I have already mapped it out the response like this but think its excessive?

export interface ContentfulApiResponse {
  sys: {
    type: string;
  };
  total: number;
  skip: number;
  limit: number;
  items: [
    {
      metadata: {
        tags: [];
      };
      sys: {
        space: {
          sys: {
            type: string;
            linkType: string;
            id: string;
          };
        };
        id: string;
        type: string;
        createdAt: string;
        updatedAt: string;
        environment: {
          sys: {
            id: string;
            type: string;
            linkType: string;
          };
        };
        revision: number;
        contentType: {
          sys: {
            type: string;
            linkType: string;
            id: string;
          };
        };
        locale: string;
      };
      fields: {
        title: string;
        body: {
          data: {};
          content: [
            {
              data: {};
              content: [
                {
                  data: {};
                  marks: [];
                  value: string;
                  nodeType: string;
                }
              ];
              nodeType: string;
            }
          ];
          nodeType: string;
        };
        tags: [
          {
            sys: {
              type: string;
              linkType: string;
              id: string;
            };
          },
          {
            sys: {
              type: string;
              linkType: string;
              id: string;
            };
          }
        ];
      };
    },
    {
      metadata: {
        tags: [];
      };
      sys: {
        space: {
          sys: {
            type: string;
            linkType: string;
            id: string;
          };
        };
        id: string;
        type: string;
        createdAt: string;
        updatedAt: string;
        environment: {
          sys: {
            id: string;
            type: string;
            linkType: string;
          };
        };
        revision: number;
        contentType: {
          sys: {
            type: string;
            linkType: string;
            id: string;
          };
        };
        locale: string;
      };
      fields: {
        title: string;
        body: {
          data: {};
          content: [
            {
              data: {};
              content: [
                {
                  data: {};
                  marks: [];
                  value: string;
                  nodeType: string;
                }
              ];
              nodeType: string;
            },
            {
              data: {};
              content: [
                {
                  data: {};
                  marks: [];
                  value: string;
                  nodeType: string;
                }
              ];
              nodeType: string;
            },
            {
              data: {};
              content: [
                {
                  data: {};
                  marks: [];
                  value: string;
                  nodeType: string;
                }
              ];
              nodeType: string;
            },
            {
              data: {};
              content: [
                {
                  data: {};
                  marks: [];
                  value: "";
                  nodeType: string;
                }
              ];
              nodeType: string;
            },
            {
              data: {};
              content: [
                {
                  data: {};
                  marks: [];
                  value: string;
                  nodeType: string;
                }
              ];
              nodeType: string;
            },
            {
              data: {};
              content: [
                {
                  data: {};
                  marks: [];
                  value: string;
                  nodeType: string;
                }
              ];
              nodeType: string;
            }
          ];
          nodeType: string;
        };
        tags: [
          {
            sys: {
              type: string;
              linkType: string;
              id: string;
            };
          },
          {
            sys: {
              type: string;
              linkType: string;
              id: string;
            };
          }
        ];
        images: [
          {
            sys: {
              type: string;
              linkType: string;
              id: string;
            };
          },
          {
            sys: {
              type: string;
              linkType: string;
              id: string;
            };
          }
        ];
      };
    }
  ];
  includes: {
    Entry: ContentfulIncludesEntry[];
    Asset: ContentfulIncludesAsset[];
  };
}

interface ContentfulIncludesAsset {
  metadata: {
    tags: [];
  };
  sys: {
    space: {
      sys: {
        type: string;
        linkType: string;
        id: string;
      };
    };
    id: string;
    type: string;
    createdAt: string;
    updatedAt: string;
    environment: {
      sys: {
        id: string;
        type: string;
        linkType: string;
      };
    };
    revision: number;
    locale: string;
  };
  fields: {
    title: string;
    description: string;
    file: {
      url: string;
      details: {
        size: number;
        image: {
          width: number;
          height: number;
        };
      };
      fileName: string;
      contentType: string;
    };
  };
}

interface ContentfulIncludesEntry {
  metadata: {
    tags: [];
  };
  sys: {
    space: {
      sys: {
        type: string;
        linkType: string;
        id: string;
      };
    };
    id: string;
    type: string;
    createdAt: string;
    updatedAt: string;
    environment: {
      sys: {
        id: string;
        type: string;
        linkType: string;
      };
    };
    revision: number;
    contentType: {
      sys: {
        type: string;
        linkType: string;
        id: string;
      };
    };
    locale: string;
  };
  fields: {
    countryTag?: string;
    regionTag?: string;
  };
}

And bear in mind the fields object can change with every content type.


Solution

  • My first thought is that you should start by creating a common type for each property name. Sys, Metadata, Tag, Environment, etc, completely ignoring the fact that they'll have circular references that might prevent them from compiling because in this phase you just want to get it all down and understand it. For example:

    interface Item {
      metadata?: Metadata;
      sys?: Sys;
      fields?: Fields;
    }
    

    Another idea is to not even try to strongly type the server response. Instead, accept that the response is unknown and then use zod to assert the data into the types your code expects. An advantage to this is that run-time assertions will help you find bugs and surprises in ways that unit tests and static type checking simply can't.

    However, are you sure you need to do any of this yourself? If something like @bgschiller/contentful-typescript-codegen doesn't help, I'd expect you could find something you could use out there?

    (Note: I'm not recommending @bgschiller/contentful-typescript-codegen. I'd never heard of it before and only found it by searching for "types/contentful" on npmjs.com.)