I need to make a component of tabs to able show tabs either in icon mode or plain simple text. Instead passing in variant
, I thought maybe better let the Typescript to check whether one of the icons is having attribute iconName
, and if so - enforce all the other items to include it as well (by throwing compiler error). If all objects has iconName
, or all objects don't have it, it's valid.
Here an example and my attempt, to make it clearer:
type ObjectWithIcon<T> = T extends { iconName: string } ? T & { iconName: string } : T;
interface MyObject {
iconName?: string;
label: string;
}
// This will throw error on second object because of missing iconName
const arrayOfObjects: ObjectWithIcon<MyObject>[] = [
{ iconName: "box", label: "storage" },
{ label: "memory" }, // Error: Property 'iconName' is missing, currently NOT throwing an error
];
// This will not give any error because both has iconName
const arrayOfObjects2: ObjectWithIcon<MyObject>[] = [
{ iconName: "box", label: "storage" },
{ iconName: "circle", label: "memory" },
];
// This will not give any error because no object using iconName
const arrayOfObjects3: ObjectWithIcon<MyObject>[] = [
{ label: "storage" },
{ label: "memory" },
];
No matter how you define ObjectWithIcon<T>
, you don't want to use the type ObjectWithIcon<MyObject>[]
, because that's just a single plain array type. Such an array type can't possibly care whether all its elements do or do not have some particular property, the only thing it cares about is whether each element conforms to ObjectWithIcon<MyObject>
, independently of every other element in the array.
Instead I think you want to use a union of array types, where you say "this is either an array of MyObject
s with an iconName
, or an array of MyObject
's without an iconName
.
That is, you want a type equivalent to
type ObjectsAllWithOrAllWithoutIcon = {
iconName: string; // <-- a required property
label: string;
}[] | {
iconName?: never; // <-- a prohibited property
label: string;
}[]
TypeScript doesn't really directly encode "without a given property", but you can write "has an optional property of the impossible never
type", which is pretty close.
Anyway, to that end, let's write some utility types to represent "with", "without" and the "either-or" array:
type With<T, K extends keyof T> = T & Required<Pick<T, K>>;
type Without<T, K extends keyof T> = T & Partial<Record<K, never>>;
type ArrayAllWithOrAllWithout<T, K extends keyof T> =
With<T, K>[] | Without<T, K>[]
The With<T, K>
type says you've got something which is of type T
, and also (via intersection), something where the K
property is required (via the Pick
utility type to grab the part with key K
, and via the Required
utility type to say that property is required and not optional).
The Without<T, K>
type says you've got something which is of type T
, and also something where the K
property is optional of type never
(via the Partial
utility type to say that the property is optional, and via the Record
utility type to say that we're talking about something with key K
and value never
).
And finally,
ArrayAllWithOrAllWithout<T, K>says you've got something which is either an array of
With<T, K>, or an array of
Without<T, K>`.
You can verify that
interface MyObject {
iconName?: string;
label: string;
}
type ObjectsAllWithOrAllWithoutIcon =
ArrayAllWithOrAllWithout<MyObject, "iconName">;
is equivalent to the manually written out ObjectsAllWithOrAllWithoutIcon
from above. And that your examples therefore work as intended:
const arrayOfObjects: ObjectsAllWithOrAllWithoutIcon = [ // error!
// ~~~~~~~~~~~~~~
// Property 'iconName' is missing in type '{ label: string; }'
// but required in type 'Required<Pick<MyObject, "iconName">>'.
{ iconName: "box", label: "storage" },
{ label: "memory" },
];
const arrayOfObjects2: ObjectsAllWithOrAllWithoutIcon = [
{ iconName: "box", label: "storage" },
{ iconName: "circle", label: "memory" },
]; // okay
const arrayOfObjects3: ObjectsAllWithOrAllWithoutIcon = [
{ label: "storage" },
{ label: "memory" },
]; // okay