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.
https://stackoverflow.com/questions/66936722/typescript-type-that-forces-exactly-one-of-the-given-types April 04, 2021 at 07:21AM
没有评论:
发表评论