Skip to content

Commit bd797c9

Browse files
boneskullclaude
andcommitted
feat: add enum array option type
Adds support for array options with enum choices: opt.array(['low', 'medium', 'high']) // --priority low --priority high → ['low', 'high'] The enum array validates that each value is in the choices list, and provides proper type inference as T[]. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 22c9b88 commit bd797c9

8 files changed

Lines changed: 195 additions & 20 deletions

File tree

src/help.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,13 @@ const buildPositionalsUsage = (schema?: PositionalsSchema): string => {
148148
*/
149149
const getTypeLabel = (def: OptionDef): string => {
150150
switch (def.type) {
151-
case 'array':
152-
return `${def.items}[]`;
151+
case 'array': {
152+
const arrayDef = def as { choices?: readonly string[]; items?: string };
153+
if (arrayDef.choices) {
154+
return `(${arrayDef.choices.join(' | ')})[]`;
155+
}
156+
return `${arrayDef.items ?? 'string'}[]`;
157+
}
153158
case 'boolean':
154159
return 'boolean';
155160
case 'count':

src/opt.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ArrayOption,
1414
BooleanOption,
1515
CountOption,
16+
EnumArrayOption,
1617
EnumOption,
1718
EnumPositional,
1819
InferOptions,
@@ -139,15 +140,48 @@ export const opt = {
139140

140141
/**
141142
* Define an array option (--flag value --flag value2).
143+
*
144+
* @example
145+
*
146+
* ```typescript
147+
* // Primitive array
148+
* opt.array('string'); // --file a.txt --file b.txt → ['a.txt', 'b.txt']
149+
* opt.array('number'); // --port 80 --port 443 → [80, 443]
150+
*
151+
* // Enum array (with choices)
152+
* opt.array(['low', 'medium', 'high']); // --priority low --priority high
153+
* ```
142154
*/
143-
array: (
144-
items: 'number' | 'string',
155+
array: ((
156+
itemsOrChoices: 'number' | 'string' | readonly string[],
145157
props: Omit<ArrayOption, 'items' | 'type'> = {},
146-
): ArrayOption => ({
147-
items,
148-
type: 'array',
149-
...props,
150-
}),
158+
): ArrayOption | EnumArrayOption<string> => {
159+
if (Array.isArray(itemsOrChoices)) {
160+
// Enum array
161+
return {
162+
choices: itemsOrChoices,
163+
type: 'array',
164+
...props,
165+
} as EnumArrayOption<string>;
166+
}
167+
// Primitive array
168+
return {
169+
items: itemsOrChoices,
170+
type: 'array',
171+
...props,
172+
} as ArrayOption;
173+
}) as {
174+
// Overload for primitive arrays
175+
(
176+
items: 'number' | 'string',
177+
props?: Omit<ArrayOption, 'items' | 'type'>,
178+
): ArrayOption;
179+
// Overload for enum arrays
180+
<const T extends readonly string[]>(
181+
choices: T,
182+
props?: Omit<EnumArrayOption<T[number]>, 'choices' | 'type'>,
183+
): EnumArrayOption<T[number]>;
184+
},
151185

152186
/**
153187
* Define a boolean option.

src/parser.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,30 @@ const coerceValues = (
8080
// Type coercion
8181
if (value !== undefined) {
8282
switch (def.type) {
83-
case 'array':
84-
if (def.items === 'number' && Array.isArray(value)) {
83+
case 'array': {
84+
const arrayDef = def as {
85+
choices?: readonly string[];
86+
items?: string;
87+
};
88+
if (arrayDef.choices && Array.isArray(value)) {
89+
// Enum array - validate each value
90+
for (const v of value as string[]) {
91+
if (!arrayDef.choices.includes(v)) {
92+
throw new Error(
93+
`Invalid value for --${name}: "${v}". Must be one of: ${arrayDef.choices.join(', ')}`,
94+
);
95+
}
96+
}
97+
result[name] = value;
98+
} else if (arrayDef.items === 'number' && Array.isArray(value)) {
8599
result[name] = (value as (number | string)[]).map(
86100
(v: number | string) => (typeof v === 'string' ? Number(v) : v),
87101
);
88102
} else {
89103
result[name] = value;
90104
}
91105
break;
106+
}
92107
case 'count':
93108
// Count options count occurrences
94109
result[name] = typeof value === 'number' ? value : value ? 1 : 0;

src/types.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ import type { ThemeInput } from './theme.js';
2323
*/
2424
export interface ArrayOption extends OptionBase {
2525
default?: number[] | string[];
26-
/** Element type of the array */
27-
items: 'number' | 'string';
26+
/** Element type of the array (for primitive arrays) */
27+
items?: 'number' | 'string';
2828
type: 'array';
2929
}
3030

@@ -189,6 +189,16 @@ export interface CreateOptions {
189189
version?: string;
190190
}
191191

192+
/**
193+
* Enum array option definition (--flag a --flag b with limited choices).
194+
*/
195+
export interface EnumArrayOption<T extends string = string> extends OptionBase {
196+
/** Valid choices for array elements */
197+
choices: readonly T[];
198+
default?: T[];
199+
type: 'array';
200+
}
201+
192202
/**
193203
* Enum option definition with string choices.
194204
*/
@@ -247,13 +257,15 @@ export type InferOption<T extends OptionDef> = T extends BooleanOption
247257
: T['default'] extends E
248258
? E
249259
: E | undefined
250-
: T extends ArrayOption
251-
? T['items'] extends 'number'
252-
? number[]
253-
: string[]
254-
: T extends CountOption
255-
? number
256-
: never;
260+
: T extends EnumArrayOption<infer E>
261+
? E[]
262+
: T extends ArrayOption
263+
? T['items'] extends 'number'
264+
? number[]
265+
: string[]
266+
: T extends CountOption
267+
? number
268+
: never;
257269

258270
/**
259271
* Infer values type from an options schema.
@@ -357,6 +369,7 @@ export type OptionDef =
357369
| ArrayOption
358370
| BooleanOption
359371
| CountOption
372+
| EnumArrayOption<string>
360373
| EnumOption<string>
361374
| NumberOption
362375
| StringOption;

src/validate.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,37 @@ const validateOption = (
127127
switch (type) {
128128
case 'array': {
129129
const items = opt['items'];
130+
const choices = opt['choices'];
131+
132+
// Enum array (with choices)
133+
if (choices !== undefined) {
134+
if (!isStringArray(choices) || choices.length === 0) {
135+
throw new ValidationError(
136+
`${path}.choices`,
137+
'must be a non-empty array of strings',
138+
);
139+
}
140+
if (opt['default'] !== undefined) {
141+
if (!isStringArray(opt['default'])) {
142+
throw new ValidationError(
143+
`${path}.default`,
144+
'must be an array of strings',
145+
);
146+
}
147+
for (let i = 0; i < opt['default'].length; i++) {
148+
const val = opt['default'][i]!;
149+
if (!choices.includes(val)) {
150+
throw new ValidationError(
151+
`${path}.default[${i}]`,
152+
`must be one of: ${choices.join(', ')}`,
153+
);
154+
}
155+
}
156+
}
157+
break;
158+
}
159+
160+
// Primitive array (with items)
130161
if (typeof items !== 'string') {
131162
throw new ValidationError(
132163
`${path}.items`,

test/opt.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,18 @@ describe('opt builders', () => {
126126
});
127127
});
128128

129+
it('creates enum array options', () => {
130+
const option = opt.array(['low', 'medium', 'high'], {
131+
description: 'Priority levels',
132+
});
133+
134+
expect(option, 'to satisfy', {
135+
choices: ['low', 'medium', 'high'],
136+
description: 'Priority levels',
137+
type: 'array',
138+
});
139+
});
140+
129141
it('creates count options', () => {
130142
const option = opt.count({ aliases: ['v'] });
131143

test/parser.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,31 @@ describe('parseSimple', () => {
128128

129129
expect(result.values.ports, 'to deeply equal', [80, 443]);
130130
});
131+
132+
it('parses enum array options', () => {
133+
const result = parseSimple({
134+
args: ['--priority', 'low', '--priority', 'high'],
135+
options: {
136+
priority: opt.array(['low', 'medium', 'high']),
137+
},
138+
});
139+
140+
expect(result.values.priority, 'to deeply equal', ['low', 'high']);
141+
});
142+
143+
it('throws on invalid enum array value', () => {
144+
expect(
145+
() =>
146+
parseSimple({
147+
args: ['--priority', 'invalid'],
148+
options: {
149+
priority: opt.array(['low', 'medium', 'high']),
150+
},
151+
}),
152+
'to throw',
153+
/invalid/i,
154+
);
155+
});
131156
});
132157

133158
describe('parseSimple positionals', () => {

test/validate.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,46 @@ describe('validateOptionsSchema', () => {
271271
);
272272
});
273273

274+
it('validates enum array option choices', () => {
275+
expect(
276+
() =>
277+
validateOptionsSchema({
278+
priority: opt.array(['low', 'medium', 'high']),
279+
}),
280+
'not to throw',
281+
);
282+
});
283+
284+
it('validates enum array option default is in choices', () => {
285+
expect(
286+
() =>
287+
validateOptionsSchema({
288+
priority: opt.array(['low', 'medium', 'high'], {
289+
default: ['low', 'high'],
290+
}),
291+
}),
292+
'not to throw',
293+
);
294+
295+
expect(
296+
() =>
297+
validateOptionsSchema({
298+
priority: {
299+
choices: ['low', 'medium', 'high'],
300+
default: ['invalid'],
301+
type: 'array',
302+
},
303+
}),
304+
'to throw a',
305+
Error,
306+
'satisfying',
307+
{
308+
message: /must be one of/,
309+
path: 'options.priority.default[0]',
310+
},
311+
);
312+
});
313+
274314
it('validates aliases are single characters', () => {
275315
expect(
276316
() =>

0 commit comments

Comments
 (0)