Skip to content

Commit 43cb4fb

Browse files
committed
fix: type inference issues; inference no longer reliant on property ordering
1 parent f91683a commit 43cb4fb

4 files changed

Lines changed: 224 additions & 27 deletions

File tree

examples/transforms-inline.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env npx tsx
2+
/**
3+
* Transform example (inline version)
4+
*
5+
* Same as transforms.ts but with everything inlined to test type inference for
6+
* inline command definitions.
7+
*
8+
* NOTE: Inline command definitions can be fully typed, but you must preserve
9+
* schema shapes:
10+
*
11+
* - Use `bargsAsync.positionals(...)` (not array literals) to get tuple
12+
* positionals inference
13+
* - Use `bargsAsync.options(...)` when composing or reusing option schemas
14+
*
15+
* If you want maximum inference with fewer moving parts, prefer
16+
* `bargsAsync.command<TGlobalOptions, TGlobalTransforms>()()` as shown in
17+
* transforms.ts.
18+
*
19+
* Usage: npx tsx examples/transforms-inline.ts process file1.txt --verbose npx
20+
* tsx examples/transforms-inline.ts info --config config.json npx tsx
21+
* examples/transforms-inline.ts --help
22+
*/
23+
import { existsSync, readFileSync } from 'node:fs';
24+
25+
import { bargsAsync } from '../src/index.js';
26+
27+
/**
28+
* Config that can be loaded from a JSON file.
29+
*/
30+
interface Config {
31+
maxRetries?: number;
32+
outputDir?: string;
33+
verbose?: boolean;
34+
}
35+
36+
const main = async () => {
37+
// Everything is inlined here - no separate variables for options, transforms, or commands
38+
await bargsAsync({
39+
commands: {
40+
// Inline command definition
41+
info: {
42+
description: 'Show configuration info',
43+
handler: ({ values }) => {
44+
console.log('Current configuration:');
45+
console.log(` Config file: ${values.config ?? '(none)'}`);
46+
console.log(` Output dir: ${values.outputDir ?? '(default)'}`);
47+
console.log(` Verbose: ${values.verbose}`);
48+
// These come from global transforms
49+
console.log(` Config loaded: ${values.configLoaded}`);
50+
console.log(` Timestamp: ${values.timestamp}`);
51+
},
52+
},
53+
// Inline command with its own options and transforms
54+
process: {
55+
description: 'Process files',
56+
handler: ({ positionals, values }) => {
57+
const isStringArray = (value: unknown): value is string[] =>
58+
Array.isArray(value) && value.every((v) => typeof v === 'string');
59+
60+
const [maybeFiles] = positionals;
61+
const files = isStringArray(maybeFiles) ? maybeFiles : [];
62+
63+
if (values.verbose) {
64+
console.log('Processing configuration:', {
65+
configLoaded: values.configLoaded,
66+
outputDir: values.outputDir,
67+
timestamp: values.timestamp,
68+
verbose: values.verbose,
69+
});
70+
}
71+
72+
console.log(`Processing ${files.length} file(s):`);
73+
for (const file of files) {
74+
console.log(` - ${file}`);
75+
}
76+
77+
if (values.outputDir) {
78+
console.log(`Output will be written to: ${values.outputDir}`);
79+
}
80+
},
81+
positionals: bargsAsync.positionals(
82+
bargsAsync.variadic('string', {
83+
description: 'Input files to process',
84+
name: 'files',
85+
}),
86+
),
87+
// Inline command-level transform
88+
transforms: {
89+
positionals: (positionals: readonly [string[]]) => {
90+
const [files] = positionals;
91+
const validFiles = files.filter((f) => {
92+
if (!existsSync(f)) {
93+
console.warn(`Warning: File not found: ${f}`);
94+
return false;
95+
}
96+
return true;
97+
});
98+
return [validFiles] as const;
99+
},
100+
},
101+
},
102+
},
103+
defaultHandler: ({ values }) => {
104+
console.log('No command specified. Use --help for usage.');
105+
if (values.verbose) {
106+
console.log('(verbose mode enabled)');
107+
}
108+
// These should be typed from global transforms
109+
console.log(`Config loaded: ${values.configLoaded}`);
110+
console.log(`Timestamp: ${values.timestamp}`);
111+
},
112+
description: 'Demonstrates inline transforms with commands',
113+
name: 'transforms-inline-demo',
114+
// Inline global options
115+
options: {
116+
config: bargsAsync.string({
117+
aliases: ['c'],
118+
description: 'Path to JSON config file',
119+
}),
120+
outputDir: bargsAsync.string({
121+
aliases: ['o'],
122+
description: 'Output directory',
123+
}),
124+
verbose: bargsAsync.boolean({
125+
aliases: ['v'],
126+
default: false,
127+
description: 'Enable verbose output',
128+
}),
129+
},
130+
// Inline global transforms
131+
transforms: {
132+
values: (values: {
133+
config: string | undefined;
134+
outputDir: string | undefined;
135+
verbose: boolean;
136+
}) => {
137+
let fileConfig: Config = {};
138+
139+
if (values.config && existsSync(values.config)) {
140+
const content = readFileSync(values.config, 'utf8');
141+
fileConfig = JSON.parse(content) as Config;
142+
}
143+
144+
return {
145+
...fileConfig,
146+
...values,
147+
configLoaded: !!values.config,
148+
timestamp: new Date().toISOString(),
149+
};
150+
},
151+
},
152+
version: '1.0.0',
153+
});
154+
};
155+
156+
void main();

src/bargs.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import type {
14-
AnyCommandConfig,
14+
AnyCommandConfigInput,
1515
BargsConfig,
1616
BargsConfigWithCommands,
1717
BargsOptions,
@@ -182,7 +182,7 @@ export function bargs<
182182
*/
183183
export function bargs<
184184
const TOptions extends OptionsSchema,
185-
const TCommands extends Record<string, AnyCommandConfig>,
185+
const TCommands extends Record<string, AnyCommandConfigInput>,
186186
>(
187187
config: BargsConfigWithCommands<TOptions, TCommands>,
188188
options?: BargsOptions,
@@ -273,7 +273,7 @@ export async function bargsAsync<
273273
*/
274274
export async function bargsAsync<
275275
const TOptions extends OptionsSchema,
276-
const TCommands extends Record<string, AnyCommandConfig>,
276+
const TCommands extends Record<string, AnyCommandConfigInput>,
277277
const TTransforms extends TransformsConfig<any, any, any, any> | undefined =
278278
undefined,
279279
>(

src/types.ts

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ export interface AnyCommandConfig {
4141
transforms?: TransformsConfig<any, any, any, any>;
4242
}
4343

44+
/**
45+
* Command config shape used for type constraints/inference.
46+
*
47+
* Intentionally omits `handler` so inline command handlers are not
48+
* context-typed as `(result: any) => …` before `CommandsInput` can apply the
49+
* computed handler signature.
50+
*/
51+
export type AnyCommandConfigInput = Omit<AnyCommandConfig, 'handler'>;
52+
4453
/**
4554
* Array option definition (--flag value --flag value2).
4655
*/
@@ -62,7 +71,8 @@ export interface ArrayOption extends OptionBase {
6271
export interface BargsConfig<
6372
TOptions extends OptionsSchema = OptionsSchema,
6473
TPositionals extends PositionalsSchema = PositionalsSchema,
65-
TCommands extends Record<string, AnyCommandConfig> | undefined = undefined,
74+
TCommands extends Record<string, AnyCommandConfigInput> | undefined =
75+
undefined,
6676
TTransforms extends TransformsConfig<any, any, any, any> | undefined =
6777
undefined,
6878
> {
@@ -93,17 +103,17 @@ export interface BargsConfig<
93103

94104
export type BargsConfigWithCommands<
95105
TOptions extends OptionsSchema = OptionsSchema,
96-
TCommands extends Record<string, AnyCommandConfig> = Record<
106+
TCommands extends Record<string, AnyCommandConfigInput> = Record<
97107
string,
98-
AnyCommandConfig
108+
AnyCommandConfigInput
99109
>,
100110
TTransforms extends TransformsConfig<any, any, any, any> | undefined =
101111
undefined,
102112
> = Omit<
103113
BargsConfig<TOptions, PositionalsSchema, TCommands, TTransforms>,
104114
'commands' | 'positionals'
105115
> & {
106-
commands: CommandsInput<TOptions, TTransforms, TCommands>;
116+
commands: CommandsInput<TOptions, TTransforms, TCommands> & TCommands;
107117
defaultHandler?:
108118
| CommandNames<TCommands>
109119
| Handler<
@@ -123,9 +133,9 @@ export type BargsConfigWithCommands<
123133
*/
124134
export type BargsConfigWithCommandsInternal<
125135
TOptions extends OptionsSchema = OptionsSchema,
126-
TCommands extends Record<string, AnyCommandConfig> = Record<
136+
TCommands extends Record<string, AnyCommandConfigInput> = Record<
127137
string,
128-
AnyCommandConfig
138+
AnyCommandConfigInput
129139
>,
130140
TTransforms extends TransformsConfig<any, any, any, any> | undefined =
131141
undefined,
@@ -400,7 +410,9 @@ export type InferPositionals<T extends PositionalsSchema> = T extends readonly [
400410
? Only extends PositionalDef
401411
? readonly [InferPositional<Only>]
402412
: readonly []
403-
: readonly [];
413+
: T extends readonly []
414+
? readonly []
415+
: readonly InferPositional<T[number]>[];
404416

405417
/**
406418
* Compute proper handler types for each command in a commands record.
@@ -427,7 +439,7 @@ export type InferPositionals<T extends PositionalsSchema> = T extends readonly [
427439
export type InferredCommands<
428440
TGlobalOptions extends OptionsSchema,
429441
TGlobalTransforms extends TransformsConfig<any, any, any, any> | undefined,
430-
TCommands extends Record<string, AnyCommandConfig>,
442+
TCommands extends Record<string, AnyCommandConfigInput>,
431443
> = {
432444
[K in keyof TCommands]: TCommands[K] extends CommandConfig<
433445
infer _TGlobalOpts,
@@ -445,9 +457,20 @@ export type InferredCommands<
445457
InferCommandResult<
446458
TGlobalOptions,
447459
TGlobalTransforms,
448-
TCommands[K]['options'],
449-
TCommands[K]['positionals'],
450-
TCommands[K]['transforms']
460+
NonNullable<TCommands[K]['options']> extends OptionsSchema
461+
? NonNullable<TCommands[K]['options']>
462+
: undefined,
463+
NonNullable<TCommands[K]['positionals']> extends PositionalsSchema
464+
? NonNullable<TCommands[K]['positionals']>
465+
: undefined,
466+
NonNullable<TCommands[K]['transforms']> extends TransformsConfig<
467+
any,
468+
any,
469+
any,
470+
any
471+
>
472+
? NonNullable<TCommands[K]['transforms']>
473+
: undefined
451474
>
452475
>;
453476
options?: TCommands[K]['options'];
@@ -620,7 +643,8 @@ interface CommandInput<
620643
* Helper type to extract command names from a commands record for
621644
* defaultHandler typing.
622645
*/
623-
type CommandNames<T> = T extends Record<infer K, AnyCommandConfig> ? K : never;
646+
type CommandNames<T> =
647+
T extends Record<infer K, AnyCommandConfigInput> ? K : never;
624648

625649
/**
626650
* Bargs config with commands (requires commands, allows defaultHandler).
@@ -644,7 +668,7 @@ type CommandNames<T> = T extends Record<infer K, AnyCommandConfig> ? K : never;
644668
type CommandsInput<
645669
TGlobalOptions extends OptionsSchema,
646670
TGlobalTransforms extends TransformsConfig<any, any, any, any> | undefined,
647-
TCommands extends Record<string, AnyCommandConfig>,
671+
TCommands extends Record<string, AnyCommandConfigInput>,
648672
> = {
649673
[K in keyof TCommands]: TCommands[K] extends CommandConfig<
650674
infer _TGlobalOpts,
@@ -659,14 +683,19 @@ type CommandsInput<
659683
CommandInput<
660684
TGlobalOptions,
661685
TGlobalTransforms,
662-
TCommands[K]['options'] extends OptionsSchema
663-
? TCommands[K]['options']
686+
NonNullable<TCommands[K]['options']> extends OptionsSchema
687+
? NonNullable<TCommands[K]['options']>
664688
: Record<string, never>,
665-
TCommands[K]['positionals'] extends PositionalsSchema
666-
? TCommands[K]['positionals']
689+
NonNullable<TCommands[K]['positionals']> extends PositionalsSchema
690+
? NonNullable<TCommands[K]['positionals']>
667691
: [],
668-
TCommands[K]['transforms'] extends TransformsConfig<any, any, any, any>
669-
? TCommands[K]['transforms']
692+
NonNullable<TCommands[K]['transforms']> extends TransformsConfig<
693+
any,
694+
any,
695+
any,
696+
any
697+
>
698+
? NonNullable<TCommands[K]['transforms']>
670699
: undefined
671700
>;
672701
};

test/command-inference.test.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,23 @@ describe('command handler type inference', () => {
1818
description: 'test command',
1919
handler: ({ values }) => {
2020
capturedValues = values;
21-
const g: string = values.global;
22-
const l: string = values.local;
21+
type IsAny<T> = 0 extends 1 & T ? true : false;
22+
type AssertFalse<_T extends false> = true;
23+
24+
// Compile-time guard: inline commands should not leak `any` into handler types.
25+
const _globalIsNotAny: AssertFalse<IsAny<typeof values.global>> =
26+
true;
27+
const _localIsNotAny: AssertFalse<IsAny<typeof values.local>> =
28+
true;
29+
30+
const g = values.global;
31+
const l = values.local;
2332
expect(typeof g, 'to equal', 'string');
2433
expect(typeof l, 'to equal', 'string');
2534
},
26-
options: {
35+
options: bargs.options({
2736
local: bargs.string({ default: 'l' }),
28-
},
37+
}),
2938
},
3039
},
3140
name: 'test',
@@ -57,7 +66,10 @@ describe('command handler type inference', () => {
5766
'to be true',
5867
);
5968
},
60-
positionals: [bargs.stringPos({ required: true }), bargs.stringPos()],
69+
positionals: bargs.positionals(
70+
bargs.stringPos({ required: true }),
71+
bargs.stringPos(),
72+
),
6173
},
6274
},
6375
name: 'test',

0 commit comments

Comments
 (0)