Add an extra type property base on another property

Viewed 176

#1 I have a type for the column that is an object. Column can be filterable or not, if isFilterable is true then the type Column should require: filterType, isTopBarFilter? and options (BUT only if filterType is 'SELECT' - #2).

type Column = {
  name: string;
  isFilterable: boolean; // passing here false should be equal with not passing the property at all (if possible)

  // below properties should exist in type only if isFilterable = true
  filterType: 'SELECT' | 'TEXT' | 'DATE';
  options: string[]; // this property should exist in type only if filterType = 'SELECT'
  isTopBarFilter?: boolean;
};

I do such type with use of types union and it work almost properly

type FilterableColumn = {
  isFilterable: true;
  filterType: 'SELECT' | 'TEXT' | 'DATE';
  options: string[];
  isTopBarFilter?: boolean;
};

type NonFilterableColumn = {
  isFilterable: false;
};

type Column = (NonFilterableColumn | FilterableColumn) & {
  name: string;
};

but:

  1. As I mentioned before (#2) Column should require options only if filterType is 'SELECT'. I have tried to do this with types union but it became works strange:
type FilterableSelectColumn = {
  filterType: 'SELECT';
  options: string[];
};

type FilterableNonSelectColumn = {
  filterType: 'TEXT' | 'DATE' | 'NUMBER';
};

type FilterableColumn = (FilterableSelectColumn | FilterableNonSelectColumn) & {
  isFilterable: true;
  isTopBarFilter?: boolean;
};

type NonFilterableColumn = {
  isFilterable: false;
};

type Column = (FilterableColumn | NonFilterableColumn) & {
  name: string;
};

// e.g
const col: Column = {
  name: 'col2',
  isFilterable: false,
  filterType: 'SELECT', // unwanted
  isTopBarFilter: false, // unwanted
  options: ['option1'], // unwanted
};

Playground

If I set isFilterable to false, TS doesn't suggesting unwanted properties (it is good) but also doesn't show error if I pass these unwanted props (it is bad)

  1. My solution also force to pass isFilterable even if it is false, as I mentioned above I want to pass it only if it is true

Is there way to improve my solution(or another solution) to achieve what I described at the beginning (#1)?

2 Answers

Let's see how the distribution law affects how the intersections and unions resolve in your case. First, the following:

type OuterUnionMemberA = (UnionMemberA | UnionMemberB) & IntersectedA;

is equivalent to this:

type OuterUnionMemberA = (UnionMemberA & IntersectedA) | (UnionMemberB & IntersectedA);

which in turns leads us to the following:

type ComplexType = (OuterUnionMemberA | IntersectedB) & OuterIntersected;

being equivalent to this complex union:

type ComplexType = (UnionMemberA & IntersectedA & OuterIntersected) | (UnionMemberB & IntersectedA & OuterIntersected) | (IntersectedB & OuterIntersected);

Let's resolve the aliases manually and see what it leaves us with:

type ComplexType = { 
  filterType: 'SELECT'; 
  options: string[];
  isFilterable: true; 
  extraProp?: boolean;
  name: string;
} | {
  filterType: 'TEXT' | 'DATE' | 'NUMBER';
  isFilterable: true; 
  extraProp?: boolean;
  name: string;
} | {
  isFilterable: false;
  name: string
}

To verify our expectation of this being the same type, let's do an equality test:

type isSupertype = ComplexType extends ComplexTypeUnwrapped ? true : false; //true
type isSubtype = ComplexTypeUnwrapped extends ComplexType ? true : false; //true

All the above was done to make the following clear:

  1. There are 2 discriminant properties (filterType and isFilterable);
  2. In this case, the excess property check is not performed;

The combination of the above turns out to be a confirmed design limitation of TypeScript (see this and this issues on the source repository and the question that lead to the former issue being raised).

But what can you do about it? never to the rescue: being forbidden as a property is pretty much the same as having a type never, so a simple change to the isFilterable property accordingly to avoid making isFilterable a second discriminant property (extra optional property omitted for simplicity) should do the trick:

type Column = 
(
  { name: string, isFilterable:true,filterType:"SELECT",options:string[] } | 
  { name: string, isFilterable:true,filterType:"TEXT"|"DATE" } | 
  { name: string, isFilterable:never } |
  { name: string, isFilterable:false } //allows "name-only" case
)

const notFilterableAll: Column = { name: 'col2', isFilterable:false };
const notFilterableText: Column = { name: 'col2', filterType: "TEXT" }; //Property 'isFilterable' is missing;
const notFilterableSelect: Column = { name: "col2", filterType: "SELECT", options: [] }; //Property 'isFilterable' is missing;
const notFilterableSelectMissingOpts: Column = { name: "col2", filterType: "SELECT" }; //Type '"SELECT"' is not assignable to type '"TEXT" | "DATE"';
const selectFilterOk: Column = { name: 'col2', isFilterable:true, filterType: "SELECT", options: [] }; //OK
const textFilter: Column = { name: "col2", isFilterable:true, filterType: "TEXT" }; //OK

Playground

Ok, after a few nights I managed to do it, I have two solutions:

1.

type FilterableColumn = {
  isFilterable: true;
  isTopBarFilter?: boolean;
} & (
  | {
      filterType: 'SELECT';
      options: string[];
    }
  | {
      filterType: 'TEXT' | 'DATE';
    });

type NonFilterableColumn = {
  isFilterable?: undefined; // same result with never
  filterType?: undefined; // same result with never
};

type ColumnBaseFields = {
  name: string;
};

type Column = (FilterableColumn | NonFilterableColumn) & ColumnBaseFields;

const column: Column = {
  name: 'someName',
  isFilterable: true,
  filterType: 'SELECT',
  options: ['option'],
};

Playground

It works as I wanted, Typescript errors appears for the cases, but error descriptions are inaccurate. I noticed that TypeScript works strange with many unions on the same nesting level

and because of it I made up the second solution with nested filters options

2.

type FilterSettings = (
  | {
      filterType: 'SELECT';
      options: string[];
    }
  | {
      filterType: 'TEXT';
    }) & {
  isTopBarFilter?: boolean;
};

type FilterableColumn = {
  isFilterable: true;
  filterSettings: FilterSettings;
};

type NonFilterableColumn = {
  isFilterable?: undefined; // same result with never
};

type ColumnBaseFields = {
  name: string;
};

type Column = (FilterableColumn | NonFilterableColumn) & ColumnBaseFields;

const column: Column = {
  name: 'someName',
  isFilterable: true,
  filterSettings: {
    filterType: 'SELECT',
    options: ['option']
  }
};

Playground

Works fine, typescript tell us exactly when some key is missed and when some key is unwanted.

I hope it will be helpful for someone

Related