5

#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)?

bastej
  • 83
  • 1
  • 5
  • 2
    Oh, that's a good one. You chanced on how discriminated unions behave with intersections. I will draft up an answer tomorrow if no one beats me to it. Here is a proof of your idea working just fine with only nested discriminated unions: https://tsplay.dev/WzyJRm – Oleg Valter is with Ukraine May 14 '21 at 02:55
  • 1
    @OlegValter Thank you for your response. I have been tried to improve my code basing on the snippet you sent above but it not quit fit and I can't handle it. I will be grateful I you would help me exactly with my case. – bastej May 20 '21 at 14:16
  • huh, guess I got distracted by the other stuff, sorry - thank you for pinging me back on it :( I will give it a shot shortly. I need to search for a good reference first - I think I saw a discussion on the TS source repo about a similar issue – Oleg Valter is with Ukraine May 20 '21 at 14:32
  • 1
    sorry, I removed a comment above because in the time passed I forgot that your question was not about nested discriminated unions, but about union of object types with intersections. That is a separate issue (probably related to the issue #9919, but I need to investigate first) – Oleg Valter is with Ukraine May 20 '21 at 15:02
  • phew - finally got to it :) The issue turned out to be different, but solvable. Apologies for the edit and subsequent rollback, I have a small experiment I needed to run, please don't mind it - thank you! – Oleg Valter is with Ukraine May 22 '21 at 06:02

2 Answers2

1

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

  • Thank you for your exhaustive answer. Idea with `never` covers most cases, but still in the case `{ name: 'col2' }` TS needs isFilterable, but should't :/ – bastej May 28 '21 at 12:23
  • @bastej - just noticed that I made a typo and left the readers wonder what law I meant :) Re: `{ name: string }` case - yeah, that's, unfortunately, the price to pay for the solution - I don't think you can completely solve it right now. At least it's on the team's tracks... – Oleg Valter is with Ukraine May 28 '21 at 12:59
  • @bastej - you can add the 4th union member in `Column` type like this: `{ name: string, isFilterable:false }` to at least give you the ability to pass the property as `false` as in your initial solution (we only use the `never` trick to break the second discriminant), but that's it – Oleg Valter is with Ukraine May 28 '21 at 13:09
  • I hope this is at least better than no error on unwanted properties, though :) – Oleg Valter is with Ukraine May 28 '21 at 13:10
0

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

bastej
  • 83
  • 1
  • 5