27

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?

C.OG
  • 6,236
  • 3
  • 20
  • 38
  • 1
    Could you please elaborate at why none of the provided answers are acceptable? What you would like included, what shortcomings you see with my approach and @hero-wanders. 10x – Titian Cernicova-Dragomir Sep 07 '18 at 12:33

5 Answers5

17

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;
Hero Wanders
  • 3,237
  • 1
  • 10
  • 14
  • good idea, it might work, more verbose, maybe there is an even better way? –  Sep 01 '18 at 22:59
  • There is a very similar question and a quite powerful answer here: https://stackoverflow.com/a/48244432/10245948 – Hero Wanders Sep 01 '18 at 23:07
  • Deriving from the solution I linked in my previous comment, this might be an alternative: If you define `interface AllOpts { path: string | Array, paths: string | Array }` and use the helper `type OneOf }> = U[keyof U]` then you can write `export type Opts = OneOf;`. – Hero Wanders Sep 01 '18 at 23:23
  • 2
    Just note that because of how excess property checking works on union, given your definition, this is also a valid call: `foo({ path: "", paths: ""})` which I don't think the op wants. – Titian Cernicova-Dragomir Sep 10 '18 at 08:17
  • 5
    If you want - deviating from your original question, exactly one, path or paths, to be present, use type PathOpts = { path: StringOrArray, paths: never }; and type PathsOpts = { paths: StringOrArray, path: never }; Thank you, Titian, for pointing this out. – Hero Wanders Sep 10 '18 at 10:54
11

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.

Titian Cernicova-Dragomir
  • 230,986
  • 31
  • 415
  • 357
  • Great answer. Can you please describe what is going on there? For example, what is `TKey extends keyof T = keyof T`, `{ [P in TKey]-?:T[TKey] }`? – Buggy Sep 10 '18 at 08:16
  • 1
    @Buggy added an explanation, hope it's clear enough :) – Titian Cernicova-Dragomir Sep 10 '18 at 08:27
  • 1
    Very good solution. I like how you realized that not both properties may be passed. Unfortunately the op explicitly said "at least 1 is necessary". If he actually wanted exactly one, your answer is the right way to handle this generically. – Hero Wanders Sep 10 '18 at 10:47
  • 1
    @TitianCernicova-Dragomir Hello Titian! How can we do this if we want an arg that is type `{a:number, b:number} OR {x:number, y:number}`, but not both, and not partial of either. For example, if I write `{a:number, b:number} | {x:number, y:number}` then it accepts a value like `{a:1, b:2, y:3}`, but I want only things like `{a:1, b:2}` or `{x:1, y:2}` without overlap. – trusktr Jan 25 '19 at 03:43
  • @trusktr you are looking for a strict union, see here : https://stackoverflow.com/questions/52677576/typescript-discriminated-union-allows-invalid-state/52678379#52678379 – Titian Cernicova-Dragomir Jan 25 '19 at 06:49
  • 1
    @TitianCernicova-Dragomir Wow. Amazing. – trusktr Jan 25 '19 at 15:13
  • @TitianCernicova-Dragomir Aside from errors being a little more difficult to read (especially if a TS newcomer gets an error), it works perfectly! Seems like a good sort of thing for the standard library. – trusktr Jan 25 '19 at 15:15
  • @trusktr it was proposed for a library(Simply Typed) by someone https://github.com/andnp/SimplyTyped/issues/76#issue-396291343 – Titian Cernicova-Dragomir Jan 25 '19 at 15:20
  • @TitianCernicova-Dragomir is there a way to get this to work for all properties in an interface? – byrnedo Aug 01 '19 at 10:51
  • @byrnedo not sure what you mean. Maybe it's best you write it in another question to show code. – Titian Cernicova-Dragomir Aug 01 '19 at 10:54
  • @TitianCernicova-Dragomir ok, I made a question: see https://stackoverflow.com/questions/57307605/typescript-type-that-enforces-that-one-and-only-one-property-is-accepted-from-an – byrnedo Aug 01 '19 at 11:06
3

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 {}

MikingTheViking
  • 886
  • 6
  • 11
1
export type Opts={paths: string | Array<string>,  path?: undefined}  |
                 {paths?: undefined,  path: string | Array<string>}

I think it is easy to understand.

Yilmaz
  • 35,338
  • 10
  • 157
  • 202
0

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!

Claudio Cortese
  • 1,372
  • 2
  • 10
  • 21