Home > Enterprise >  How to enforce that some nested keys of an object are of a certain type?
How to enforce that some nested keys of an object are of a certain type?

Time:01-17

I'm looking for a type Validate that would enforce something like this

// validate
const myObj: Validate<'value', boolean> = {
   value: true,
   otherValue: 'its value',
   nestedProp: {
       value: false
   }
}

// does not validate
const myObj: Validate<'value', boolean> = {
   value: true,
   otherValue: 'its value',
   nestedProp: {
       value: 0 // not of type boolean
   }
}

Where the first parameter of the generic is the name of the nested property, and the 2nd is the type to enforce.

Is this even possible?

Edit: the idea is to get auto completition from the IDE when union types are used instead of boolean in that example.

type ColorType = 'red' | 'green'

type ColoredObject<T> = {
  [P in keyof T]: T[P] extends object ? ColoredObject<T[P]> : T[P];
} & { color: ColorType };

const myObject: ColoredObject<{ prop1: { prop2: { color: string } } }> = {
  color: 'red',
  prop1: {
    color: 'red',
    prop2: {
      color: 0 // this would fail
    }
  }
};

In the example above, it works the way I want it, but it makes the color attribute required. Is there a way to make it optional without breaking the requirement ? Also this requires me to pass the object interface as generic, which I do not want since I might not know it.

Edit2: Or this, but then it will not allow any other props than color

type ColoredObject<T, P, V> = {
  [P in keyof T]: T[P] extends object ? ColoredObject<T[P], P, V> : T[P] extends 'color' ? V : never
}

type ColorType = 'red' | 'green'
const test: ColoredObject<any, 'color', ColorType> = {
  color: 'green',
  otherProps: 'lala', // <---- fails here
  props: {
    color: 'green'
  }
}

CodePudding user response:

what about that ?

type Validate<K extends string, T> = {
  value: boolean,
  otherValue: string,
  nestedProp:  {
    [key in K] :T
  }
}

Thanks to Titian comment

type Validate<K extends string, T> = {
      value: boolean,
      otherValue: string,
      nestedProp: Record<K, T>
    }

CodePudding user response:

It isn't obvious that, given a key type K and a value type V, that there is a specific type Validate<K, V> which exactly enforces the rule you're looking to enforce. Often when it's difficult or impossible to write a specific type corresponding to some rule, it becomes a lot more straightforward to write a generic type that checks if a candidate type obeys that rule.

So instead of Validate<K, V>, you use T extends Validate<T, K, V> as a self-referential type constraint. And to prevent you from needing to manually specify that T type, you can write a generic helper function to infer it for you.

So conceptually the approach is: instead of const x: Validate<K, V> = ..., you write a function of the form

declare const validateKV = <T extends Validate<T, K, V>>(t: T) => t;

and then write const x = validateKV(...);. Of course, it's not even that easy all the time; often T extends Validate<T, K, V> would be considered an illegally circular constraint, and to work around that you need to play games with conditional types like

declare const validateKV = <T,>(
  t: T extends Validate<T, K, V> ? T : Validate<T, K, V>
) => t;

And you probably don't want to write a new helper function for every possible K and V, so that should be generic also, and unfortunately you can't get "partial inference" where you specify K and V but the compiler infers V (see microsoft/TypeScript#26242 for the feature request) so you need to work around that (see Typescript: infer type of generic after optional first generic for more information) and the easiest way is currying, like this:

const validate = <K extends PropertyKey, V>(k: K, v: V) =>
  <T,>(t: T extends Validate<T, K, V> ? T : Validate<T, K, V>) => t;

So that's what we'll use, once we define Validate<T, K, V>.


And here it is:

type Validate<T, K extends PropertyKey, V> =
  T extends object ? {
    [P in keyof T]: P extends K ? V : Validate<T[P], K, V>
  } : T;

The idea is that, if T is a primitive type, then Validate<T, K, V> is just T. So Validate<T, K, V> will automatically accept any primitive type. On the other hand, if it is an object type, then Validate<T, K, V> is a mapped type where each property key P is checked. If the property key P happens to be the same as the special key K, then we want to make sure that it has value type V. Otherwise, we just want to recurse down and make sure that the property type T[P] is assignable to Validate<T[P], K, V> for the same K and V.


Let's test it out, for the particular K of "value" and V of boolean, as mentioned in the question code. First we need to use the curried outer function:

const validateValueBoolean = validate("value", Math.random() < 0.5);

And now we can try that.

const myObj = validateValueBoolean({
  value: true,
  otherValue: 'its value',
  nestedProp: {
    value: false,
    nested: {
      value: true
    }
  },
  me: {
    value: true,
    other: 23,
    somethingElse: "",
    whoKnows: {
      foo: {
        value: true
      },
      value: false
    }
  }
}); // okay

That type checks as desired. At every level, the property named value has a value of type boolean. Contrast that with:

const myObj2 = validateValueBoolean({
  value: true,
  otherValue: 'its value',
  nestedProp: {
    value: false,
    nested: {
      value: true
    }
  },
  me: {
    value: true,
    other: 23,
    somethingElse: "",
    whoKnows: {
      foo: {
        value: 3 // error, number is not a boolean
      },
      value: false
    }
  }
});

This is a compiler error, and specifically on the bad value property, where it tells you that 3 is a number whereas a boolean was expected. And if you fix that then the error goes away.

So, looks good. This is about as close as I can imagine getting to your desired type, given the limitations of the language and my understanding of the use case.


Playground link to code

  • Related