Say I have this type:
export interface Opts {
paths?: string | Array,
path?: string | Array
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 {}
You are basically looking for an exclusive union type.
It has been already proposed but unfortunately, in the end, it was declined.
I found the proposed solutions here not to my liking, mostly because I'm not a fan of fancy and complex types.
Have you tried with function overloading?
I was in a similar situation and for me, this was the solution.
interface Option1 {
paths: string | string[];
}
interface Option2 {
path: string | string[];
}
function foo(o: Option1): void;
function foo(o: Option2): void;
function foo(o: any): any {}
foo({ path: './' });
foo({ paths: '../' });
// The folling two lines gives an error: No overload matches this call.
foo({ paths: './', path: '../' });
foo({})
With arrow function the same code as above would instead be:
interface Option1 {
paths: string | string[];
}
interface Option2 {
path: string | string[];
}
interface fooOverload {
(o: Option1): void;
(o: Option2): void;
}
const foo: fooOverload = (o: any) => {};
foo({ path: '2' });
foo({ paths: '2' });
// The following two lines gives an error: No overload matches this call.
foo({ paths: '', path: 'so' });
foo({});
Hope this helps you out!