Say I have this type:
export interface Opts {
paths?: string | Array<string>,
path?: string | Array<string>
}
I want to tell the user that they must pass either paths or path, but it is not necessary to pass both. Right now the problem is that this compiles:
export const foo = (o: Opts) => {};
foo({});
does anyone know to allow for 2 or more optional but at least 1 is necessary parameters with TS?
You may use
export type Opts = { path: string | Array<string> } | { paths: string | Array<string> }
To increase readability you may write:
type StringOrArray = string | Array<string>;
type PathOpts = { path : StringOrArray };
type PathsOpts = { paths: StringOrArray };
export type Opts = PathOpts | PathsOpts;
If you already have that interface defined and want to avoid duplicating the declarations, an option could be to create a conditional type that takes a type and returns a union with each type in the union containing one field (as well as a record of never
values for any other fields to dissalow any extra fields to be specified)
export interface Opts {
paths?: string | Array<string>,
path?: string | Array<string>
}
type EitherField<T, TKey extends keyof T = keyof T> =
TKey extends keyof T ? { [P in TKey]-?:T[TKey] } & Partial<Record<Exclude<keyof T, TKey>, never>>: never
export const foo = (o: EitherField<Opts>) => {};
foo({ path : '' });
foo({ paths: '' });
foo({ path : '', paths:'' }); // error
foo({}) // error
Edit
A few details on the type magic used here. We will use the distributive property of conditional types to in effect iterate over all keys of the T
type. The distributive property needs an extra type parameter to work and we introduce TKey
for this purpose but we also provide a default of all keys since we want to take all keys of type T
.
So what we will do is actually take each key of the original type and create a new mapped type containing just that key. The result will be a union of all the mapped types that contain a single key. The mapped type will remove the optionality of the property (the -?
, described here) and the property will be of the same type as the original property in T
(T[TKey]
).
The last part that needs explaining is Partial<Record<Exclude<keyof T, TKey>, never>>
. Because of how excess property checks on object literals work we can specify any field of the union in an object key assigned to it. That is for a union such as { path: string | Array<string> } | { paths: string | Array<string> }
we can assign this object literal { path: "", paths: ""}
which is unfortunate. The solution is to require that if any other properties of T
(other then TKey
so we get Exclude<keyof T, TKey>
) are present in the object literal for any given union member they should be of type never
(so we get Record<Exclude<keyof T, TKey>, never>>
). But we don't want to have to explicitly specify never
for all members so that is why we Partial
the previous record.
This works.
It accepts a generic type T
, in your case a string
.
The generic type OneOrMore
defines either 1 of T
or an array of T
.
Your generic input object type Opts
is either an object with either a key path
of OneOrMore<T>
, or a key paths
of OneOrMore<T>
. Although not really necessary, I made it explicit with that the only other option is never acceptable.
type OneOrMore<T> = T | T[];
export type Opts<T> = { path: OneOrMore<T> } | { paths: OneOrMore<T> } | never;
export const foo = (o: Opts<string>) => {};
foo({});
There is an error with {}