Home > database >  Is there a way define a second property type based on a key used in a separate property?
Is there a way define a second property type based on a key used in a separate property?

Time:01-16

I am trying to create a filter type which takes the primary object type to define a set of keys for "field" and will map the specified field to properly type "value"; however, the closest I could get is a union of the possible value types. Most documentation I could find was related to functions with works perfectly - just not sure how to get a type to span multiple properties for this situation. If this is possible, I'm also curious if there is a way to define the "operator" union to only include items such as "startswith" if the value type is a string. Any help/resources would be greatly appreciated. Example code:

type FilterItem<T, K extends keyof T> = {
    field: K;
    value: T[K];
    operator: 'eq'|'neq'|'startswith'|'doesnotstartwith';
}
type Test = {
    name: string;
    age: number;
}
const filter = {
    field: 'age',
    value: 'test', //should error as "age" is a number
    operator: 'startswith' //should error as "age" is not a string
} as FilterItem;

CodePudding user response:

Thank you jcalz! Post has been updated to correct spelling and your approach did what I wanted. Resharing link as the answer: TS Playground

To be picky and note for documentation purposes, at least for now, VS code sadly doesn't exclude the string types when using a number value; however, it does still properly display an error within intellisense if the combination is not valid.

CodePudding user response:

Conceptually you want your filter variable to be of a union type with one member for every property of Test. Like this:

type FilterItemTest = {
    field: "name";
    value: string;
    operator: "startswith" | "doesnotstartwith" | "eq" | "neq";
} | {
    field: "age";
    value: number;
    operator: "eq" | "neq";
}

Once you do that then you get the desired behavior (or at least you get errors on invalid assignments, if not on any particular invalid properties; see microsoft/TypeScript#39438 and microsoft/TypeScript#40934 for more information):

let filter: FilterItemTest;
filter = { field: 'age', value: 10, operator: "eq" } // okay
filter = { field: 'age', value: 'test', operator: 'startswith' } // error, value incompatible
filter = { field: 'age', value: 10, operator: 'startswith' } // error, operator incompatible
filter = { field: 'name', value: 'test', operator: 'startswith' } // okay

So that means you want FilterItem<T> to be generic in the object type T corresponding to Test, and produce the desired union from it. Here's one way to accomplish that.

First let's define Operator<T> to represent the acceptable values for the operator property given a property of type T:

type Operator<T> =
    (T extends string ? 'startswith' | 'doesnotstartwith' : never)
    | 'eq' | 'neq'

That's a conditional type which only includes 'startswith' and 'doesnotstartwith' if T has any string values in its domain. Let's make sure it behaves as desired:

type OperatorString = Operator<string>;
// type OperatorString = "startswith" | "doesnotstartwith" | "eq" | "neq"
type OperatorNumber = Operator<number>;
// type OperatorNumber = "eq" | "neq"

Okay, now we can define FilterItem<T>:

type FilterItem<T> = { [K in keyof T]-?: {
    field: K;
    value: T[K];
    operator: Operator<T[K]>
} }[keyof T]

This is a distributive object type (as coined in microsoft/TypeScript#47109) where we map over the properties of T to get an object whose property values are the desired union members, and then immediately index into it with the union of keys to get the desired output. Essentially we're distributing the {field: K, value: T[K], operator: Operator<T[K]>} operation across the union of K values in keyof T.

Let's make sure it works as desired:

type Test = {
    name: string;
    age: number;
}

type FilterItemTest = FilterItem<Test>;
/* type FilterItemTest = {
  field: "name";
  value: string;
  operator: Operator<string>;
} | {
  field: "age";
  value: number;
  operator: Operator<number>;
} */

Looks good. The programmatically-created type FilterItem<Test> is equivalent to the manually-created FilterItemTest, and so our work is now complete.

Playground link to code

  • Related