cli-kiss 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/Run.ts CHANGED
@@ -1,56 +1,31 @@
1
- import { Command, CommandFactory } from "./Command";
1
+ import { Command, CommandDecoder } from "./Command";
2
2
  import { ReaderArgs } from "./Reader";
3
3
  import { TypoSupport } from "./Typo";
4
4
  import { usageToStyledLines } from "./Usage";
5
5
 
6
6
  /**
7
- * Parses the provided CLI arguments against the given command descriptor, executes
8
- * the matched command, and exits the process with an appropriate exit code.
7
+ * Main entry point: parses CLI arguments, executes the matched command, and exits.
8
+ * Handles `--help`, `--version`, usage-on-error, and exit codes.
9
9
  *
10
- * This is the primary entry point for running a `cli-kiss`-based CLI application.
11
- * It handles argument parsing, `--help` / `--version` flags, usage printing on errors,
12
- * and exit code management.
10
+ * Exit codes:
11
+ * - `0` on success / `--help` / `--version`
12
+ * - `1` on parse error or execution error.
13
13
  *
14
- * **Exit codes:**
15
- * - `0` — Command executed successfully, or `--help` / `--version` was handled.
16
- * - `1` — Argument parsing failed (a usage summary is also printed to stderr), or the
17
- * command threw an unhandled execution error.
14
+ * @typeParam Context - Forwarded unchanged to the handler.
18
15
  *
19
- * **Built-in flags:**
20
- * - `--help` Enabled by default (`usageOnHelp: true`). Prints the usage summary to
21
- * stdout and exits with code `0`. This flag takes precedence over `--version`.
22
- * - `--version` Enabled when `buildVersion` is provided. Prints `<cliName> <version>`
23
- * to stdout and exits with code `0`.
16
+ * @param cliName - Program name used in usage and `--version` output.
17
+ * @param cliArgs - Raw arguments, typically `process.argv.slice(2)`.
18
+ * @param context - Forwarded to the handler.
19
+ * @param command - Root {@link Command}.
20
+ * @param options.useTtyColors - Color mode: `true` (always), `false` (never),
21
+ * `"mock"` (snapshot-friendly), `undefined` (auto-detect from env).
22
+ * @param options.usageOnHelp - Enables `--help` flag (default `true`).
23
+ * @param options.usageOnError - Prints usage to stderr on parse error (default `true`).
24
+ * @param options.buildVersion - Enables `--version`; prints `<cliName> <buildVersion>`.
25
+ * @param options.onError - Custom handler for errors.
26
+ * @param options.onExit - Overrides `process.exit`; useful for testing.
24
27
  *
25
- * @typeParam Context - Arbitrary value passed unchanged to the command's execution handler.
26
- * Use this to inject dependencies (e.g. a database connection, a logger) into your commands.
27
- *
28
- * @param cliName - The name of the CLI program (e.g. `"my-cli"`). Used in the usage
29
- * summary header and in the `--version` output.
30
- * @param cliArgs - The raw command-line arguments to parse, typically `process.argv.slice(2)`.
31
- * @param context - The context value forwarded to the command's execution handler.
32
- * @param command - The root {@link Command} that describes how to parse and execute
33
- * the CLI.
34
- * @param options - Optional configuration for the runner.
35
- * @param options.useTtyColors - Controls terminal color output in styled messages.
36
- * - `true` — Always apply ANSI color codes.
37
- * - `false` — Never apply color codes (plain text).
38
- * - `"mock"` — Use a deterministic mock style useful for snapshot testing.
39
- * - `undefined` (default) — Auto-detect based on `process.stdout.isTTY` and the
40
- * `FORCE_COLOR` / `NO_COLOR` environment variables.
41
- * @param options.usageOnHelp - When `true` (default), registers a `--help` flag that
42
- * prints the usage summary and exits with code `0`.
43
- * @param options.usageOnError - When `true` (default), prints the usage summary to
44
- * stderr before the error message whenever argument parsing fails.
45
- * @param options.buildVersion - When provided, registers a `--version` flag that prints
46
- * `<cliName> <buildVersion>` to stdout and exits with code `0`.
47
- * @param options.onError - Custom handler for errors thrown during command execution.
48
- * If omitted, the error is printed to stderr via {@link TypoSupport}.
49
- * @param options.onExit - Overrides the process exit function (default: `process.exit`).
50
- * Useful for testing — supply a function that throws or captures the exit code instead
51
- * of actually terminating the process.
52
- *
53
- * @returns A `Promise<never>` because the function always terminates by calling `onExit`.
28
+ * @returns `Promise<never>` always calls `onExit`.
54
29
  *
55
30
  * @example
56
31
  * ```ts
@@ -60,7 +35,7 @@ import { usageToStyledLines } from "./Usage";
60
35
  * { description: "Greet someone" },
61
36
  * operation(
62
37
  * { options: {}, positionals: [positionalRequired({ type: typeString, label: "NAME" })] },
63
- * async (_ctx, { positionals: [name] }) => {
38
+ * async function (_ctx, { positionals: [name] }) {
64
39
  * console.log(`Hello, ${name}!`);
65
40
  * },
66
41
  * ),
@@ -77,7 +52,7 @@ export async function runAndExit<Context>(
77
52
  context: Context,
78
53
  command: Command<Context, void>,
79
54
  options?: {
80
- useTtyColors?: boolean | undefined | "mock";
55
+ useTtyColors?: boolean | undefined | "mock"; // TODO - flag setter option
81
56
  usageOnHelp?: boolean | undefined;
82
57
  usageOnError?: boolean | undefined;
83
58
  buildVersion?: string | undefined;
@@ -91,7 +66,10 @@ export async function runAndExit<Context>(
91
66
  readerArgs.registerOption({
92
67
  shorts: [],
93
68
  longs: ["help"],
94
- valued: false,
69
+ parsing: {
70
+ consumeShortGroup: false,
71
+ consumeNextArg: () => false,
72
+ },
95
73
  });
96
74
  }
97
75
  const buildVersion = options?.buildVersion;
@@ -99,7 +77,10 @@ export async function runAndExit<Context>(
99
77
  readerArgs.registerOption({
100
78
  shorts: [],
101
79
  longs: ["version"],
102
- valued: false,
80
+ parsing: {
81
+ consumeShortGroup: false,
82
+ consumeNextArg: () => false,
83
+ },
103
84
  });
104
85
  }
105
86
  /*
@@ -110,7 +91,8 @@ export async function runAndExit<Context>(
110
91
  longs: ["completion"],
111
92
  });
112
93
  */
113
- const commandFactory = command.createFactory(readerArgs);
94
+ // TODO - handle color flag ?
95
+ const commandDecoder = command.consumeAndMakeDecoder(readerArgs);
114
96
  while (true) {
115
97
  try {
116
98
  const positional = readerArgs.consumePositional();
@@ -119,18 +101,11 @@ export async function runAndExit<Context>(
119
101
  }
120
102
  } catch (_) {}
121
103
  }
104
+ const typoSupport = computeTypoSupport(options?.useTtyColors);
122
105
  const onExit = options?.onExit ?? process.exit;
123
- const typoSupport =
124
- options?.useTtyColors === undefined
125
- ? TypoSupport.inferFromProcess()
126
- : options.useTtyColors === "mock"
127
- ? TypoSupport.mock()
128
- : options.useTtyColors
129
- ? TypoSupport.tty()
130
- : TypoSupport.none();
131
106
  if (usageOnHelp) {
132
107
  if (readerArgs.getOptionValues("--help" as any).length > 0) {
133
- console.log(computeUsageString(cliName, commandFactory, typoSupport));
108
+ console.log(computeUsageString(cliName, commandDecoder, typoSupport));
134
109
  return onExit(0);
135
110
  }
136
111
  }
@@ -141,35 +116,55 @@ export async function runAndExit<Context>(
141
116
  }
142
117
  }
143
118
  try {
144
- const commandInstance = commandFactory.createInstance();
119
+ const commandInterpreter = commandDecoder.decodeAndMakeInterpreter();
145
120
  try {
146
- await commandInstance.executeWithContext(context);
121
+ await commandInterpreter.executeWithContext(context);
147
122
  return onExit(0);
148
123
  } catch (executionError) {
149
- if (options?.onError) {
150
- options.onError(executionError);
151
- } else {
152
- console.error(typoSupport.computeStyledErrorMessage(executionError));
153
- }
124
+ handleError(options?.onError, executionError, typoSupport);
154
125
  return onExit(1);
155
126
  }
156
127
  } catch (parsingError) {
157
128
  if (options?.usageOnError ?? true) {
158
- console.error(computeUsageString(cliName, commandFactory, typoSupport));
129
+ console.error(computeUsageString(cliName, commandDecoder, typoSupport));
159
130
  }
160
- console.error(typoSupport.computeStyledErrorMessage(parsingError));
131
+ handleError(options?.onError, parsingError, typoSupport);
161
132
  return onExit(1);
162
133
  }
163
134
  }
164
135
 
136
+ function handleError(
137
+ onError: ((error: unknown) => void) | undefined,
138
+ error: unknown,
139
+ typoSupport: TypoSupport,
140
+ ) {
141
+ if (onError !== undefined) {
142
+ onError(error);
143
+ } else {
144
+ console.error(typoSupport.computeStyledErrorMessage(error));
145
+ }
146
+ }
147
+
165
148
  function computeUsageString<Context, Result>(
166
149
  cliName: Lowercase<string>,
167
- commandFactory: CommandFactory<Context, Result>,
150
+ commandDecoder: CommandDecoder<Context, Result>,
168
151
  typoSupport: TypoSupport,
169
152
  ) {
170
153
  return usageToStyledLines({
171
154
  cliName,
172
- commandUsage: commandFactory.generateUsage(),
155
+ usage: commandDecoder.generateUsage(),
173
156
  typoSupport,
174
157
  }).join("\n");
175
158
  }
159
+
160
+ function computeTypoSupport(
161
+ useTtyColors: boolean | undefined | "mock",
162
+ ): TypoSupport {
163
+ return useTtyColors === undefined
164
+ ? TypoSupport.inferFromProcess()
165
+ : useTtyColors === "mock"
166
+ ? TypoSupport.mock()
167
+ : useTtyColors
168
+ ? TypoSupport.tty()
169
+ : TypoSupport.none();
170
+ }
package/src/lib/Type.ts CHANGED
@@ -7,79 +7,68 @@ import {
7
7
  } from "./Typo";
8
8
 
9
9
  /**
10
- * Describes how to decode a raw CLI string token into a typed TypeScript value.
10
+ * Decodes a raw CLI string into a typed value.
11
+ * A pair of a human-readable `content` name (e.g. `"Number"`) and a `decoder` function.
11
12
  *
12
- * A `Type` is a pair of:
13
- * - a `content` string — a human-readable name shown in help/error messages (e.g.
14
- * `"String"`, `"Number"`, `"Url"`).
15
- * - a `decoder` function — converts the raw string or throws a {@link TypoError} on
16
- * invalid input.
17
- *
18
- * Built-in types: {@link typeString}, {@link typeBoolean}, {@link typeNumber},
13
+ * Built-in: {@link typeString}, {@link typeBoolean}, {@link typeNumber},
19
14
  * {@link typeInteger}, {@link typeDate}, {@link typeUrl}.
15
+ * Composite: {@link typeOneOf}, {@link typeMapped}, {@link typeTuple}, {@link typeList}.
20
16
  *
21
- * Composite types: {@link typeOneOf}, {@link typeConverted}, {@link typeTuple},
22
- * {@link typeList}.
23
- *
24
- * @typeParam Value - The TypeScript type that the decoder produces.
17
+ * @typeParam Value - Type produced by the decoder.
25
18
  */
26
19
  export type Type<Value> = {
27
20
  /**
28
- * Human-readable name for this type, used in help text and error messages.
29
- * Examples: `"String"`, `"Number"`, `"Url"`.
21
+ * Human-readable name shown in help and errors (e.g. `"String"`, `"Number"`).
30
22
  */
31
23
  content: string;
32
24
  /**
33
- * Decodes a raw string token into a `Value`.
25
+ * Decodes a raw CLI string into `Value`.
34
26
  *
35
- * @param value - The raw string from the command line.
27
+ * @param input - Raw string from the command line.
36
28
  * @returns The decoded value.
37
- * @throws {@link TypoError} if the value cannot be decoded.
29
+ * @throws {@link TypoError} on invalid input.
38
30
  */
39
- decoder(value: string): Value;
31
+ decoder(input: string): Value;
40
32
  };
41
33
 
42
34
  /**
43
- * A {@link Type} that decodes `"true"` / `"yes"` to `true` and `"false"` / `"no"` to
44
- * `false` (case-insensitive). Any other value throws a {@link TypoError}.
45
- *
46
- * Primarily used internally by {@link optionFlag} for the `--flag=<value>` syntax, but
47
- * can also be used in positionals or valued options.
35
+ * Decodes a string to `boolean` (case-insensitive).
36
+ * Used by {@link optionFlag} for `--flag=<value>`.
48
37
  *
49
38
  * @example
50
39
  * ```ts
40
+ * typeBoolean.decoder("true") // → true
51
41
  * typeBoolean.decoder("yes") // → true
42
+ * typeBoolean.decoder("y") // → true
52
43
  * typeBoolean.decoder("false") // → false
53
- * typeBoolean.decoder("1") // throws TypoError
44
+ * typeBoolean.decoder("no") // false
45
+ * typeBoolean.decoder("n") // → false
54
46
  * ```
55
47
  */
56
48
  export const typeBoolean: Type<boolean> = {
57
49
  content: "Boolean",
58
- decoder(value: string) {
59
- const lowerValue = value.toLowerCase();
60
- if (lowerValue === "true" || lowerValue === "yes") {
50
+ decoder(input: string) {
51
+ const lower = input.toLowerCase();
52
+ if (booleanValuesTrue.has(lower)) {
61
53
  return true;
62
54
  }
63
- if (lowerValue === "false" || lowerValue === "no") {
55
+ if (booleanValuesFalse.has(lower)) {
64
56
  return false;
65
57
  }
66
58
  throw new TypoError(
67
59
  new TypoText(
68
60
  new TypoString(`Invalid value: `),
69
- new TypoString(`"${value}"`, typoStyleQuote),
61
+ new TypoString(`"${input}"`, typoStyleQuote),
70
62
  ),
71
63
  );
72
64
  },
73
65
  };
66
+ const booleanValuesTrue = new Set(["true", "yes", "on", "1", "y", "t"]);
67
+ const booleanValuesFalse = new Set(["false", "no", "off", "0", "n", "f"]);
74
68
 
75
69
  /**
76
- * A {@link Type} that parses a date/time string using `Date.parse`.
77
- *
78
- * Accepts any format supported by the JavaScript `Date.parse` API, including ISO 8601
79
- * strings (e.g. `"2024-01-15"`, `"2024-01-15T10:30:00Z"`). Non-parseable values throw
80
- * a {@link TypoError}.
81
- *
82
- * Produces a `Date` object. The decoded value is the result of `new Date(Date.parse(value))`.
70
+ * Parses a date/time string via `Date.parse`.
71
+ * Accepts any format supported by `Date.parse`, including ISO 8601.
83
72
  *
84
73
  * @example
85
74
  * ```ts
@@ -90,9 +79,9 @@ export const typeBoolean: Type<boolean> = {
90
79
  */
91
80
  export const typeDate: Type<Date> = {
92
81
  content: "Date",
93
- decoder(value: string) {
82
+ decoder(input: string) {
94
83
  try {
95
- const timestampMs = Date.parse(value);
84
+ const timestampMs = Date.parse(input);
96
85
  if (isNaN(timestampMs)) {
97
86
  throw new Error();
98
87
  }
@@ -101,7 +90,7 @@ export const typeDate: Type<Date> = {
101
90
  throw new TypoError(
102
91
  new TypoText(
103
92
  new TypoString(`Not a valid ISO_8601: `),
104
- new TypoString(`"${value}"`, typoStyleQuote),
93
+ new TypoString(`"${input}"`, typoStyleQuote),
105
94
  ),
106
95
  );
107
96
  }
@@ -109,11 +98,7 @@ export const typeDate: Type<Date> = {
109
98
  };
110
99
 
111
100
  /**
112
- * A {@link Type} that parses a string into a JavaScript `number` using the `Number()`
113
- * constructor.
114
- *
115
- * Accepts integers, floating-point values, and scientific notation (e.g. `"3.14"`,
116
- * `"-1"`, `"1e10"`). Values that produce `NaN` throw a {@link TypoError}.
101
+ * Parses a string to `number` via `Number()`; `NaN` throws {@link TypoError}.
117
102
  *
118
103
  * @example
119
104
  * ```ts
@@ -124,9 +109,9 @@ export const typeDate: Type<Date> = {
124
109
  */
125
110
  export const typeNumber: Type<number> = {
126
111
  content: "Number",
127
- decoder(value: string) {
112
+ decoder(input: string) {
128
113
  try {
129
- const parsed = Number(value);
114
+ const parsed = Number(input);
130
115
  if (isNaN(parsed)) {
131
116
  throw new Error();
132
117
  }
@@ -135,7 +120,7 @@ export const typeNumber: Type<number> = {
135
120
  throw new TypoError(
136
121
  new TypoText(
137
122
  new TypoString(`Unable to parse: `),
138
- new TypoString(`"${value}"`, typoStyleQuote),
123
+ new TypoString(`"${input}"`, typoStyleQuote),
139
124
  ),
140
125
  );
141
126
  }
@@ -143,11 +128,8 @@ export const typeNumber: Type<number> = {
143
128
  };
144
129
 
145
130
  /**
146
- * A {@link Type} that parses a string into a JavaScript `bigint` using the `BigInt()`
147
- * constructor.
148
- *
149
- * Only accepts valid integer strings (e.g. `"42"`, `"-100"`, `"9007199254740993"`).
150
- * Floating-point strings or non-numeric values throw a {@link TypoError}.
131
+ * Parses an integer string to `bigint` via `BigInt()`.
132
+ * Floats and non-numeric strings throw {@link TypoError}.
151
133
  *
152
134
  * @example
153
135
  * ```ts
@@ -158,14 +140,14 @@ export const typeNumber: Type<number> = {
158
140
  */
159
141
  export const typeInteger: Type<bigint> = {
160
142
  content: "Integer",
161
- decoder(value: string) {
143
+ decoder(input: string) {
162
144
  try {
163
- return BigInt(value);
145
+ return BigInt(input);
164
146
  } catch {
165
147
  throw new TypoError(
166
148
  new TypoText(
167
149
  new TypoString(`Unable to parse: `),
168
- new TypoString(`"${value}"`, typoStyleQuote),
150
+ new TypoString(`"${input}"`, typoStyleQuote),
169
151
  ),
170
152
  );
171
153
  }
@@ -173,10 +155,8 @@ export const typeInteger: Type<bigint> = {
173
155
  };
174
156
 
175
157
  /**
176
- * A {@link Type} that parses a string into a `URL` object using the `URL` constructor.
177
- *
178
- * The string must be a valid absolute URL (e.g. `"https://example.com/path?q=1"`).
179
- * Relative URLs and malformed strings throw a {@link TypoError}.
158
+ * Parses an absolute URL string to a `URL` object.
159
+ * Relative or malformed URLs throw {@link TypoError}.
180
160
  *
181
161
  * @example
182
162
  * ```ts
@@ -186,14 +166,14 @@ export const typeInteger: Type<bigint> = {
186
166
  */
187
167
  export const typeUrl: Type<URL> = {
188
168
  content: "Url",
189
- decoder(value: string) {
169
+ decoder(input: string) {
190
170
  try {
191
- return new URL(value);
171
+ return new URL(input);
192
172
  } catch {
193
173
  throw new TypoError(
194
174
  new TypoText(
195
175
  new TypoString(`Unable to parse: `),
196
- new TypoString(`"${value}"`, typoStyleQuote),
176
+ new TypoString(`"${input}"`, typoStyleQuote),
197
177
  ),
198
178
  );
199
179
  }
@@ -201,9 +181,7 @@ export const typeUrl: Type<URL> = {
201
181
  };
202
182
 
203
183
  /**
204
- * A {@link Type} that passes the raw string through unchanged (identity decoder).
205
- *
206
- * This is the simplest type and accepts any string value without validation.
184
+ * Identity decoder passes the raw string through unchanged.
207
185
  *
208
186
  * @example
209
187
  * ```ts
@@ -213,35 +191,27 @@ export const typeUrl: Type<URL> = {
213
191
  */
214
192
  export const typeString: Type<string> = {
215
193
  content: "String",
216
- decoder(value: string) {
217
- return value;
194
+ decoder(input: string) {
195
+ return input;
218
196
  },
219
197
  };
220
198
 
221
199
  /**
222
- * Creates a new {@link Type} by chaining a `before` type decoder with an `after`
223
- * transformation.
224
- *
225
- * The raw string is first decoded by `before.decoder`; its result is then passed to
226
- * `after.decoder`. Errors from `before` are wrapped with a "from: <content>" context
227
- * prefix so that the full decoding path is visible in error messages.
200
+ * Chains `before`'s decoder with an `after` transformation.
201
+ * `before` errors are prefixed with `"from: <content>"` for traceability.
228
202
  *
229
- * Use this when an existing type (e.g. {@link typeString}, {@link typeOneOf}) produces
230
- * an intermediate value that needs a further transformation (e.g. parsing a
231
- * string-keyed enum into a number).
203
+ * @typeParam Before - Intermediate type from `before.decoder`.
204
+ * @typeParam After - Final type from `after.decoder`.
232
205
  *
233
- * @typeParam Before - The intermediate type produced by `before.decoder`.
234
- * @typeParam After - The final type produced by `after.decoder`.
235
- *
236
- * @param before - The base type that decodes the raw CLI string.
237
- * @param after - The transformation applied to the `Before` value.
238
- * @param after.content - Human-readable name for the resulting type (shown in errors).
239
- * @param after.decoder - Function that converts a `Before` value into `After`.
240
- * @returns A new {@link Type}`<After>` whose `content` is `after.content`.
206
+ * @param before - Base decoder for the raw string.
207
+ * @param after - Transformation applied to the decoded value.
208
+ * @param after.content - Name for the resulting type (shown in errors).
209
+ * @param after.decoder - Converts a `Before` value to `After`.
210
+ * @returns A {@link Type}`<After>`.
241
211
  *
242
212
  * @example
243
213
  * ```ts
244
- * const typePort = typeConverted(typeNumber, {
214
+ * const typePort = typeMapped(typeNumber, {
245
215
  * content: "Port",
246
216
  * decoder: (n) => {
247
217
  * if (n < 1 || n > 65535) throw new Error("Out of range");
@@ -252,16 +222,16 @@ export const typeString: Type<string> = {
252
222
  * // "--port 99999" → TypoError: --port: <PORT>: Port: Out of range
253
223
  * ```
254
224
  */
255
- export function typeConverted<Before, After>(
225
+ export function typeMapped<Before, After>(
256
226
  before: Type<Before>,
257
227
  after: { content: string; decoder: (value: Before) => After },
258
228
  ): Type<After> {
259
229
  return {
260
230
  content: after.content,
261
- decoder: (value: string) => {
231
+ decoder: (input: string) => {
262
232
  return after.decoder(
263
233
  TypoError.tryWithContext(
264
- () => before.decoder(value),
234
+ () => before.decoder(input),
265
235
  () =>
266
236
  new TypoText(
267
237
  new TypoString("from: "),
@@ -274,18 +244,12 @@ export function typeConverted<Before, After>(
274
244
  }
275
245
 
276
246
  /**
277
- * Creates a {@link Type}`<string>` that only accepts a fixed set of string values.
278
- *
279
- * The decoder performs an exact (case-sensitive) lookup in `values`. If the input is
280
- * not in the set, a {@link TypoError} is thrown listing up to 5 of the valid options.
281
- *
282
- * Combine with {@link typeConverted} to map the accepted strings to a richer type.
247
+ * Creates a {@link Type}`<string>` that only accepts a fixed set of values.
248
+ * Out-of-set inputs throw {@link TypoError} listing up to 5 valid options.
283
249
  *
284
- * @param content - Human-readable name for this type shown in help text and error
285
- * messages (e.g. `"Environment"`, `"LogLevel"`).
286
- * @param values - The ordered list of accepted string values. The order is preserved in
287
- * the error message preview.
288
- * @returns A {@link Type}`<string>` that validates membership in `values`.
250
+ * @param content - Name shown in help and errors (e.g. `"Environment"`).
251
+ * @param values - Ordered list of accepted values.
252
+ * @returns A {@link Type}`<string>`.
289
253
  *
290
254
  * @example
291
255
  * ```ts
@@ -294,16 +258,17 @@ export function typeConverted<Before, After>(
294
258
  * typeEnv.decoder("unknown") // throws TypoError: Invalid value: "unknown" (expected one of: "dev" | "staging" | "prod")
295
259
  * ```
296
260
  */
297
- export function typeOneOf(
261
+ export function typeOneOf<const Value extends string>(
298
262
  content: string,
299
- values: Array<string>,
300
- ): Type<string> {
301
- const valuesSet = new Set(values);
263
+ values: Array<Value>,
264
+ ): Type<Value> {
302
265
  return {
303
266
  content: content,
304
- decoder(value: string) {
305
- if (valuesSet.has(value)) {
306
- return value;
267
+ decoder(input: string) {
268
+ for (const value of values) {
269
+ if (input === value) {
270
+ return value;
271
+ }
307
272
  }
308
273
  const valuesPreview = [];
309
274
  for (const value of values) {
@@ -319,7 +284,7 @@ export function typeOneOf(
319
284
  throw new TypoError(
320
285
  new TypoText(
321
286
  new TypoString(`Invalid value: `),
322
- new TypoString(`"${value}"`, typoStyleQuote),
287
+ new TypoString(`"${input}"`, typoStyleQuote),
323
288
  new TypoString(` (expected one of: `),
324
289
  ...valuesPreview,
325
290
  new TypoString(`)`),
@@ -330,23 +295,14 @@ export function typeOneOf(
330
295
  }
331
296
 
332
297
  /**
333
- * Creates a {@link Type} that decodes a single delimited string into a fixed-length
334
- * tuple of typed elements.
298
+ * Splits a delimited string into a typed tuple.
299
+ * Each part is decoded by the corresponding element type; wrong count or decode failure throws {@link TypoError}.
335
300
  *
336
- * The raw string is split on `separator` into exactly `elementTypes.length` parts.
337
- * Each part is decoded by its corresponding element type. If the number of splits does
338
- * not match, or if any element's decoder fails, a {@link TypoError} is thrown with the
339
- * index and element type context.
301
+ * @typeParam Elements - Tuple of decoded value types (inferred from `elementTypes`).
340
302
  *
341
- * The resulting `content` is the element types' `content` values joined by `separator`
342
- * (e.g. `"Number,String"` for a `[number, string]` tuple with `","` separator).
343
- *
344
- * @typeParam Elements - The tuple type of decoded element values (inferred from
345
- * `elementTypes`).
346
- *
347
- * @param elementTypes - An ordered array of {@link Type}s, one per tuple element.
348
- * @param separator - The string used to split the raw value (default: `","`).
349
- * @returns A {@link Type}`<Elements>` tuple type.
303
+ * @param elementTypes - One {@link Type} per tuple element, in order.
304
+ * @param separator - Delimiter (default `","`).
305
+ * @returns A {@link Type}`<Elements>`.
350
306
  *
351
307
  * @example
352
308
  * ```ts
@@ -364,14 +320,14 @@ export function typeTuple<const Elements extends Array<any>>(
364
320
  content: elementTypes
365
321
  .map((elementType) => elementType.content)
366
322
  .join(separator),
367
- decoder(value: string) {
368
- const splits = value.split(separator, elementTypes.length);
323
+ decoder(input: string) {
324
+ const splits = input.split(separator, elementTypes.length);
369
325
  if (splits.length !== elementTypes.length) {
370
326
  throw new TypoError(
371
327
  new TypoText(
372
328
  new TypoString(`Found ${splits.length} splits: `),
373
329
  new TypoString(`Expected ${elementTypes.length} splits from: `),
374
- new TypoString(`"${value}"`, typoStyleQuote),
330
+ new TypoString(`"${input}"`, typoStyleQuote),
375
331
  ),
376
332
  );
377
333
  }
@@ -391,26 +347,14 @@ export function typeTuple<const Elements extends Array<any>>(
391
347
  }
392
348
 
393
349
  /**
394
- * Creates a {@link Type} that decodes a single delimited string into an array of
395
- * homogeneous typed elements.
396
- *
397
- * The raw string is split on `separator` and each part is decoded by `elementType`.
398
- * If any element's decoder fails, a {@link TypoError} is thrown with the index and
399
- * element type context.
400
- *
401
- * Unlike {@link typeTuple}, the number of elements is not fixed; the result array
402
- * length equals the number of `separator`-delimited parts in the input string. To pass
403
- * an empty array, the user must pass an empty string (`""`), which splits into one
404
- * empty-string element — consider using {@link optionRepeatable} instead if you want a
405
- * naturally empty default.
406
- *
407
- * The `content` is formatted as `"<elementContent>[<sep><elementContent>]..."` to
408
- * signal repeatability.
350
+ * Splits a delimited string into a typed array.
351
+ * Each part is decoded by `elementType`; failed decodes throw {@link TypoError}.
352
+ * Note: splitting an empty string yields one empty element — prefer {@link optionRepeatable} for a zero-default.
409
353
  *
410
- * @typeParam Value - The TypeScript element type produced by `elementType.decoder`.
354
+ * @typeParam Value - Element type produced by `elementType.decoder`.
411
355
  *
412
- * @param elementType - The {@link Type} used to decode each element.
413
- * @param separator - The string used to split the raw value (default: `","`).
356
+ * @param elementType - Decoder applied to each element.
357
+ * @param separator - Delimiter (default `","`).
414
358
  * @returns A {@link Type}`<Array<Value>>`.
415
359
  *
416
360
  * @example
@@ -429,8 +373,8 @@ export function typeList<Value>(
429
373
  ): Type<Array<Value>> {
430
374
  return {
431
375
  content: `${elementType.content}[${separator}${elementType.content}]...`,
432
- decoder(value: string) {
433
- const splits = value.split(separator);
376
+ decoder(input: string) {
377
+ const splits = input.split(separator);
434
378
  return splits.map((split, index) =>
435
379
  TypoError.tryWithContext(
436
380
  () => elementType.decoder(split),