6

I frequently need to define a type object where a property key is only accepted if another property/properties of the type are a certain value.

A simple example (in the context of React but should be applicable in any situation) is I need a type Button object which accepts the following properties:

type Button = {
  size: 'small' | 'large';
  appearance: 'solid' | 'outline' | 'minimal';
  isDisabled?: boolean;
  hasFancyOutline?: boolean;
}

Now, I actually don't want the type to accept hasFancyOutline if appearance is not outline and isDisabled is false.

The correct way is to do this is:

type SharedButtonProps = {
  size: 'small' | 'large';
}

type NonOutlineButtonProps = SharedButtonProps & {
  appearance: solid' | 'minimal';
  isDisabled?: boolean;
}

type OutlineButtonProps = SharedButtonProps & {
  appearance: 'outline';
  isDisabled: false;
  hasFancyOutline?: boolean;
}

type Button = NonOutlineButtonProps | OutlineButtonProps

I want to write a shorthand utility type called ConditionalProps that intelligently does this for me. Something like this:

type Button = ConditionalProps<
  {
    size: 'small' | 'large';
    appearance: 'solid' | 'outline' | 'minimal';
    isDisabled?: boolean;
  },
  {
    appearance: 'outline';
    isDisabled: false;
    hasFancyOutline?: boolean;
  }
>

I'm thinking in pseudo-code, it'd work something like this:

type ConditionalProps<BaseProps, ConditionalProps> = {
  // 1. Find keys with the same name in BaseProps & ConditionalProps. Optional and non-optional types such as `isDisabled?` and `isDisabled` need to be matched.

  type MatchingProps = Match<BaseProps, ConditionalProps> // { appearance: 'solid' | 'outline' | 'minimal', isDisabled?: boolean }

  type SharedProps = Omit<BaseProps, MatchingProps> // { size: 'small' | 'large' }

  // 2. Find what's the values of the props if they don't match the condition, e.g. 'appearance' would be either 'solid' or 'minimal'

  type FailConditionProps = RemainingValues<MatchingProps, ConditionalProps> // { appearance: 'solid' | 'minimal'; isDisabled?: boolean; }

  // 3. Assemble

  type FailConditionPlusSharedProps = SharedProps & FailConditionProps

  type PassConditionPlusSharedProps = SharedProps & ConditionalProps

  return FailConditionPlusSharedProps | PassConditionPlusSharedProps
}

EDIT

Titian's answer below is the exact solution for this. But I'm wondering if there's a way to rewrite ConditionalProps to be even better.

I find myself writing a lot of types that are conditional on values it is given.

So for example,

  type Button = {
    size: 'small' | 'large';
    isReallyBig?: boolean;
    appearance: 'solid' | 'outline' | 'minimal';
    hasFancyOutline?: boolean;
    outlineBackgroundColor: string;
    isDisabled?: boolean;
    isLoading?: boolean;
  }

Say I want to do:

  1. isReallyBig? is accepted only if size = 'large'
  2. hasFancyOutline? & outlineBackgroundColor is accepted only if appearance = ‘outline’ & isDisabled = false
  3. isLoading can be true only if isDisabled = true.

If I wanted to re-write ConditionalProps to cleanly define this type, how would I do so? I was thinking implementation would be something like:

  type Button = ConditionalProps<
    {
      size: 'small' | 'large';
      appearance: 'solid' | 'outline' | 'minimal';
      outlineBackgroundColor: string;
      isDisabled?: boolean;
    },
    [
      [
        { size: 'large' },
        { isReallyBig?: boolean }
      ], [
        { appearance: 'outline', isDisabled: false },
        { hasFancyOutline?: boolean }
      ], [
        { isDisabled: true },
        { isLoading?: boolean }
      ]
    ]
  >

Is something like this achievable, or is there a better way of dealing with this scenario?

Stephen Koo
  • 447
  • 1
  • 5
  • 10

1 Answers1

4

While implementing this, the problem I had was that it was not obvious why only appearance should have it's values removed from the common case. isDisabled is a union of true | false so, removing all values from the common case would result in false being removed from isDisabled on the default case. This is probably not the desired behavior.

If we add a property to spell out what the discriminant is, we can build the type you want

type Button = ConditionalProps<
  {
    size: 'small' | 'large';
    appearance: 'solid' | 'outline' | 'minimal';
    isDisabled?: boolean;
  }, 'appearance',
  {
    appearance: 'outline';
    isDisabled: false;
    hasFancyOutline?: boolean;
  }
>


type RemoveCommonValues<T, TOmit> = {
  [P in keyof T]: TOmit extends Record<P, infer U> ? Exclude<T[P], U> : T[P]
}

type Omit<T, K extends PropertyKey> = Pick<T, Exclude<keyof T, K>> // not needed in 3.5
type Id<T> = {} & { [P in keyof T]: T[P] } // flatens out the types to make them more readable can be removed
type ConditionalProps<T, TKey extends keyof TCase, TCase extends Partial<T>> =
  Id<Omit<T, keyof TCase> & TCase>
  | Id<RemoveCommonValues<T, Pick<TCase, TKey>>>

RemoveCommonValues goes through the common properties, and if they are defined on TOmit removed the values that are defined there from the common values. To get the properties defined by the TOmit case, we need to get the common properties (Omit<T, keyof TOmit>) and intersect them with TOmit.

Testing it out:

type Button = ConditionalProps<
  {
    size: 'small' | 'large';
    appearance: 'solid' | 'outline' | 'minimal';
    isDisabled?: boolean;
  }, 'appearance',
  {
    appearance: 'outline';
    isDisabled: false;
    hasFancyOutline?: boolean;
  }
>
// same as 
type Button = {
    size: "small" | "large";
    appearance: "outline";
    isDisabled: false;
    hasFancyOutline?: boolean | undefined;
} | {
    size: "small" | "large";
    appearance: "solid" | "minimal";
    isDisabled?: boolean | undefined;
}

We can pass in multiple cases:

type Button = ConditionalProps<
{
  size: 'small' | 'large';
  appearance: 'solid' | 'outline' | 'minimal';
  isDisabled?: boolean;
}, 'appearance' ,{
  appearance: 'outline';
  isDisabled: false;
  hasFancyOutline?: boolean;
} | {
  appearance: 'minimal';
  isDisabled: false;
  useReadableFont?: boolean;
}
>
// same as
type Button = {
    size: "small" | "large";
    appearance: "outline";
    isDisabled: false;
    hasFancyOutline?: boolean | undefined;
} | {
    size: "small" | "large";
    appearance: "minimal";
    isDisabled: false;
    useReadableFont?: boolean | undefined;
} | {
    size: "small" | "large";
    appearance: "solid";
    isDisabled?: boolean | undefined;
}

If we want to have more discriminant keys its not clear how that would work though as this does not compose well. You can pass in multiple keys, but you have to make sure the cases you pass in cover all possible combinations as any values will all be removed from the result:

type Button = ConditionalProps<
{
  size: 'small' | 'large';
  appearance: 'solid' | 'outline' | 'minimal';
  isDisabled?: boolean;
}, 'appearance' | 'size' ,{
  appearance: 'outline';
  size: 'small'
  isDisabled: false;
  hasFancyOutline?: boolean;
} | {
  appearance: 'minimal';
  size: 'small'
  isDisabled: false;
  hasFancyOutline?: boolean;
}
>
// same as
type Button = {
    appearance: "outline";
    size: "small";
    isDisabled: false;
    hasFancyOutline?: boolean | undefined;
} | {
    appearance: "minimal";
    size: "small";
    isDisabled: false;
    hasFancyOutline?: boolean | undefined;
} | {
    size: "large";
    appearance: "solid";
    isDisabled?: boolean | undefined;
}

No minimal large button is possible.

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • Hi Titian, that's amazing! Thanks so much for that comprehensive answer. If I'd like to make `ConditionalProps` even more flexible & re-usable, what would you suggest? I'd ideally like to make it an easy way to add multiple conditional props based on various values. So building on the example above: ``` type Button = { size: 'small' | 'large'; appearance: 'solid' | 'outline' | 'minimal'; isDisabled?: boolean; hasFancyOutline?: boolean; isAllCaps?: boolean; loading?: boolean; loadingText?: string; } ``` – Stephen Koo Jun 04 '19 at 09:19
  • 1
    @StephenKoo you are welcomed. Let me know if you find any issues I can help with. – Titian Cernicova-Dragomir Jun 04 '19 at 09:20
  • Sorry, I botched that reply - may I PM you to ask a bit more about your answer? I'm trying to get better at advanced TS types. – Stephen Koo Jun 04 '19 at 09:26
  • 1
    @StephenKoo you can PM me on gitter, I prefer it for IM :) Not sure about how to make it more generic though – Titian Cernicova-Dragomir Jun 04 '19 at 09:59