Home > Net >  Check if type has more than one property
Check if type has more than one property

Time:12-28

I would like to create a type called X that accepts a generic type and will be inferred as follows:

  1. If a parent has only one child, then return itself.
  2. If not, return parent name (child 1) or parent name (child 2) and so on.

Given the following type:

type ParentsWithChildren= {
  parent1: {
    child1: unknown;
    child2: unknown;
  },
  parent2: {
    child3: unknown;
  }
}

// X<ParentsWithChildren> = "parent1 (child1)" | "parent1 (child2)" | "parent2"

See playground

CodePudding user response:

Since our logic depends on property count, first of all, we need to create an utility type to count the number of object properties/children. I would say that this is not trivial.

Let's try to do this.

type KeyCount<Obj, Cache extends any[] = []> =
  keyof Obj extends never ? Cache['length'] : {
    [Prop in keyof Obj]: KeyCount<Omit<Obj, Prop>, [...Cache, Prop]>
  }[keyof Obj]

{
  type _ = KeyCount<{ a: 'a' }> // 1
  type __ = KeyCount<{ a: 'a', b: 'b' }> // 2
  type ___ = KeyCount<{ a: 'a', b: 'b', c: 'c' }> // 3
  type ____ = KeyCount<{ a: 'a', b: 'b', c: 'c', d: 'd', e: 'e', f: 'f' }> // 6 (MAX supported count of props in TS 4.5.4 )
  type _____ = KeyCount<{ a: 'a', b: 'b', c: 'c', d: 'd', e: 'e', f: 'f', j: 'j' }> // 7 (works only with TS >= 4.6.* but makes my CPU unhappy)
  type ______ = KeyCount<{ a: 'a', b: 'b', c: 'c', d: 'd', e: 'e', f: 'f', j: 'j', h: 'h', }> // 8 (works only with TS >= 4.6.*)
}

KeyCount - recursively iterates through each object property and adds 1 to Cache array. The length of Cache coresponds to number of object properties.

Now we need to obtain a union of all possible paths combinations. This is similar to:

See comments for explanation

type Label<Cache extends string, Prop> = Prop extends string ? `${Cache} (${Prop})` : never

type Path<Obj, Cache extends string = ''> =
  // If hit the primitive value  - return Cache
  Obj extends string
  ? Cache
  // Otherswise, iterate through Obj properties and
  : {
    [Prop in keyof Obj]:
    // check if it is first iterated property - don't use Cache as a prefix
    Cache extends '' ? Path<Obj[Prop], `${Prop & string}`> :
    // if it is not first iterated property, check if number of properties is 1
    | KeyCount<Obj> extends 1
    // If Obj has only 1 property - return top level property name [parent]
    ? Cache
    // If Obj has more than 1 properties/children - create approperiate label (parent 1 child1)
    : Label<Cache, Prop>
    | Path<Obj[Prop], Label<Cache, Prop>>
  }[keyof Obj]

Whole code

type ParentsWithChildren = {
  parent1: {
    child1: string,
    child2: string;
  },
  parent2: {
    child3: string,
  }
}

type KeyCount<Obj, Cache extends any[] = []> =
  keyof Obj extends never ? Cache['length'] : {
    [Prop in keyof Obj]: KeyCount<Omit<Obj, Prop>, [...Cache, Prop]>
  }[keyof Obj]


type Label<Cache extends string, Prop> = Prop extends string ? `${Cache} (${Prop})` : never

type Path<Obj, Cache extends string = ''> =
  // If hit the primitive value  - return Cache
  Obj extends string
  ? Cache
  // Otherswise, iterate through Obj properties and
  : {
    [Prop in keyof Obj]:
    // check if it is first iterated property - don't use Cache as a prefix
    Cache extends '' ? Path<Obj[Prop], `${Prop & string}`> :
    // if it is not first iterated property, check if number of properties is 1
    | KeyCount<Obj> extends 1
    // If Obj has only 1 property - return top level property name [parent]
    ? Cache
    // If Obj has more than 1 properties/children - create approperiate label (parent 1 child1)
    : Label<Cache, Prop>
    | Path<Obj[Prop], Label<Cache, Prop>>
  }[keyof Obj]

// "parent1 (child1)" | "parent1 (child2)" | "parent2 (child3)"
type Result = Path<ParentsWithChildren>

Playground

Please keep in mind that calculating number of proeprties is tricky. Maybe it worth using an array for this purpose.


If you don't like my KeyCount approach, because it has his own recursion limitation, you can consider this approach:

type ParentsWithChildren = {
  parent1: {
    child1: string,
    child2: string;
  },
  parent2: {
    child3: string,
  }
}

// credits goes to https://stackoverflow.com/a/50375286
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;

// credits goes to https://github.com/microsoft/TypeScript/issues/13298#issuecomment-468114901
type UnionToOvlds<U> = UnionToIntersection<
  U extends any ? (f: U) => void : never
>;

type PopUnion<U> = UnionToOvlds<U> extends (a: infer A) => void ? A : never;

// credits goes to https://stackoverflow.com/questions/53953814/typescript-check-if-a-type-is-a-union#comment-94748994
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;

type UnionToArray<T, A extends unknown[] = []> = IsUnion<T> extends true
  ? UnionToArray<Exclude<T, PopUnion<T>>, [PopUnion<T>, ...A]>
  : [T, ...A];

// NEW VERSION OF KeyCount
type KeyCount<Obj> = UnionToArray<keyof Obj> extends any[] ? UnionToArray<keyof Obj>['length'] : never

type Label<Cache extends string, Prop> = Prop extends string ? `${Cache} (${Prop})` : never

type Path<Obj, Cache extends string = ''> =
  Obj extends string
  ? Cache
  : {
    [Prop in keyof Obj]:
    Cache extends '' ? Path<Obj[Prop], `${Prop & string}`> :
    | KeyCount<Obj> extends 1
    ? Cache
    : Label<Cache, Prop>
    | Path<Obj[Prop], Label<Cache, Prop>>
  }[keyof Obj]

// "parent1 (child1)" | "parent1 (child2)" | "parent2 (child3)"
type Result = Path<ParentsWithChildren>

Playground

CodePudding user response:

Shout out to @captain-yossarian for nudging me in the right direction.

Here's a simpler approach.

For each property key in the top-most ("parent") object, get the child object, derive the set of its keys. If the set is just one key (i.e., is a string literal type), resolve to the current parent key (it's easy to confuse the two keys here, read carefully). Otherwise, if the set of child object's keys contains multiple keys (i.e., is a union), append each child key from the set to the current parent key.

This is IMO better than the currently accepted answer, because:

  • the result perfectly fits the original requirement that children objects with only one property must not include the property key (e.g., it produces "parent2" rather than "parent2 (child3)");
  • as far as I can tell, there should be no significant performance caveats that you have to consider, – just be reasonable as usual.

Here's how it might be done:

type UnionToIntersection<Union> =
  (Union extends any ? (k: Union) => void : never) extends ((k: infer Intersection) => void)
    ? Intersection
    : never;

type IsUnion<Type> = [Type] extends [UnionToIntersection<Type>] ? false : true;

type X<Obj extends Record<string, object>> = {
  [Key in keyof Obj]:
    IsUnion<keyof Obj[Key]> extends false ?
      Key
    :
    IsUnion<keyof Obj[Key]> extends true ?
      `${string & Key} (${string & keyof Obj[Key]})`
    :
      never;
}[keyof Obj];

Try it.

CodePudding user response:

Here is where Typescript leaks, if you come from a type safe background with proper run time type information.

AFAIK you have to step into javascript-land. MDN: getOwnPropertyNames

TL;DR

Javascript has not types. -ish. So when you run your typescript you really run javascript and all type inference is off.

If you happen to know the property names you can check for them like myObject.child1 == undefined. Which is not 100% correct, as a programmer could do MyParent2.child1='whatever' and suddenly you have a child1 on your parent2. But most programmers don't so it is a valid solution.

Then you should remember that objects are just key value stores.

const myObj = new MyObject();
myObj.MyProperty = 1;
// myObj["MyProperty"] == 1
  • Related