0

When I have a type property that requires another property in the type to be a specific value, my types become overly complex. I'd like to write a utility type that abstracts the logic.

Example. Say I write a type that relates to a Text (React) component I'm building:

type Props = {
  children: React.ReactNode; // Just a React type, not pertinent to the question
  as: "p" | "span" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; // The html tag for the text
  truncate?: boolean; // Whether to truncate the text when it is long
  expanded?: boolean; // The truncated text is expanded when this is true
  bigHeading?: boolean; // When `as` is `"h1" | "h2" | "h3" | "h4" | "h5" | "h6"`, this property makes a normal heading extra large.
}

expanded? doesn’t do anything unless truncate? is true, and bigHeading? doesn’t do anything unless as is "h1" | "h2" | "h3" | "h4" | "h5" | "h6". So, I want the Props type to disallow expanded and bigHeading unless those conditions are met.

The way I go about it is this:

type BaseProps = {
  children: React.ReactNode;
};

type NoTruncateProps = BaseProps & {
  as: "p" | "span";
  truncate?: false;
};

type TruncateProps = BaseProps & {
  as: "p" | "span";
  truncate: true;
  expanded?: boolean;
};

type NoTruncateHeadingProps = BaseProps & {
  as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
  bigHeading?: boolean;
  truncate?: false;
};

type TruncateHeadingProps = BaseProps & {
  as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
  bigHeading?: boolean;
  truncate: true;
  expanded?: boolean;

};

type Props = NoTruncateProps | TruncateProps | NoTruncateHeadingProps | TruncateHeadingProps

This is obviously overly complex. How would I simplify and abstract this logic away for re-usability?

Related: TypeScript utility type for conditional props (based on entered value of other properties in the type)

Stephen Koo
  • 447
  • 1
  • 5
  • 10

1 Answers1

2

The logic in this type is pretty complex so either way you put it the type is going to be pretty complex. The approach I would use is to break down each piece of logic in a separate type and then intersect them.

For truncate and expanded we would get:

  type Truncatable = {
    truncate: true;
    expanded?: boolean
  } | {
    truncate?: false;
  }

For as and bigHeading we would get:

type HeadingOrText = {
    as: "p" | "span"
} | {
    as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
    bigHeading?: boolean;
}

We can then intersect them to get the final union we want. There is one complication though with excess property checks that is outlined here and we will use the solution presented there. We can also use the Id type to flatten out the props for nicer errors and tool-tips:

type UnionKeys<T> = T extends any ? keyof T : never;
type StrictUnionHelper<T, TAll> = T extends any ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, never>> : never;
type StrictUnion<T> = StrictUnionHelper<T, T>

type Id<T> = {} & { [P in keyof T]: T[P] }

type Truncateable = {
    truncate: true;
    expanded?: boolean
} | {
    truncate?: false;
}
type HeadingOrText = {
    as: "p" | "span"
} | {
    as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
    bigHeading?: boolean;
}

type WithChildren = {
    children: React.ReactNode;
}
type Props = Id<StrictUnion<HeadingOrText & WithChildren & Truncateable>>

let h1: Props = {
    as: "h1",
    bigHeading: true,
    truncate: true,
    expanded: false,
    children: []
}


let hErr: Props = {
    as: "h1",
    bigHeading: true,
    truncate: false,
    expanded: false, // causes error
    children: []
}



let p: Props = { // ok
    as: "p",
    children: []
}

let pErr: Props = {
    as: "p",
    children: [],
    bigHeading: true // causes error
}

While not necessarily less code I think this solution composes well and allows the addition of other mixin-like types to the union.

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357