I have this type or interface:
export interface CreateProjectAction {
type: typeof CREATE_PROJECT | typeof CREATE_PROJECT_ERROR
project?: {title:string, content:string}
err?: Error
}
project
and err
are optional.
I would like them to preserve their name props, so that they can be passed in or required as such in the type instance, but, to have one of them compulsorily, so that this would give an error as one of the either two optional project
or err
neeeds to be passed in.
function Foo(createAction: CreateProjectAction) {
console.log(createAction);
}
Foo({ type: 'CREATE_PROJECT_ERROR' } ); //ERROR, need to pass in either the project or the err prop
As said, as I would like to preserve the prop names because the optionalArg
name would no be very descriptive, so I discard the option:
export interface CreateProjectAction {
type: typeof CREATE_PROJECT | typeof CREATE_PROJECT_ERROR
optionalArg?: {title:string, content:string} | Error
}
With a bit of black magic, you can force "one and only one property defined" depending on the type of type
. First of all, we need to establish a relationship between members of the type
union and the required properties of CreateProjectAction
. The easiest way to do that is to create a lookup where keys are of type type
and values are keyof CreateProjectAction
, like:
{
"CREATE_PROJECT": "project",
"CREATE_PROJECT_ERROR" : "err"
}
Next, let's allow the lookup to be partial with this small helper utilizing key remapping feature from 4.1 (undefined
properties are filtered out from the lookup):
type OnlyDefined<T> = {
[ P in keyof T as T[P] extends undefined ? never : P ] : T[P]
};
Finally, the incantation:
type RequireIf<T extends { type: ActionType }, R extends { [ P in ActionType ] ?: keyof T }> = {
[ P in keyof OnlyDefined<R> as P extends T["type"] ? OnlyDefined<R>[P] & string : never ] : OnlyDefined<R>[P] extends keyof T ? Exclude<T[OnlyDefined<R>[P]], undefined> : never
} & Omit<T, OnlyDefined<R>[ keyof OnlyDefined<R> ] & string >
T
is constrained to { type: ActionType }
so as we are able to index T["type"]
OnlyDefined<R>[P]
gives us the correct property of T
that should be presentExclude<T[OnlyDefined<R>[P]], undefined>
forces the property to be defined (as a consequence of allowing partial lookups, the type of property is <type> | undefined
)Testing confirms everything is working as expected:
type CreateProjectAction<T extends ActionType> = RequireIf<{
type: T
project?: {title:string, content:string}
err?: Error
}, {
"CREATE_PROJECT": "project",
"CREATE_PROJECT_ERROR" : "err"
}>
const project: CreateProjectAction<"CREATE_PROJECT"> = { type: "CREATE_PROJECT", project: { content: "Yay!", title: "Vote" } };
const error: CreateProjectAction<"CREATE_PROJECT_ERROR"> = { type: "CREATE_PROJECT_ERROR", err: new RangeError("Nay!") };
const invalid: CreateProjectAction<"CREATE_PROJECT"> = { type: "CREATE_PROJECT", err: new Error("Nope") }; //'err' does not exist in type