typescriptreact-typescript

Conditional type props based on other props with mandatory props


I've got a discriminated union type that allows certain props based on another prop in the type. Trying to add a mandatory field to the union seems to be breaking the type-checking that was working before.

I've got the following types:

type ColType = 'string' | 'array';

// Base type for all columns - makes sure the field is valid
type DataGridColDefBase<C extends ColType, F extends boolean> = {
  type?: C;
  filterable?: F;
};

// Type that makes the 'type' prop optional
type DataGridColDefNoType<C extends ColType = ColType, F extends boolean = boolean>
  = DataGridColDefBase<C, F> &
  { type?: never; filterable?: F };

// Type used for columns that have operators that take no parameters - these don't need to be specified when used
type DataGridColDefNoParameterOperator<C extends ColType = ColType, F extends boolean = boolean>
  = DataGridColDefBase<C, F> &
  (
    | { type: C extends 'string' ? C : never; filterable?: F extends true ? F : never; filterOperators?: () => void; }
    | { type: C extends 'string' ? C : never; filterable?: F extends false ? F : never; }
  );

// Type used for columns that have operators that take parameters - those need to be specified when used
type DataGridColDefParameterOperator<C extends ColType = ColType, F extends boolean = boolean>
  = DataGridColDefBase<C, F> &
  (
    | { type: C extends 'array' ? C : never; filterable?: F extends true ? F : never; filterOperators: () => void; }
    | { type: C extends 'array' ? C : never; filterable?: F extends false ? F : never; }
  );

type TypeSafeGridColDef<C extends ColType = ColType, F extends boolean = boolean>
  =
  | DataGridColDefNoType<C, F>
  | DataGridColDefNoParameterOperator<C, F>
  | DataGridColDefParameterOperator<C, F>;

This is correctly throwing an error that type is missing when I try to define a variable without it:

const test: TypeSafeGridColDef = { filterOperators: () => [] };

// Error: Property 'type' is missing in type '{ filterOperators: () => never[]; }' but required in type '{ type: "array"; filterable?: true | undefined; filterOperators: () => void; }'.

See a full example playground with more tests here.

I then add another prop field to DataGridColDefBase, which I want to be mandatory in all cases and I pass the new generic through all types that are affected:

type GridValidRowModel = { [key: string]: any; [key: symbol]: any; };

type FieldDef<R extends GridValidRowModel> = keyof R;

// Base type for all columns - makes sure the field is valid
type DataGridColDefBase<R extends GridValidRowModel, C extends ColType, F extends boolean> = {
  field: FieldDef<R>;
  type?: C;
  filterable?: F;
};

The test same test from above now stops giving me that error:

const test: TypeSafeGridColDef<{ bar: string }> = { field: 'bar', filterOperators: () => [] };

// No error

See a the same example playground from before but with the extra field here.

What I've tried so far

I don't think this is anything to do with the field type per se - I've tried having a simple field: string instead and not using a generic and I get the same behaviour.

I've also tried putting the field prop inside all the union types with the same effect.

This line of thinking has further pushed me into thinking if this entire discriminated union approach is the right way to go, so suggestions on that one are welcome too!


Solution

  • The issue is that you've got a union member of the form

    {
      type?: undefined;
      filterable?: boolean | undefined;
    } 
    

    in the first case and

    {
      field: string;
      type?: undefined;
      filterable?: boolean | undefined;
    } 
    

    in the second. Both of those are incredibly wide types that match just about anything, even things you don't want. In the first case, though, TypeScript sees that all the properties are optional and performs weak type detection on it, meaning it enforces excess property checking even though that is not really a type safety issue. Technically, { filterOperators: () => [] } matches, but weak types are given extra scrutiny.

    In the second case, it's not a weak type because of the required field property, and it therefore matches { field: 'bar', filterOperators: () => [] } and there is no complaint.

    I suspect you really want that union member to look like { type?: never; filterable?: F; filterOperators?: never } so that filterOpterators is prohibited when type is absent. But that is up to you to decide. Don't rely on excess property checking to enforce these things, because excess property checking is more of a linter feature and not a type safety feature.

    Playground link to code