typescripttypescript-typingstypescript-generics

Declare interface that respect mapped type


On a typescript project, I have created the following mapped type GlanceOsComparatorV2

interface DonutData {
  label: string;
  value: number;
}

interface ProjectMetric {
  value: number;
}

enum ZoneMetricId {
  ClickRate = 'clickRate',
}
enum PageMetricId {
  ActivityRate = 'activityRate',
  BounceRate = 'bounceRate'
}

type MetricId =
  | ZoneMetricId
  | PageMetricId

type MetricLabel = 'device' | 'appVersion';

type GlanceKeyCustomV2 = `${MetricId}-${MetricLabel}-app`;

type GlanceKeyV2 = GlanceKeyCustomV2 | MetricId;

type GlanceOsComparatorV2<T> = {
  [to in GlanceKeyV2]: T | DonutData[];
}

Then I want to create an interface that all it's property satisfy the mapped type GlanceOsComparatorV2<ProjectMetric>, like this :

interface GlanceOsComparator {
  ['clickRate-device-app']: DonutData[];
  ['clickRate-appVersion-app']: DonutData[];
  [ZoneMetricId.ClickRate]: ProjectMetric;
};

My problem is how I could declare my interface GlanceOsComparator so that if I add a property that doesn't match GlanceKeyV2 a typescript error is throw ?

ie: I want this to throw an error

interface GlanceOsComparator {
  ['test-error']: DonutData[]; // THIS SHOULD NOT BE POSSIBLE
};

Thanks !


Solution

  • To achieve the desired behavior without creating an unused type, you could leverage TypeScript's conditional types and type inference to enforce the constraints directly on the GlanceOsComparator interface.

    That would involve defining the interface such that any keys not matching GlanceKeyV2 will result in a type error.

    From tsplay.dev

    interface DonutData {
      label: string;
      value: number;
    }
    
    interface ProjectMetric {
      value: number;
    }
    
    enum ZoneMetricId {
      ClickRate = "clickRate",
    }
    
    enum PageMetricId {
      ActivityRate = "activityRate",
      BounceRate = "bounceRate",
    }
    
    type MetricId = ZoneMetricId | PageMetricId;
    
    type MetricLabel = "device" | "appVersion";
    
    type GlanceKeyCustomV2 = `${MetricId}-${MetricLabel}-app`;
    
    type GlanceKeyV2 = GlanceKeyCustomV2 | MetricId;
    
    type GlanceOsComparatorV2<T> = {
      [to in GlanceKeyV2]: T | DonutData[];
    };
    
    // Helper type to enforce the key constraint and allow partial implementation
    type WithGlanceKeyV2<T> = Partial<{
      [K in keyof T]: K extends GlanceKeyV2 ? T[K] : never;
    }>;
    
    // Interface with constrained keys
    interface GlanceOsComparator extends WithGlanceKeyV2<GlanceOsComparatorV2<ProjectMetric>> {}
    
    // Usage examples
    const validComparator: GlanceOsComparator = {
      'clickRate-device-app': [{ label: 'Example', value: 10 }],
      'clickRate-appVersion-app': [{ label: 'Example', value: 10 }],
      [ZoneMetricId.ClickRate]: { value: 10 }
    };
    
    // Uncommenting this will throw a TypeScript error
    // const invalidComparator: GlanceOsComparator = {
    //   'test-error': [{ label: 'Example', value: 10 }] // Error
    //};
    

    GlanceOsComparatorV2<T> is a mapped type that creates a dictionary where each key is a string from GlanceKeyV2 and the value is either type T or DonutData[].

    The WithGlanceKeyV2<T> type uses a conditional mapped type to iterate over the keys of GlanceOsComparatorV2<ProjectMetric>.

    • For each key K, it checks if K is assignable to GlanceKeyV2. If it is, the type of the property is preserved. If not, the type is set to never.
    • By wrapping this mapped type in Partial, we make each property optional, which means that GlanceOsComparator can implement any subset of these keys without requiring all of them.

    The interface GlanceOsComparator extends WithGlanceKeyV2<GlanceOsComparatorV2<ProjectMetric>>. That means it inherits the conditional property checks from WithGlanceKeyV2.
    As a result, any key added to an object of type GlanceOsComparator that does not exist in GlanceKeyV2 would be of type never. TypeScript will flag any property with type never as an error, unless the property is explicitly omitted.

    Usage examples:

    • The validComparator object demonstrates a valid usage of the GlanceOsComparator interface. It only uses keys that are part of GlanceKeyV2.
    • The commented-out invalidComparator object illustrates what would happen if you try to add a property that is not in GlanceKeyV2. If you uncomment this part, TypeScript will throw an error because 'test-error' is not a valid key according to GlanceKeyV2.

    In the last case, the error would be:

    Type '{ 'test-error': { label: string; value: number; }[]; }' is not assignable 
    to type 'Partial<{ clickRate: ProjectMetric | DonutData[]; activityRate: ProjectMetric | DonutData[]; bounceRate: ProjectMetric | DonutData[]; 5 more ...; "bounceRate-appVersion-app": ProjectMetric | DonutData[]; }>'.
    
    Object literal may only specify known properties, and ''test-error'' does not exist 
    in type 'Partial<{ clickRate: ProjectMetric | DonutData[]; activityRate: ProjectMetric | DonutData[]; bounceRate: ProjectMetric | DonutData[]; 5 more ...; "bounceRate-appVersion-app": ProjectMetric | DonutData[]; }>'.
    

    That does indicate that the TypeScript compiler has correctly identified that the key 'test-error' is not a valid property of the GlanceOsComparator type. That is because 'test-error' is not a key in GlanceKeyV2, and therefore it cannot be used in the GlanceOsComparator interface as per the constraints defined in the WithGlanceKeyV2<T> type.

    The error confirms that the code is functioning as intended: it allows only properties that match keys in GlanceKeyV2 within the GlanceOsComparator interface, and any attempt to use keys outside this set will result in a TypeScript compilation error. That behavior makes sure the interface respects the constraints of the mapped type GlanceOsComparatorV2<ProjectMetric>.