How to use typescript generics to validate object values correctly

Viewed 111

I have a special case, where I need to write an object configuration for predefined filters. I would like to use typescript generics to accomplish this task.

So, I have the following configuration:

const filterOptions: FilterOption[] = [
  // These should be valid
  { field: 'name', operator: 'sw', operand: 'Mr.' },
  { field: 'age', operator: 'lt', operand: 18 },
  { field: 'joinDate', operator: 'gte', operand: new Date(new Date().setFullYear(new Date().getFullYear() - 1)) },
  
  // These should NOT be valid
  { field: 'name', operator: 'eq', operand: 5 },
  { field: 'age', operator: 'in', operand: 5 },
];

with these types:

interface Filterable {
  name: string;
  age: number;
  joinDate: Date;
}

type NumberOperator = 'lt' | 'lte' | 'gt' | 'gte' | 'eq' | 'ne';
type StringOperator = 'eq' | 'ne' | 'in' | 'ni' | 'sw' | 'ew';

type FilterOption = {
  field: keyof Filterable,

  operator: ???, // What type should I write here? 
                 // NumberOperator type should apply for fields with number and Date types only.
                 // StringOperator type for fields with string type only.

  operand: ???, // What type should I write here?
                // This should be the type of the field as the key in Filterable interface.
}

Two questions:

  1. operand type - It seems to me like it is possible, but I don't know how to write it correctly. This is the closest solution I found, but I can't make it work for objects, this type of generics seems to work only with functions. How should I write generics correctly for this type?
  2. operator type - I think this is much more complicated, and I don't know if it's even possible to accomplish. Is it possible?

Currently I'm using the following way to validate the configuration object. A bit ugly, but works:

interface FilterableString {
  name: string;
}
interface FilterableNumber {
  age: number;
}
interface FilterableDate {
  joinDate: Date;
}
interface Filterable extends FilterableString, FilterableNumber, FilterableDate {}

type FilterOptionString = {
  field: keyof FilterableString,
  operator: StringOperator,
  operand: string,
}
type FilterOptionNumber = {
  field: keyof FilterableNumber,
  operator: NumberOperator,
  operand: number,
}
type FilterOptionDate = {
  field: keyof FilterableDate,
  operator: NumberOperator,
  operand: Date,
}
type FilterOption = FilterOptionString | FilterOptionNumber | FilterOptionDate;

But again, I'm looking for a way to do it using generics, as in the future things might get even uglier and complicated, when more fields and types are added. This may become a bit of a headache to maintain, so I'm trying to avoid that.

1 Answers

You need to create a union of all allowed states of the object. In other words, make illegal state unrepresentable:

interface Filterable {
    name: string;
    age: number;
    joinDate: Date;
}

type NumberOperator = 'lt' | 'lte' | 'gt' | 'gte' | 'eq' | 'ne';

type StringOperator = 'eq' | 'ne' | 'in' | 'ni' | 'sw' | 'ew';


type GetFilter<T> = T extends string ? StringOperator : T extends number | Date ? NumberOperator : never;


type Values<T> = T[keyof T]

type FilterOption = Values<{
    [Prop in keyof Filterable]:
    { field: Prop, operator: GetFilter<Filterable[Prop]>, operand: Filterable[Prop] }
}>

const filterOptions: FilterOption[] = [
    // These should be valid
    { field: 'name', operator: 'sw', operand: 'Mr.' },
    { field: 'age', operator: 'lt', operand: 18 },
    { field: 'joinDate', operator: 'gte', operand: new Date(new Date().setFullYear(new Date().getFullYear() - 1)) },

    // These should NOT be valid
    { field: 'name', operator: 'eq', operand: 5 },
    { field: 'age', operator: 'in', operand: 5 },
];

Playground

Values - obtains a union of all object values

GetFilter - checks the type of property. If it is a string - return StringOperator, if it is a number or Date - return NumberOperator, otherwise - return never.

Related