2021年4月4日星期日

TypeScript type that forces exactly one of the given types

Before this is marked as a duplicate, it is not, at least not of any of the first 10+ search results. This situation adds a level of complexity that I can't quite figure out.

tl;dr What works for exclusively specifying exactly one property of n must exist, or that a property must exist in exactly one of n spots, does not work when used on a nested property. TS Playground

I'm doing some Saturday overengineering--like ya do. My project uses JSON HAL documents (HAL documents basically describe an entity of some sort and with a _links property, define the available behavior), however, the backend (which I did not write) does not always send the documents in the same shape. I have a normalizeRepresentation() function that takes in what the server sends me and morphs it into what it should look like.

To produce minimal code, I've removed stuff regarding _links.

Differences between standard HAL:

  • the entity data is on exactly one of:
    • the root document itself
    • the document property
    • the data property
    • the item property
    • the item.data property
  • there is an optional contentType property that is either placed on the root document or inside the entity data, but not both

normalizeRepresentation() initially had the signature:

function normalizeRepresentation(representation: Record<PropertyKey, any>): IHalDocument;  

I want to strongly type a IHalRepresentation<T> in order to strongly type normalizeRepresentation():

function normalizeRepresentation<T = any>(representation: IHalRepresentation<T>): IHalDocument<T>;  

So, if T is { id: number }, some examples of correct representations would be:

const valid: IHalRepresentation<{ id: number }>[] = [    { id: 1 },    { id: 1, contentType: 'foo' },    { data: { id: 1 }, contentType: 'foo' },    { document: { id: 1, contentType: 'foo' } },    { item: { data: { id: 1, contentType: 'foo' } } },    { item: { data: { id: 1 } }, contentType: 'foo' },  ];  

Some invalid documents would include:

const invalid: IHalRepresentation<{ id: number }>[] = [    // data is both on document and in data prop    { id: 1, data: { id: 1 } },      // both data and item are specified    { data: { id: 1 }, item: { contentType: 'foo' } },      // contentType is on item when item.data exists    { item: { data: { id: 1 }, contentType: 'foo' } },      // contentType is included twice    { item: { data: { id: 1, contentType: 'foo' } }, contentType: 'foo' },  ];  

I have the majority of these cases working. The only problems occur in the { item: { data: T } } cases.

Here is my mostly working code:

/**    * Adds an optional `contentType` property on either `T[K]` or as a sibling of `K`.   */  type WithContentType<T, K extends keyof T> =    | { [_K in K]: T[_K] & { contentType?: string } } & { contentType?: never }    | { [_K in K]: T[_K] & { contentType?: never } } & { contentType?: string };    /**   * The three root properties that may contain our data.   */  type ItemProperties = 'data' | 'item' | 'document';    /**   * @param T The data of the HAL document used for describing the entity.   * @param P The property we want to put `T` on.  If `null`, places it at the root.   * @param CTProp The property of `T` in which to append `contentType`.  If `null`, places it on `T` itself.   */  type Content<T, P extends ItemProperties | null, CTProp extends null | keyof T = null> =    P extends null       ? T & { contentType?: string; data?: never; item?: never; document?: never }      : CTProp extends null        ? WithContentType<{ [K in P & ItemProperties]: T }, P & ItemProperties> & { [K in Exclude<ItemProperties | keyof T, P>]?: never }        : { [K in P & ItemProperties]: WithContentType<T, CTProp & keyof T> } & { [K in Exclude<ItemProperties | keyof T, P>]?: never };    type IHalRepresentation<T extends Record<string, any>> =    // case when all the data is stored at the root of the document    | Content<T, null>    // case when data is stored on the `data` property    | Content<T, 'data'>    // case when data is stored on the `item` property    | Content<T, 'item'>    // case when data is stored on the `item.data` property    | Content<{ data: T }, 'item', 'data'>    // case when data is stored on the `document` property    | Content<T, 'document'>;  

These are the three cases that fail:

interface Doc { id: number }    // @ts-expect-error  const failureItemDataWithTwoContentTypes : IHalRepresentation<Doc> = {    item: {       data: {         id: 1,         contentType: 'bar',      }    },    contentType: 'foo',  };    // @ts-expect-error  const failureItemDataWithContentTypeOnItem : IHalRepresentation<Doc> = {    item: {       data: { id: 1 },      contentType: 'bar',    },  };    // @ts-expect-error  const failureItemandItemData: IHalRepresentation<Doc> = {    item: {       id: 1,      data: { id: 2 },    },  };  

In none of these cases does the compiler detect that they are invalid. I suspect that fixing one case will fix all of them. It seems to think that if item.data is specified, item can be T and { data: T }. I can't for the life of me see where my error is, though.

Another oddity I noticed that may or may not point us in the right direction is that in VSCode, it knows that after P extends null ? ... : P exclusively extends ItemProperties and that CTProp must extend keyof JSONSansLinks<T>>, while TS Playground does not, and I had to specify them manually.

My team is using TS v3.9.7, but I'm seeing the same problem in v4.2.3.

TS Playground

https://stackoverflow.com/questions/66936722/typescript-type-that-forces-exactly-one-of-the-given-types April 04, 2021 at 07:21AM

没有评论:

发表评论