Cristian Blandon
Cristian Blandon

Reputation: 43

How to create a RequireOnlyOne nested type in TypeScript?

I've seen this code snippet a lot:

type RequireOnlyOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>>
& {
    [K in Keys]-?:
        Required<Pick<T, K>>
        & Partial<Record<Exclude<Keys, K>, undefined>>
}[Keys]

Here you can find the question from where I took it.

It works, however, I have this structure:

export interface MenuItems {
  firstLevel: {
    secondLevel: ['one', 'two']
  };
  anotherFirstLevel: {
    anotherSecondLevel: ['one', 'two'],
    oneMoreSecondLevel: null
  };
}

I need to apply RequireOnlyOne for the first and the second levels, but I can't figure what to change on the type RequireOnlyOne so it works for each firstLevel keys but also with secondLevel keys. As it is right now I can select only one firstLevel but multiple secondLevels of that firstLevel.

I also tried to compose a new type with an object which key could be RequireOnlyOne<keyof MenuItems> and a value that uses also RequireOnlyOne for the values, but couldn't make it.

Example of what I want, calling the desired type as customType:

const workingObject: customType = {
  firstLevel: { // Just one property of the first level
    secondLevel: ['one', 'two'] // Just one property of the second level
  };
}

const errorObject: customType = {
  firstLevel: {
    secondLevel: ['one', 'two']
  };
  anotherFirstLevel: { // Should not work as I am including 2 properties for the first level
    anotherSecondLevel: ['one', 'two']
  };
}

const anotherErrorObject: customType = {
  anotherFirstLevel: {
    anotherSecondLevel: ['one', 'two'],
    oneMoreSecondLevel: null // Should not work neither as I am including 2 properties for second level
  };
}

The type should throw an error if the object has more than one first level property, and/or more than one second level property. With the proposed RequireOnlyOne type I can achieve that but just for the first level, but I need the same effect for first and second level.

Any ideas?

Upvotes: 2

Views: 404

Answers (2)

Maxim Mazurok
Maxim Mazurok

Reputation: 4128

IMO it's a quite complex question and I'd recommend looking for alternatives and refactor the code.

Otherwise, you might find this helpful:

type RequireOnlyOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
  {
    [K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>>;
  }[Keys];

type RequireOnlyOneUnion<T, Keys extends KeysOfUnion<T> = KeysOfUnion<T>> = Pick<T, Exclude<keyof T, Keys>> &
  {
    [K in Keys]-?: Required<Pick<T, K>> & Partial<Record<Exclude<Keys, K>, undefined>>;
  }[Keys];

export interface MenuItems {
  firstLevel: {
    secondLevel: ["one", "two"];
  };
  anotherFirstLevel: {
    anotherSecondLevel: ["one", "two"];
    oneMoreSecondLevel: null;
  };
}

type KeysOfUnion<T> = T extends T ? keyof T : never;

const x: RequireOnlyOne<MenuItems, keyof MenuItems> = { firstLevel: { secondLevel: ["one", "two"] } };

// ok
const a: RequireOnlyOneUnion<MenuItems[keyof typeof x], KeysOfUnion<MenuItems[keyof typeof x]>> = {
  anotherSecondLevel: ["one", "two"],
};
// error
const b: RequireOnlyOneUnion<MenuItems[keyof typeof x], KeysOfUnion<MenuItems[keyof typeof x]>> = {
  secondLevel: ["one", "two"],
  anotherSecondLevel: ["one", "two"],
};

Upvotes: 0

I don't know how to change RequireOnlyOne type but I know how to create new type.

export interface MenuItems {
  firstLevel: {
    secondLevel: ['one', 'two']
  };
  anotherFirstLevel: {
    anotherSecondLevel: ['one', 'two']
    oneMoreSecondLevel: null
  };
}

type Primitives = string | number | boolean | null | undefined | bigint | symbol

type UnionKeys<T> = T extends T ? keyof T : never;
// credits goes to https://stackoverflow.com/questions/65805600/type-union-not-checking-for-excess-properties#answer-65805753
type StrictUnionHelper<T, TAll> =
  T extends any
  ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;

type StrictUnion<T> = StrictUnionHelper<T, T>

type Transform<Obj, Keys extends keyof Obj = keyof Obj, Result = never> =
  StrictUnion<
    Keys extends string ? { // #1
      [Key in Keys]:
      Key extends keyof Obj
      ? (Obj[Key] extends Primitives
        ? Obj[Key]
        : (Obj[Key] extends any[]
          ? Obj[Key]
          : Transform<Obj[Key], keyof Obj[Key], Obj[Key]>)
      )
      : never
    } : Result>


type CustomType = Transform<MenuItems>

const workingObject: CustomType = {
  firstLevel: { // Just one property of the first level
    secondLevel: ['one', 'two'] // Just one property of the second level
  },
}


const errorObject: CustomType = {
  firstLevel: {
    secondLevel: ['one', 'two']
  },
  anotherFirstLevel: { // Should not work as I am including 2 properties for the first level
    anotherSecondLevel: ['one', 'two']
  },
}

const anotherErrorObject: CustomType= {
  anotherFirstLevel: {
    anotherSecondLevel: ['one', 'two'],
    oneMoreSecondLevel: null // Should not work neither as I am including 2 properties for second level
  },
}

Transform - is a main utility type. Recursively iterates over keys. 1# Keys extends string - this line makes sure that that Keys is distributet. It means that whole code which goes after this line will be applied to each key. Please see docs for more info.

I have also added Obj[Key] extends any[] - because you don't want (I suppose) to iterate though arrays keys.

Playground

Upvotes: 1

Related Questions