Portrait of me in my natural habitat

Reducing Repetition while Maintaining Type-Saftey

Posted 2023-07-24

Over the last year I’ve been developing a design system / component library to use in my side-projects. At the base is a component called Block, upon which most of the styles get applied. The interface of Block focuses on configuration of atomic properties at the React component prop level. So for example, setting the padding and margin of a button is done like this:

<Block padding="0.25" marginRight="1" tagName="button">
  Hello World
</Block>

I wanted the implementation to have a very strict, type-safe interface, but the repetitive nature of it was starting to weigh on me.

interface BlockProps {
	padding: string;
	paddingTop: string;
	paddingBottom: string;
	paddingLeft: string;
	paddingRight: string;
	margin: string;
	marginTop: string;
	marginBottom: string;
	marginLeft: string;
	marginRight: string;
}

Maintaining this was time-consuming, and updating did not scale particularly well. Plus margin and padding props were basically the same except their name. I resolved to abuse every feature TypeScript afforded me to do this. At the center of it all is this type:

export type Mapping<T extends string, R = string> = {
    [K in T]: R;
};

This is a mapped type. It allows me to define a type that has the keys R, and the type T for each of those keys (It was also brought to my attention that Mapping is basically just a backwards Record, so use that instead if you intend to walk this path). For padding, to have each directional prop and an overall padding, I’d define something like this:

type PaddingProps = Mapping<string, 'padding' | 'paddingTop' | 'paddingBottom' | 'paddingLeft' | 'paddingRight';

which is equivalent to

interface BlockProps {
	padding: string;
	paddingTop: string;
	paddingBottom: string;
	paddingLeft: string;
	paddingRight: string;
}

Looking at this, I wondered if I could decouple padding from Top, Bottom, Left, and Right, especially as I was adding more props that had directions variants. To do this, I created a generic type consisting of a union type of the type itself, and template literal types for each direction. Then I used that in the Mapping instead of an explicit union type.

type DirectionOptions<T extends string> =
    | `${T}Top`
    | `${T}Bottom`
    | `${T}Left`
    | `${T}Right`
    | `${T}`;

type PaddingProps = Mapping<string, DirectionOptions<'padding'>>;

This also lets us easily define the directional props for margin.

type MarginProps = Mapping<string, DirectionOptions<'margin'>>;

Finally, I wanted to make it so that you could set the hover styles for properties. The way I wanted them to manifest was to have an additional field for each already existing field that appended the text Hover. paddingTop would have an associated paddingTopHover, paddingBottom and paddingBottomHover, etc. This combines the technique used for Mapping with the keyof operator. For each key in the supplied type, a new property is added using a template literal to expand its name to include Hover.

export type Hoverable<Type> = {
    [Property in keyof Type as `${string & Property}Hover`]: Type[Property];
};

Applying Hoverable to BasePaddingProps below produces a type with the expected xxxHover, props:

interface BasePaddingProps {
	padding: string;
	paddingTop: string;
	paddingBottom: string;
	paddingLeft: string;
	paddingRight: string;
}

type PaddingProps = Hoverable<BasePaddingProps>;

interface PaddingProps { // equivalent to above ^^^
	padding: string;
	paddingTop: string;
	paddingBottom: string;
	paddingLeft: string;
	paddingRight: string;
	paddingHover: string;
	paddingTopHover: string;
	paddingBottomHover: string;
	paddingLeftHover: string;
	paddingRightHover: string;
}

Combining it with our previous properties, we can easily generate hoverable padding and margin types:

type PaddingProps = Hoverable<Mapping<string, DirectionOptions<'padding'>>>;
type MarginProps = Hoverable<Mapping<string, DirectionOptions<'margin'>>>;

This implementation is certainly a tradeoff. The interface it produces is really clean. It lets me move fast, and adding additions onto this Block infrastructure is simple. However, from an outsider’s perspective, as I found out sharing my journey piecemeal with my friends and peers, it seems kind of whack. There’s something about

[Property in keyof Type as`${string & Property}Hover`]: Type[Property];

that doesn’t quite roll off the tongue very well.