Search code examples
javascriptreactjstypescripttypescript-types

how to use typescript to set unique property?


I have some data in my code like below:

interface Product {
    id: number
    name: string;
}

enum EnumValue {
  'VALUE1' = 'VALUE1',
  'VALUE2' = 'VALUE2',
  'VALUE3' = 'VALUE3',
}

const data = {
  'VALUE1': {
    num1: {id: 1, name: '2'},
    num2: {id: 2, name: '2'},
  },
    'VALUE2': {
    num1: {id: 1, name: '2'},
  },
    'VALUE3': {
    num1: {id: 1, name: '2'},
  },
} as const satisfies { readonly [key in EnumValue]: { [key: string]: Product} };

I need to Define a validator for my data type so it only takes unique ids for each EnumValue property. I mean

data = {
 'VALUE1': {
    num1: {id: 1, name: '2'},
    num2: {id: 1, name: '2'},
  },
    'VALUE2': {
    num1: {id: 1, name: '2'},
  },
    'VALUE3': {
    num1: {id: 1, name: '2'},
  },
}

ts should throw error because VALUE1 has 2 objects with id = 1 but

data = {
 'VALUE1': {
    num1: {id: 1, name: '2'},
    num2: {id: 2, name: '2'},
  },
    'VALUE2': {
    num1: {id: 1, name: '2'},
  },
    'VALUE3': {
    num1: {id: 1, name: '2'},
  },

is a valid value. i need as const satisfies part to use data model type in my code. so can you help me define a validator to correct my data type?

There is some code to validate unique ids over an array of objects that may help but the problem is i don't know how to access object values to iterate on in type validation. link to this question

interface IProduct<Id extends number> {
    id: Id
    name: string;
}

type Validation<
    Products extends IProduct<number>[],
    Accumulator extends IProduct<number>[] = []>
    =
    (Products extends []
        // #1 Last call
        ? Accumulator
        // #2 All calls but last
        : (Products extends [infer Head, ...infer Tail]
            ? (Head extends IProduct<number>
                // #3 Check whether [id] property already exists in our accumulator 
                ? (Head['id'] extends Accumulator[number]['id']
                    ? (Tail extends IProduct<number>[]
                        // #4 [id] property is a duplicate, hence we need to replace it with [never] in order to trigger the error
                        ? Validation<Tail, [...Accumulator, { id: never, name: Head['name'] }]>
                        : 1)
                    // #5 [id] is not a duplicate, hence we can add to our accumulator whole product
                    : (Tail extends IProduct<number>[]
                        ? Validation<Tail, [...Accumulator, Head]>
                        : 2)
                )
                : 3)
            : Products)
    )


Solution

  • There is no specific type ValidData in TypeScript corresponding to your requirement that each property have unique id subproperties, so you can't write const data = {⋯} as const satisfies ValidData. Instead you can make a generic ValidData<T> type that checks the input type T and validates it, along with a helper validData() function. So you'd write const data = validData({⋯}); and it would either succeed or fail based on the input.


    First lets write a BasicData<K> type to represent a supertype of all ValidData<T> types, where we don't care about the uniqueness of the id; all we care about is that it has keys K and values whose properties are all Products:

    type BasicData<K extends PropertyKey = EnumValue> =
      Record<K, { [k: string]: Product }>;
    

    Note that I've used a default generic type argument so that BasicData by itself corresponds to BasicData<EnumValue>, which is basically the same as what you were using after satisfies.

    Then, ValidData<T> would look like

    type ValidData<T extends BasicData<keyof T>> =
      { [K in keyof T]: UniqueId<T[K]> }   
    

    where UniqueId<T> is a validator generic that makes sure T has unique id properties. Here's one way to write that:

    type UniqueId<T extends Record<keyof T, Product>> =
      { [K in keyof T]: {
        id: Exclude<T[K]["id"], T[Exclude<keyof T, K>]["id"]>,
        name: string
      } }
    

    This is a mapped type over T where each property is a Product whose id property explicitly Excludes the id properties from all other keys. T[Exclude<keyof T, K>] is the union of all properties of T except the one with key K. And so that id property looks like "Take the id property of this property and Exclude the id properties from all other properties."

    If the id properties are unique, this will end up not excluding anything. If they are not, then the id property for the duplicates will end up being the never type. So if UniqueId<T> extends T, then T is valid. Otherwise, T is invalid in exactly those properties with duplicate ids.

    So now we can write validData() like this:

    const validData =
      <const T extends BasicData & BasicData<keyof T> & ValidData<T>>(
        d: T) => d;
    

    This uses a const type parameter so that callers don't need to remember to use a const assertion. The constraint is the intersection of all the individual constraints we care about. The first is BasicData, meaning it must have all the keys of EnumValue. The second is BasicData<keyof T>, meaning for each property it does have, all of the properties must be a bag of Products. And the final one is ValidData<T>, meaning that its properties must have unique ids.


    Okay, let's test it:

    const data = validData({
      'VALUE1': {
        num1: { id: 1, name: '2' },
        num2: { id: 2, name: '2' },
      },
      'VALUE2': {
        num1: { id: 1, name: '2' },
      },
      'VALUE3': {
        num1: { id: 1, name: '2' },
      },
    }); // okay
    
    const data2 = validData({
      'VALUE1': {
        num1: { id: 1, name: '2' }, // error
        num2: { id: 1, name: '2' }, // error
      },
      'VALUE2': {
        num1: { id: 1, name: '2' },
      },
      'VALUE3': {
        num1: { id: 1, name: '2' },
      },
    });
    

    Looks good. For data1 everything passes. For data2, the 'VALUE1' property fails, and the id properties for both num1 and num2 have errors saying that they are not assignable to never. It's not the prettiest error message, but it works.

    You could do some complicated mess to try to make the error message more understandable:

    type Repl<T, U> = [T] extends [never] ? U : T;
    
    // https://github.com/microsoft/TypeScript/issues/23689 workaround
    interface ErrMsg<T> { nope: never, msg: T } 
    
    type UniqueId<T extends Record<keyof T, Product>> =
      { [K in keyof T]: {
        id: Repl<Exclude<T[K]["id"], T[Exclude<keyof T, K>]["id"]>,
          ErrMsg<`conflicts with ${string &
            { [P in keyof T]:
              T[K]["id"] & T[P]["id"] extends never ? never : Exclude<P, K>
            }[keyof T]}`>>,
        name: string
      } }
    
    
    const data2 = validData({
      'VALUE1': {
        num1: { id: 1, name: '2' }, // ErrMsg<"conflicts with num2">
        num2: { id: 1, name: '2' }, // ErrMsg<"conflicts with num1">
      },
      'VALUE2': {
        num1: { id: 1, name: '2' },
      },
      'VALUE3': {
        num1: { id: 1, name: '2' },
      },
    });
    

    But I consider that out of scope for this question and I won't digress further by explaining it here.

    Playground link to code