dx_over_dt
dx_over_dt

Reputation: 14318

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:

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

Upvotes: 2

Views: 926

Answers (2)

smac89
smac89

Reputation: 43068

This is probably the gnarliest type wrangling I've ever done and here it is (proceed with caution):

type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };

type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : (T | U);

type XORs<T extends unknown[]> = T extends [infer P1, infer P2] ? XOR<P1, P2> : T extends [infer Only, ...infer Rest] ? XOR<Only, XORs<Rest>> : never;

type Never<T> = T extends object ? { [P in keyof T]?: never } : never;

interface IContentType {
  contentType?: string;
}

interface IHalBaseLinks {
  links: string[];
}

interface IHalBaseULinks {
  _links: string[];
}

type IHalDocBase = XOR<IHalBaseLinks, IHalBaseULinks>;
type IHalIsData<T> = IHalDocBase & T;
type IHalHasData<T> = IHalIsData<{ data: T }>
type IHalHasDocument<T> = IHalIsData<{ document: T }>;
type IHalHasItem<T> = IHalIsData<{ item: XOR<T, {data: T}> }>;

type IHalWithData<T> = XORs<[IHalHasData<T>, IHalHasDocument<T>, IHalHasItem<T>]>;
type IHalRepresentation<T extends object> =  XORs<[IContentType & IHalIsData<T>, IHalWithData<T & IContentType>, IContentType & IHalWithData<T & Never<IContentType>>]>;

Learned a lot doing this. Specifically, | and & are not as intuitive as they seem at first, which is why I had to specifically look for mutually exclusive types (which don't exist currently) in Typescript, and thus landed on the XOR construct (copied from ts-xor) used to actually make this work.

I still don't fully understand why/how the XOR thing works, but it was the secret ingredient to bringing this monster to life.

The most difficult to figure out was how to make sure contentType was either in the root or inside the object.

Check it on the Playground (Complete with tests).

Upvotes: 3

jtbandes
jtbandes

Reputation: 118651

I think I've done it, by basically expanding all of the helper types out into one big union. This helped me see what was supposed to be happening, and also made it work better in the end. I found it important to add x?: never in many places, so that TS can properly prevent you from mixing together different variants of the data structure.

The errors don't always occur on the same lines as your original code, but I'm not sure much can be done about that.

I think this also still has some limitations, for example, it probably won't work if T legitimately has a data or contentType key.

At some level of complexity, though, I would question the value of having these very strict TS types, which work best with object literals (where the compiler actually gives an error if you add unknown keys), when you said the data is coming from a server anyway, which implies that mostly only your test code will ever be working with object literals. If you're able to leverage these types on the server where the objects are being generated, though, that could be beneficial.

TS Playground

type IHalRepresentation<T extends Record<string, any>> =
  | (T & { contentType?: string; data?: never; item?: never; document?: never })
  | ({item?: never; data: T & {contentType?: never}; document?: never} & { contentType?: string })
  | {item?: never; data: T & {contentType?: string}; document?: never; contentType?: never}
  | ({item: T & {contentType?: never; data?: never}; data?: never; document?: never} & { contentType?: string })
  | {item: T & {contentType?: string; data?: never}; data?: never; document?: never; contentType?: never}
  | ({item: {data: T & {contentType?: never}; contentType?: never} & {[K in keyof T]?: never}; data?: never; document?: never} & { contentType?: string })
  | {item: {data: T & {contentType?: string}; contentType?: never} & {[K in keyof T]?: never}; data?: never; document?: never; contentType?: never}
  | ({item?: never; data?: never; document: T & {contentType?: never}} & { contentType?: string })
  | {item?: never; data?: never; document: T & {contentType?: string}; contentType?: never}
;

Upvotes: 0

Related Questions