I am trying to enforce interdependence among properties in a general TypeScript type. The context is a reusable React component that receives props. If the component receives a text for a button, for example, then it must also receive a handler for the button; but if the component doesn't receive the text, then the handler prop should also be undefined.
This is my attempt:
type InterConnectedProps<
T extends string | undefined,
PropertyName extends string | undefined,
> = T extends string
? ({
[K in T]: string;
} & { [K in PropertyName]: () => void })
: {
T?: undefined;
PropertyName?: undefined;
};
//Alternatively:
type InterConnectedProps2<
T extends string | undefined,
PropertyName extends string | undefined,
> = T extends string
? ({
[K in T]: string;
//The differnce lies here: "as string"
} & { [K in PropertyName as string]: () => void })
: {
T?: undefined;
PropertyName?: undefined;
};
type Props = {
title: string;
subtitle?: string;
}& InterConnectedProps2<'button1', 'handlerBtn1'> & InterConnectedProps2<'button2', 'handlerBtn2'>;
// Props should be either:
// {title: string; subtitle?:string; button1: string; handlerBtn1 : () => void}
// or {title: string; subtitle?:string; button1: undefined; handlerBtn1 : undefined}
//Anyway, there should be no handlerBtn1 if there is no button1
But this doesn't work, not only because I am not verifying that PropertyName is a string (how do I introduce multiple conditions?). Even if write { [K in PropertyName as string]: () => void }, the resulting type seem not to include the second parameter (PropertyName):
type Button = InterConnectedProps<'button1', 'btn1Handler'> is just {button1: string}.
Where am I going wrong, or is there another alternative?
Thanks!
It looks like you want to define a utility type AllOrNone<T>
where T
is an object type with all required properties, whose resulting type will accept either an object of type T
, or an object with none of the properties from T
.
Here's one possible way to define it:
type AllOrNone<T extends object> =
T | { [P in keyof T]?: never }
This is a union type to capture the "either-or" part. One union member is T
, and the other is { [P in keyof T]?: never }
, which is as close as we can get to "an object with none of the properties of T
" in TypeScript.
Let's examine { [P in keyof T]?: never }
more. It's a mapped type where each property key from T
is made optional and the value type is the impossible never
type. So if T
is {a: 0, b: 1, c: 2}
, then {[P in keyof T]?: never}
is {a?: never, b?: never, c?: never}
. TypeScript doesn't really have a way to prohibit a property. But an optional property of type never
is close: you can't supply a value of type never
, so then the only "option" you have is to leave the property out entirely (or to set it to undefined
depending on your compiler settings).
Let's test it out:
type Z = AllOrNone<{ a: 0, b: 1, c: 2 }>;
/* type Z = { a: 0, b: 1, c: 2 } |
{ a?: never, b?: never, c?: never } */
let z: Z;
z = {a: 0, b: 1, c: 2}; // okay
z = {a: 0, b: 1}; // error!
z = {a: 0}; // error!
z = {}; // okay
This makes sense; Z
is either {a: 0, b: 1, c: 2}
, or it's an object without any of those properties.
Now we can define Props
by using AllOrNone
twice:
type Props = { title: string; subtitle?: string; } &
AllOrNone<{ button1: string, handlerBtn1(): void }> &
AllOrNone<{ button2: string, handlerBtn2(): void }>;
let p: Props;
p = { title: "" };
p = { title: "", button1: "", handlerBtn1() { } };
p = { title: "", button2: "", handlerBtn2() { } };
p = { title: "", button1: "", handlerBtn2() { } }; // error
Looks good!