typescriptmapped-typesconditional-types

How to enforce interdependence among properties in Typescript


I am trying to enforce interdependence among properties in a general TypeScript type. The context is a reusable React component that receives props. If the component receives a text for a button, for example, then it must also receive a handler for the button; but if the component doesn't receive the text, then the handler prop should also be undefined.

This is my attempt:

type InterConnectedProps<
  T extends string | undefined,
  PropertyName extends string | undefined,
> = T extends string
  ? ({
      [K in T]: string;
    } & { [K in PropertyName]: () => void })
  : {
      T?: undefined;
      PropertyName?: undefined;
    };

    //Alternatively:
    
    type InterConnectedProps2<
  T extends string | undefined,
  PropertyName extends string | undefined,
> = T extends string
  ? ({
      [K in T]: string;
      //The differnce lies here: "as string"
    } & { [K in PropertyName as string]: () => void })
  : {
      T?: undefined;
      PropertyName?: undefined;
    };



type Props = {
  title: string;
  subtitle?: string;
}& InterConnectedProps2<'button1', 'handlerBtn1'> & InterConnectedProps2<'button2', 'handlerBtn2'>;

// Props should be either:
//  {title: string; subtitle?:string; button1: string; handlerBtn1 : () => void}
// or {title: string; subtitle?:string; button1: undefined; handlerBtn1 : undefined}
//Anyway, there should be no handlerBtn1 if there is no button1

But this doesn't work, not only because I am not verifying that PropertyName is a string (how do I introduce multiple conditions?). Even if write { [K in PropertyName as string]: () => void }, the resulting type seem not to include the second parameter (PropertyName):

type Button = InterConnectedProps<'button1', 'btn1Handler'> is just {button1: string}.

Where am I going wrong, or is there another alternative?

Thanks!


Solution

  • It looks like you want to define a utility type AllOrNone<T> where T is an object type with all required properties, whose resulting type will accept either an object of type T, or an object with none of the properties from T.

    Here's one possible way to define it:

    type AllOrNone<T extends object> =
      T | { [P in keyof T]?: never }
    

    This is a union type to capture the "either-or" part. One union member is T, and the other is { [P in keyof T]?: never }, which is as close as we can get to "an object with none of the properties of T" in TypeScript.

    Let's examine { [P in keyof T]?: never } more. It's a mapped type where each property key from T is made optional and the value type is the impossible never type. So if T is {a: 0, b: 1, c: 2}, then {[P in keyof T]?: never} is {a?: never, b?: never, c?: never}. TypeScript doesn't really have a way to prohibit a property. But an optional property of type never is close: you can't supply a value of type never, so then the only "option" you have is to leave the property out entirely (or to set it to undefined depending on your compiler settings).


    Let's test it out:

    type Z = AllOrNone<{ a: 0, b: 1, c: 2 }>;
    /* type Z = { a: 0, b: 1, c: 2 } | 
         { a?: never, b?: never, c?: never } */
    
    let z: Z;
    z = {a: 0, b: 1, c: 2}; // okay
    z = {a: 0, b: 1}; // error!
    z = {a: 0}; // error!
    z = {}; // okay
    

    This makes sense; Z is either {a: 0, b: 1, c: 2}, or it's an object without any of those properties.


    Now we can define Props by using AllOrNone twice:

    type Props = { title: string; subtitle?: string; } &
      AllOrNone<{ button1: string, handlerBtn1(): void }> &
      AllOrNone<{ button2: string, handlerBtn2(): void }>;
    
    let p: Props;
    p = { title: "" };
    p = { title: "", button1: "", handlerBtn1() { } };
    p = { title: "", button2: "", handlerBtn2() { } };
    p = { title: "", button1: "", handlerBtn2() { } }; // error
    

    Looks good!

    Playground link to code