When creating new types or interfaces in your projects, you may come up with crazy solutions to solve a problem to ensure property values that actually can be handled at the type level of your code.
Drafting
Just recently, I was creating my personal site and experimenting with new things and ideas, and one of them was to have a mutable type to use as the propType for a component that was responsible for handling gradient headers on a page.
The main idea is that you can use custom colors to set your left and right edges, but also, have the option to define a custom linear gradient if you like. The catch is, they can't co-exist at the same time.
In our drafted idea, our type would be something like this:
type GradientTitle = {
name: string;
leftColor?: string;
rightColor?: string;
customColor?: string;
}
This way, we can set our gradient with custom values, like so (using pug πΆ):
...
GradientTitle(
name="My Awesome Name"
leftColor="#a163f1"
rightColor="#3498ea"
)
...
If we want to use a custom gradient, we could do it, like so:
GradientTitle(
name="My Awesome Name"
customColor="linear-gradient(45deg,#a163f1,#6363f1 22%,#3498ea 40%,#40dfa3 67%, rgba(64, 223, 163, 0));"
)
The problem here is that there is nothing that blocks us from having the two options at the same time. Moreover, what should happen if we have both options set?
// Gradient hell
GradientTitle(
name="My Awesome Name"
leftColor="#a163f1"
rightColor="#3498ea"
customColor="linear-gradient(45deg,#a163f1,#6363f1 22%,#3498ea 40%,#40dfa3 67%, rgba(64, 223, 163, 0));"
)
Ideias
First off, having everything optional does not seem reasonable. What if I say I have this for "leftColor" and nothing, for "rightColor"? That would have us creating business logic inside the component just to handle what properties have values or not and then deciding if we should use leftColor and rightColor, or customColor.
We would need to create a function just to validate our props to decide what to use, something like this:
const isCustomColor: boolean = (props: GradientTitle) =>
typeof props.leftColor !== 'string' && typeof props.rightColor !== 'string' && typeof props.customColor === 'string';
...
if (isCustomColor(props)) {
// Use props.customColor
} else {
// Use props.leftColor and props.rightColor
}
That is one way of resolving this problem at runtime. However, wouldn't it be nicer to solve this issue at the type level? I mean, let's make TS do this job for us, shall we?
In our case, we have our GradientTitle
type already defined:
// remember
type GradientTitle = {
name: string;
leftColor?: string;
rightColor?: string;
customColor?: string;
}
First off, let's decide what is mandatory for this type. The name
seems to be the key value for this component to exist, so let's have it as our base type:
type GradientProp = {
name: string;
}
Nice! Now, we should handle how we should address the colors, in a way we can have the option of using left and right or using a custom value, but never
both. See what I did there? never
? π
The NEVER type
Never in TypeScript deserves a whole new topic, but essentially, it's a way to define a type that should never be present. Simply put, JavaScript does not have a primitive value of type "never". We have string
, number
, bigint
, boolean
, symbol
, null
, and undefined
. So, when it comes to the type level when a property of type never exists in the scope, the TS throws an error informing us that "this shouldn't be here".
Phew! That's a lot of information in a short paragraph. With that said, let's use this never
to our advantage.
Back to the example
There are two options available in our scenario: one is having both left and right colors defined but never having customColor, and the other is to have the customColor defined but never have left and right colors set. That would leave us with this:
type GradientProps = {
name: string;
}
type LeftAndRightColors = {
leftColor: string;
rightColor: string;
customColor?: never;
}
type CustomColor = {
customColor: string;
rightColor?: never;
leftColor?: never;
}
You might be asking, "Why are there optional values in these types?". I got you! Let's think about that.
In the type LeftAndRightColors
, we should set the left and right colors. If customColor
wasn't optional, the TS would yell at us saying that we should have a value of type 'never' to this property. However, if you assign anything to customColor
in this object, you'll get a type error.
type LeftAndRightColors = {
leftColor: string;
rightColor: string;
customColor: never;
}
const LRColors: LeftAndRightColors = {
// ^error: Property 'customColor' is missing in type.
leftColor: "#000000",
rightColor: "#111111"
}
Now, if we have it as an optional property, the TS would not yell at us when missing. Moreover, as we have its type defined as never
, if we set anything to it we also get an error.
type LeftAndRightColors = {
leftColor: string;
rightColor: string;
customColor?: never;
}
const LRColors: LeftAndRightColors = {
leftColor: "#000000",
rightColor: "#111111",
customColor: "anything"
// ^erro: Types of property 'customColor' are incompatible.
}
Now, we have two types defining the two scenarios we may encounter. However, we can still improve this.
Separation of concerns
It does not seem reasonable to have these properties repeating themselves within these two types, LeftAndRightColor
and CustomColor
. We should have their values defined as normal and then have new types to negate the property value when necessary. Like so:
type LeftAndRightColors = {
leftColor: string;
rightColor: string;
}
type CustomColor = {
customColor: string;
}
type CustomNotAllowed = {
customColor?: never;
}
type LRNotAllowed = {
leftColor?: never;
rightColor?: never;
}
With that, we can use our intersection operator to define the negatives in our types, just by intersecting them, like so:
type LeftAndRightColors = {
leftColor: string;
rightColor: string;
} & CustomNotAllowed;
type CustomColor = {
customColor: string;
} & LRNotAllowed;
Using Type Operations
In TypeScript we have what we call Union Types
and Intersection Types
. Union Types are types that are combined using the |
symbol and Intersection Types are types that are combined with the &
symbol. The idea is that you can create a new type by combining some existing types, making their type rules co-exist.
When we intersect two types, it creates a new type combining all its property rules as a result. In addition, we can easily understand whats going on by renaming our boundaries.
type GradientProps = {
name: string;
}
type LRNotAllowed = {
leftColor?: never;
rightColor?: never;
}
type CustomNotAllowed = {
customColor?: never;
}
type LeftAndRightColors = {
leftColor: string;
rightColor: string;
} & CustomNotAllowed;
type CustomColor = {
customColor: string;
} & LRNotAllowed;
export type GradientTitle = GradientProps & (LeftAndRightColors | CustomColor);
Finally, we have TypeScript handling at the type level of what properties are allowed to exist based on the values. Also, we can easily understand what each type is responsible for and combine its effects using type operators.
Conclusion
The never
type in TypeScript can be very helpful when defining boundaries on property values. Combined with operators, we can create very powerful types that better handle specific rules of complex components.
Top comments (0)