Skip to content

Commit

Permalink
narrow value types based on choices (#29)
Browse files Browse the repository at this point in the history
  • Loading branch information
edwardfoyle authored Feb 26, 2023
1 parent 3fad90f commit 4324f97
Show file tree
Hide file tree
Showing 5 changed files with 239 additions and 37 deletions.
78 changes: 41 additions & 37 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,35 +29,39 @@ type InferVariadic<S extends string, ArgT> =
? ArgT[]
: ArgT;

type InferArgumentType<Value extends string, DefaultT, CoerceT> =
type InferArgumentType<Value extends string, DefaultT, CoerceT, ChoicesT> =
[CoerceT] extends [undefined]
? InferVariadic<Value, string> | DefaultT
: CoerceT | DefaultT;
? InferVariadic<Value, [ChoicesT] extends [undefined] ? string : ChoicesT> | DefaultT
: ([ChoicesT] extends [undefined]
? CoerceT | DefaultT
: CoerceT | DefaultT | ChoicesT
)


// Special handling for optional variadic argument, won't be undefined as implementation returns [].
type InferArgumentOptionalType<Value extends string, DefaultT, CoerceT> =
type InferArgumentOptionalType<Value extends string, DefaultT, CoerceT, ChoicesT> =
Value extends `${string}...`
? InferArgumentType<Value, [DefaultT] extends [undefined] ? never : DefaultT, CoerceT>
: InferArgumentType<Value, DefaultT, CoerceT>
? InferArgumentType<Value, [DefaultT] extends [undefined] ? never : DefaultT, CoerceT, ChoicesT>
: InferArgumentType<Value, DefaultT, CoerceT, ChoicesT>

// ArgRequired comes from .argRequired()/.argOptional(), and ArgRequiredFromUsage is implied by usage <required>/[optional]
type ResolveRequired<ArgRequired extends boolean|undefined, ArgRequiredFromUsage extends boolean> =
ArgRequired extends undefined
? ArgRequiredFromUsage
: ArgRequired;

type InferArgumentTypeResolvedRequired<Value extends string, DefaultT, CoerceT, ArgRequired extends boolean> =
type InferArgumentTypeResolvedRequired<Value extends string, DefaultT, CoerceT, ArgRequired extends boolean, ChoicesT> =
ArgRequired extends true
? InferArgumentType<Value, never, CoerceT>
: InferArgumentOptionalType<Value, DefaultT, CoerceT>;
? InferArgumentType<Value, never, CoerceT, ChoicesT>
: InferArgumentOptionalType<Value, DefaultT, CoerceT, ChoicesT>;

// Resolve whether argument required, and strip []/<> from around value.
type InferArgument<S extends string, DefaultT = undefined, CoerceT = undefined, ArgRequired extends boolean|undefined = undefined> =
type InferArgument<S extends string, DefaultT = undefined, CoerceT = undefined, ArgRequired extends boolean|undefined = undefined, ChoicesT = undefined> =
S extends `<${infer Value}>`
? InferArgumentTypeResolvedRequired<Value, DefaultT, CoerceT, ResolveRequired<ArgRequired, true>>
? InferArgumentTypeResolvedRequired<Value, DefaultT, CoerceT, ResolveRequired<ArgRequired, true>, ChoicesT>
: S extends `[${infer Value}]`
? InferArgumentTypeResolvedRequired<Value, DefaultT, CoerceT, ResolveRequired<ArgRequired, false>>
: InferArgumentTypeResolvedRequired<S, DefaultT, CoerceT, ResolveRequired<ArgRequired, true>>; // the implementation fallback is treat as <required>
? InferArgumentTypeResolvedRequired<Value, DefaultT, CoerceT, ResolveRequired<ArgRequired, false>, ChoicesT>
: InferArgumentTypeResolvedRequired<S, DefaultT, CoerceT, ResolveRequired<ArgRequired, true>, ChoicesT>; // the implementation fallback is treat as <required>

type InferArguments<S extends string> =
S extends `${infer First} ${infer Rest}`
Expand Down Expand Up @@ -145,23 +149,23 @@ type InferOptionsNegateCombo<Options, Flag extends string, Name extends string,

// Recalc values taking into account negated option.
// Fill in appropriate PresetT value if undefined.
type InferOptionTypes<Options, Flag extends string, Value extends string, ValueT, PresetT, DefaultT, CoerceT, Mandatory extends boolean> =
type InferOptionTypes<Options, Flag extends string, Value extends string, ValueT, PresetT, DefaultT, CoerceT, Mandatory extends boolean, ChoicesT> =
InferOptionsNegateCombo<Options, Flag, ConvertFlagToName<Flag>,
CoerceValueType<CoerceT, InferVariadic<Value, ValueT>>,
CoerceValueType<CoerceT, [ChoicesT] extends [undefined] ? InferVariadic<Value, ValueT> : InferVariadic<Value, ChoicesT>>,
NegatePresetType<Flag, CoercePresetType<CoerceT, PresetT>>,
NegateDefaultType<Flag, DefaultT>,
IsAlwaysDefined<DefaultT, Mandatory>>;

type InferOptionsFlag<Options, Flags extends string, Value extends string, ValueT, PresetT, DefaultT, CoerceT, Mandatory extends boolean> =
InferOptionTypes<Options, FlagsToFlag<Trim<Flags>>, Trim<Value>, ValueT, PresetT, DefaultT, CoerceT, Mandatory>;
type InferOptionsFlag<Options, Flags extends string, Value extends string, ValueT, PresetT, DefaultT, CoerceT, Mandatory extends boolean, ChoicesT> =
InferOptionTypes<Options, FlagsToFlag<Trim<Flags>>, Trim<Value>, ValueT, PresetT, DefaultT, CoerceT, Mandatory, ChoicesT>;

// Split up Usage into Flags and Value
type InferOptions<Options, Usage extends string, DefaultT, CoerceT, Mandatory extends boolean, PresetT = undefined> =
type InferOptions<Options, Usage extends string, DefaultT, CoerceT, Mandatory extends boolean, PresetT = undefined, ChoicesT = undefined> =
Usage extends `${infer Flags} <${infer Value}>`
? InferOptionsFlag<Options, Flags, Value, string, never, DefaultT, CoerceT, Mandatory>
? InferOptionsFlag<Options, Flags, Value, string, never, DefaultT, CoerceT, Mandatory, ChoicesT>
: Usage extends `${infer Flags} [${infer Value}]`
? InferOptionsFlag<Options, Flags, Value, string, PresetT, DefaultT, CoerceT, Mandatory>
: InferOptionsFlag<Options, Usage, '', never, PresetT, DefaultT, CoerceT, Mandatory>;
? InferOptionsFlag<Options, Flags, Value, string, PresetT, DefaultT, CoerceT, Mandatory, ChoicesT>
: InferOptionsFlag<Options, Usage, '', never, PresetT, DefaultT, CoerceT, Mandatory, ChoicesT>;

export type CommandUnknownOpts = Command<unknown[], OptionValues>;

Expand Down Expand Up @@ -204,7 +208,7 @@ export class CommanderError extends Error {
exitCode?: number;
}

export class Argument<Usage extends string = '', DefaultT = undefined, CoerceT = undefined, ArgRequired extends boolean|undefined = undefined> {
export class Argument<Usage extends string = '', DefaultT = undefined, CoerceT = undefined, ArgRequired extends boolean|undefined = undefined, ChoicesT = undefined> {
description: string;
required: boolean;
variadic: boolean;
Expand All @@ -224,30 +228,30 @@ export class CommanderError extends Error {
/**
* Set the default value, and optionally supply the description to be displayed in the help.
*/
default<T>(value: T, description?: string): Argument<Usage, T, CoerceT, ArgRequired>;
default<T>(value: T, description?: string): Argument<Usage, T, CoerceT, ArgRequired, ChoicesT>;

/**
* Set the custom handler for processing CLI command arguments into argument values.
*/
argParser<T>(fn: (value: string, previous: T) => T): Argument<Usage, DefaultT, T, ArgRequired>;
argParser<T>(fn: (value: string, previous: T) => T): Argument<Usage, DefaultT, T, ArgRequired, undefined>; // setting ChoicesT to undefined because argParser overwrites choices

/**
* Only allow argument value to be one of choices.
*/
choices(values: readonly string[]): this;
choices<T extends readonly string[]>(values: T): Argument<Usage, DefaultT, undefined, ArgRequired, T[number]>; // setting CoerceT to undefined because choices overrides argParser

/**
* Make argument required.
*/
argRequired(): Argument<Usage, DefaultT, CoerceT, true>;
argRequired(): Argument<Usage, DefaultT, CoerceT, true, ChoicesT>;

/**
* Make argument optional.
*/
argOptional(): Argument<Usage, DefaultT, CoerceT, false>;
argOptional(): Argument<Usage, DefaultT, CoerceT, false, ChoicesT>;
}

export class Option<Usage extends string = '', PresetT = undefined, DefaultT = undefined, CoerceT = undefined, Mandatory extends boolean = false> {
export class Option<Usage extends string = '', PresetT = undefined, DefaultT = undefined, CoerceT = undefined, Mandatory extends boolean = false, ChoicesT = undefined> {
flags: string;
description: string;

Expand All @@ -270,7 +274,7 @@ export class CommanderError extends Error {
/**
* Set the default value, and optionally supply the description to be displayed in the help.
*/
default<T>(value: T, description?: string): Option<Usage, PresetT, T, CoerceT, Mandatory>;
default<T>(value: T, description?: string): Option<Usage, PresetT, T, CoerceT, Mandatory, ChoicesT>;

/**
* Preset to use when option used without option-argument, especially optional but also boolean and negated.
Expand All @@ -282,7 +286,7 @@ export class CommanderError extends Error {
* new Option('--donate [amount]').preset('20').argParser(parseFloat);
* ```
*/
preset<T>(arg: T): Option<Usage, T, DefaultT, CoerceT, Mandatory>;
preset<T>(arg: T): Option<Usage, T, DefaultT, CoerceT, Mandatory, ChoicesT>;

/**
* Add option name(s) that conflict with this option.
Expand Down Expand Up @@ -324,12 +328,12 @@ export class CommanderError extends Error {
/**
* Set the custom handler for processing CLI option arguments into option values.
*/
argParser<T>(fn: (value: string, previous: T) => T): Option<Usage, PresetT, DefaultT, T, Mandatory>;
argParser<T>(fn: (value: string, previous: T) => T): Option<Usage, PresetT, DefaultT, T, Mandatory, undefined>; // setting ChoicesT to undefined because argParser overrides choices

/**
* Whether the option is mandatory and must have a value after parsing.
*/
makeOptionMandatory<M extends boolean = true>(mandatory?: M): Option<Usage, PresetT, DefaultT, CoerceT, M>;
makeOptionMandatory<M extends boolean = true>(mandatory?: M): Option<Usage, PresetT, DefaultT, CoerceT, M, ChoicesT>;

/**
* Hide option in help.
Expand All @@ -339,7 +343,7 @@ export class CommanderError extends Error {
/**
* Only allow option value to be one of choices.
*/
choices(values: readonly string[]): this;
choices<T extends readonly string[]>(values: T): Option<Usage, PresetT, DefaultT, undefined, Mandatory, T[number]>; // setting CoerceT to undefined becuase choices overrides argParser

/**
* Return option name.
Expand Down Expand Up @@ -558,8 +562,8 @@ export class CommanderError extends Error {
*
* @returns `this` command for chaining
*/
addArgument<Usage extends string, DefaultT, CoerceT, ArgRequired extends boolean|undefined>(
arg: Argument<Usage, DefaultT, CoerceT, ArgRequired>): Command<[...Args, InferArgument<Usage, DefaultT, CoerceT, ArgRequired>]>;
addArgument<Usage extends string, DefaultT, CoerceT, ArgRequired extends boolean|undefined, ChoicesT>(
arg: Argument<Usage, DefaultT, CoerceT, ArgRequired, ChoicesT>): Command<[...Args, InferArgument<Usage, DefaultT, CoerceT, ArgRequired, ChoicesT>]>;


/**
Expand Down Expand Up @@ -756,8 +760,8 @@ export class CommanderError extends Error {
*
* See .option() and .requiredOption() for creating and attaching an option in a single call.
*/
addOption<Usage extends string, PresetT, DefaultT, CoerceT, Mandatory extends boolean>(
option: Option<Usage, PresetT, DefaultT, CoerceT, Mandatory>): Command<Args, InferOptions<Opts, Usage, DefaultT, CoerceT, Mandatory, PresetT>>;
addOption<Usage extends string, PresetT, DefaultT, CoerceT, Mandatory extends boolean, ChoicesT>(
option: Option<Usage, PresetT, DefaultT, CoerceT, Mandatory, ChoicesT>): Command<Args, InferOptions<Opts, Usage, DefaultT, CoerceT, Mandatory, PresetT, ChoicesT>>;

/**
* Whether to store option values as properties on command object,
Expand Down
101 changes: 101 additions & 0 deletions tests/arguments.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,104 @@ program
expectType<string | undefined>(bar);
expectAssignable<OptionValues>(options);
});

// choices
program
.addArgument(new Argument("<foo>").choices(["A", "B"] as const))
.action((foo, options) => {
expectType<"A" | "B">(foo);
expectAssignable<OptionValues>(options);
});

program
.addArgument(new Argument("[foo]").choices(["A", "B"] as const))
.action((foo, options) => {
expectType<"A" | "B" | undefined>(foo);
expectAssignable<OptionValues>(options);
});

program
.addArgument(new Argument("<foo...>").choices(["A", "B"] as const))
.action((foo, options) => {
expectType<("A" | "B")[]>(foo);
expectAssignable<OptionValues>(options);
});

program
.addArgument(new Argument("[foo...]").choices(["A", "B"] as const))
.action((foo, options) => {
expectType<("A" | "B")[]>(foo);
expectAssignable<OptionValues>(options);
});

// default type ignored when arg is required
expectType<('C')>(
program
.addArgument(new Argument('<foo>').default('D' as const).choices(['C'] as const))
.parse()
.processedArgs[0]
)

// default before choices results in union when arg optional
expectType<('C' | 'D')>(
program
.addArgument(new Argument('[foo]').default('D' as const).choices(['C'] as const))
.parse()
.processedArgs[0]
)

// default after choices is still union type
expectType<('C' | 'D')>(
program
.addArgument(new Argument('[foo]').choices(['C'] as const).default('D' as const))
.parse()
.processedArgs[0]
)

// argRequired after choices still narrows type
expectType<('C')>(
program
.addArgument(new Argument('foo').choices(['C'] as const).argRequired())
.parse()
.processedArgs[0]
)

// argRequired before choices still narrows type
expectType<('C')>(
program
.addArgument(new Argument('foo').argRequired().choices(['C'] as const))
.parse()
.processedArgs[0]
)

// argOptional after choices narrows type and includes undefined
expectType<('C' | undefined)>(
program
.addArgument(new Argument('foo').choices(['C'] as const).argOptional())
.parse()
.processedArgs[0]
)

// argOptional before choices narrows type and includes undefined
expectType<('C' | undefined)>(
program
.addArgument(new Argument('foo').argOptional().choices(['C'] as const))
.parse()
.processedArgs[0]
)

// argParser after choices overrides choice type
expectType<(number)>(
program
.addArgument(new Argument('<foo>').choices(['C'] as const).argParser((val: string, prev: number) => prev + Number.parseInt(val)))
.parse()
.processedArgs[0]
)

// choices after argParser overrides argParser type
expectType<('C')>(
program
.addArgument(new Argument('<foo>').argParser((val: string, prev: number) => prev + Number.parseInt(val)).choices(['C'] as const))
.parse()
.processedArgs[0]
)
17 changes: 17 additions & 0 deletions tests/create-argument.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,20 @@ if ('when global createArgument with variadic then type is string[]') {
expectType<string[]>(arg)
});
}

if ('when global createArgument with const choices then type is string union') {
const program = new Command();
program
.addArgument(createArgument('<value>').choices(['A', 'B', 'C'] as const))
.action(arg => {
expectType<'A' | 'B' | 'C'>(arg)
})
}

if ('when global createArgument with variadic and const choices then type is array of string union') {
const program = new Command();
program.addArgument(createArgument('<value...>').choices(['A', 'B', 'C'] as const))
.action(arg => {
expectType<('A' | 'B' | 'C')[]>(arg)
})
}
18 changes: 18 additions & 0 deletions tests/create-option.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,21 @@ if ('when global createOption with optional option-argument then type is string|
.foo;
expectType<string | true | undefined>(foo);
}

if ('when global createOption with const choices then type is string union') {
const program = new Command();
const foo = program
.addOption(createOption('-f, --foo <value>', 'description').choices(['A', 'B', 'C'] as const))
.opts()
.foo;
expectType<'A' | 'B' | 'C' | undefined>(foo);
}

if ('when global createOption with variadic and const choices then type is string union array') {
const program = new Command();
const foo = program
.addOption(createOption('-f, --foo <value...>', 'description').choices(['A', 'B', 'C'] as const).makeOptionMandatory())
.opts()
.foo;
expectType<('A' | 'B' | 'C')[]>(foo);
}
Loading

0 comments on commit 4324f97

Please sign in to comment.