@gunshi/docs 0.27.0-beta.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.
@@ -0,0 +1,561 @@
1
+ # Type System
2
+
3
+ Gunshi v0.27 introduces a powerful type parameter system that provides comprehensive type safety across all core functions: `cli`, `define`, `lazy`, and `plugin`.
4
+
5
+ This enhancement brings TypeScript's full type-checking capabilities to your CLI applications, ensuring compile-time safety for command arguments and plugin extensions.
6
+
7
+ This guide focuses on type safety for command definitions and their arguments. If you're creating custom plugins and need to understand the `plugin` function's type system, refer to the [Plugin Type System](../plugin/type-system.md) guide.
8
+
9
+ <!-- eslint-disable markdown/no-missing-label-refs -->
10
+
11
+ > [!NOTE]
12
+ > Some code examples in this guide include TypeScript file extensions (`.ts`) in `import`/`export` statements. If you use this pattern in your plugin, you'll need to enable `allowImportingTsExtensions` in your `tsconfig.json`.
13
+
14
+ <!-- eslint-enable markdown/no-missing-label-refs -->
15
+
16
+ ## Overview of v0.27 Type System
17
+
18
+ The v0.27 release provides comprehensive type safety through:
19
+
20
+ - **Core Functions**: `define` and `lazy` provide excellent type inference for standard commands
21
+ - **Plugin Extension Support**: `defineWithTypes` and `lazyWithTypes` enable type declarations for plugin extensions
22
+ - **Unified Type System (GunshiParams)**: A coherent type system for arguments and extensions
23
+ - **CLI Entry Point Types**: Type parameters for the `cli` function's entry command
24
+ - **Enhanced TypeScript Inference**: Automatic type inference reduces boilerplate while maintaining safety
25
+
26
+ ## Understanding `GunshiParams`
27
+
28
+ `GunshiParams` is the core type that provides type safety for command arguments and plugin extensions. At its simplest, it ensures that your command context has properly typed `values` (from args) and `extensions` (from plugins).
29
+
30
+ Before diving into the implementation details, let's start with a simple example that demonstrates how `GunshiParams` provides type safety for command arguments:
31
+
32
+ ```ts
33
+ // Simple usage - just arguments
34
+ type SimpleParams = GunshiParams<{
35
+ args: { port: { type: 'number' } }
36
+ }>
37
+
38
+ // With extensions from plugins
39
+ type FullParams = GunshiParams<{
40
+ args: { port: { type: 'number' } }
41
+ extensions: { logger: Logger }
42
+ }>
43
+ ```
44
+
45
+ The actual type definition uses TypeScript's conditional types to provide flexibility:
46
+
47
+ <<< ../../../../gunshi/src/types.ts#snippet
48
+
49
+ ## Core Functions: `define` and `lazy`
50
+
51
+ ### The `define` Function
52
+
53
+ The `define` function is the primary way to create commands in Gunshi. For most use cases, the basic usage with automatic type inference is sufficient. Advanced usage with type parameters is useful when you need explicit type control or are working with complex type definitions.
54
+
55
+ #### Basic Usage
56
+
57
+ When your command doesn't use plugin extensions, `define` provides excellent automatic type inference:
58
+
59
+ ```ts
60
+ import { define } from 'gunshi'
61
+
62
+ // Standard command definition with automatic type inference
63
+ export const serverCommand = define({
64
+ name: 'server',
65
+ description: 'Start the development server',
66
+ args: {
67
+ port: { type: 'number', default: 3000 },
68
+ host: { type: 'string', default: 'localhost' },
69
+ verbose: { type: 'boolean', short: 'V' }
70
+ },
71
+ run: ctx => {
72
+ // ctx.values is automatically inferred as { port?: number; host?: string; verbose?: boolean }
73
+ const { port, host, verbose } = ctx.values
74
+
75
+ console.log(`Starting server on ${host}:${port}`)
76
+ if (verbose) {
77
+ console.log('Verbose mode enabled')
78
+ }
79
+ }
80
+ })
81
+ ```
82
+
83
+ The basic usage covers most scenarios where you're defining commands with inline arguments and don't need to share type definitions across multiple commands.
84
+
85
+ #### Advanced Usage with Type Parameters
86
+
87
+ When you need explicit type control or are working with pre-defined argument configurations, `define` accepts `GunshiParams` compatible type parameter:
88
+
89
+ ```ts [commands/server.ts]
90
+ import { define } from 'gunshi'
91
+
92
+ // Define args separately
93
+ export const serverArgs = {
94
+ port: { type: 'number', default: 3000 },
95
+ host: { type: 'string', default: 'localhost' }
96
+ } as const
97
+
98
+ export const serverCommand = define<{ args: typeof serverArgs }>({
99
+ name: 'server',
100
+ args: serverArgs,
101
+ run: ctx => {
102
+ // ctx.values is typed based on ServerParams
103
+ console.log(`Server: ${ctx.values.host}:${ctx.values.port}`)
104
+ }
105
+ })
106
+ ```
107
+
108
+ <!-- eslint-disable markdown/no-missing-label-refs -->
109
+
110
+ > [!TIP]
111
+ > The example fully code is [here](https://github.com/kazupon/gunshi/tree/main/playground/advanced/type-system/define).
112
+
113
+ <!-- eslint-enable markdown/no-missing-label-refs -->
114
+
115
+ This advanced approach is particularly useful when you want to reuse argument definitions across multiple commands or need to export types for use in other modules.
116
+
117
+ ### The `lazy` Function
118
+
119
+ The `lazy` function enables code-splitting while maintaining type safety. Like `define`, it works well with automatic type inference for most cases. Advanced usage provides explicit type control when needed.
120
+
121
+ #### Basic Usage
122
+
123
+ For commands without plugin extensions, `lazy` automatically infers types from your definition:
124
+
125
+ ```js
126
+ import { lazy } from 'gunshi'
127
+
128
+ // Lazy-loaded command with automatic type inference
129
+ export const buildCommand = lazy(
130
+ async () => {
131
+ // Heavy dependencies can be loaded here when needed
132
+ const { build } = await import('./build.js')
133
+
134
+ return async ctx => {
135
+ // ctx.values is automatically inferred from args definition below
136
+ const { target, minify } = ctx.values
137
+
138
+ console.log(`Building for ${target}...`)
139
+ if (minify) {
140
+ console.log('Minification enabled')
141
+ }
142
+
143
+ return build({ target, minify })
144
+ }
145
+ },
146
+ {
147
+ name: 'build',
148
+ description: 'Build the project',
149
+ args: {
150
+ target: { type: 'string', required: true, choices: ['dev', 'prod'] },
151
+ minify: { type: 'boolean', default: false }
152
+ }
153
+ }
154
+ )
155
+ ```
156
+
157
+ This basic usage is ideal for most lazy-loaded commands where you want to defer loading dependencies until the command is actually executed.
158
+
159
+ #### Advanced Usage with Pre-defined Arguments
160
+
161
+ When working with pre-defined argument configurations or when you need to explicitly type your command runner, you can structure your lazy command as follows:
162
+
163
+ ```ts [commands/build.ts]
164
+ import { lazy } from 'gunshi'
165
+
166
+ // Pre-defined arguments
167
+ export const buildArgs = {
168
+ target: { type: 'enum', required: true, choices: ['dev', 'prod'] },
169
+ minify: { type: 'boolean', default: false }
170
+ } as const
171
+
172
+ // Create the lazy command with explicit typing
173
+ export const buildCommand = lazy<{ args: typeof buildArgs }>(
174
+ async () => {
175
+ // Heavy dependencies can be loaded here when needed
176
+ const { bundle } = await import('./utils.ts')
177
+
178
+ return async ctx => {
179
+ // Inference of args values
180
+ const { target, minify } = ctx.values
181
+ // Implementation
182
+ return bundle({ target, minify })
183
+ }
184
+ },
185
+ {
186
+ name: 'build',
187
+ args: buildArgs
188
+ }
189
+ )
190
+ ```
191
+
192
+ <!-- eslint-disable markdown/no-missing-label-refs -->
193
+
194
+ > [!TIP]
195
+ > The example fully code is [here](https://github.com/kazupon/gunshi/tree/main/playground/advanced/type-system/lazy).
196
+
197
+ <!-- eslint-enable markdown/no-missing-label-refs -->
198
+
199
+ ## Plugin Extensions and Architectural Constraints
200
+
201
+ ### Understanding the Timing Constraint
202
+
203
+ Gunshi's architecture intentionally separates command definition from plugin installation:
204
+
205
+ 1. **Command Definition Time**: When you call `define()` or `lazy()` in your code, plugins haven't been installed yet
206
+ 2. **CLI Execution Time**: When `cli()` runs, plugins are installed and extensions become available
207
+ 3. **The Gap**: Commands can't know what extensions will be available at definition time
208
+
209
+ This architectural design enables flexible command configuration but creates a challenge: how can commands safely use plugin extensions that don't exist yet?
210
+
211
+ ### The Solution: Type Declaration Functions
212
+
213
+ `defineWithTypes` and `lazyWithTypes` solve this by allowing you to declare expected extensions at command definition time. These functions provide type safety for plugin extensions that will be available when the command actually runs.
214
+
215
+ This separation is crucial because:
216
+
217
+ - It maintains modularity - commands don't depend on specific plugin implementations
218
+ - It allows flexible plugin configuration - different CLI instances can use different plugins
219
+ - It enables type safety - TypeScript knows what extensions to expect even though they're not yet available
220
+
221
+ ## Functions for Plugin Extensions: `defineWithTypes` and `lazyWithTypes`
222
+
223
+ When your command needs to use plugin extensions, use these specialized functions that allow you to declare expected extension types.
224
+
225
+ ### The `defineWithTypes` Function
226
+
227
+ Use `defineWithTypes` when your command needs plugin extensions. It uses a currying approach where you specify the extensions type, and args are automatically inferred:
228
+
229
+ ```ts [commands/server.ts]
230
+ import { defineWithTypes } from 'gunshi'
231
+ import type { AuthExtension } from '../plugin.ts'
232
+
233
+ // Define your extensions type
234
+ type ServerExtensions = {
235
+ auth: AuthExtension
236
+ }
237
+
238
+ // Use defineWithTypes - specify only extensions, args are inferred!
239
+ export const serverCommand = defineWithTypes<{ extensions: ServerExtensions }>()({
240
+ name: 'server',
241
+ description: 'Start the development server',
242
+ args: {
243
+ port: { type: 'number', default: 3000 },
244
+ host: { type: 'string', default: 'localhost' },
245
+ verbose: { type: 'boolean', short: 'V' }
246
+ },
247
+ run: ctx => {
248
+ // ctx.values is automatically inferred as { port?: number; host?: string; verbose?: boolean }
249
+ const { port, host, verbose } = ctx.values
250
+
251
+ // ctx.extensions is typed as ServerExtensions
252
+ // Optional chaining (?.) is used because plugins may not be installed
253
+ if (!ctx.extensions.auth?.isAuthenticated()) {
254
+ throw new Error('Please login first')
255
+ }
256
+
257
+ const user = ctx.extensions.auth?.getUser()
258
+ console.log(`Server started by ${user.name} on ${host}:${port}`)
259
+
260
+ if (verbose) {
261
+ console.log('Verbose mode enabled')
262
+ }
263
+ }
264
+ })
265
+ ```
266
+
267
+ <!-- eslint-disable markdown/no-missing-label-refs -->
268
+
269
+ > [!TIP]
270
+ > The example fully code is [here](https://github.com/kazupon/gunshi/tree/main/playground/advanced/type-system/define-with-types).
271
+
272
+ <!-- eslint-enable markdown/no-missing-label-refs -->
273
+
274
+ #### Flexible Type Parameters
275
+
276
+ `defineWithTypes` supports multiple scenarios for different use cases:
277
+
278
+ ```ts
279
+ // Case 1: Extensions only (most common)
280
+ // You want to use plugin extensions in your CommandRunner implementation.
281
+ // Args are automatically inferred from the inline definition
282
+ const cmd1 = defineWithTypes<{ extensions: MyExtensions }>()({
283
+ name: 'server',
284
+ args: { port: { type: 'number' } }, // args types are inferred
285
+ run: ctx => {
286
+ // ctx.extensions is typed, ctx.values is inferred from args
287
+ /* ... */
288
+ }
289
+ })
290
+
291
+ // Case 2: Args only (when using pre-defined args)
292
+ // Your args are defined in a variable, not inline in the define function.
293
+ // Pre-defined args variables need explicit type specification for proper inference
294
+ const myArgs = {
295
+ port: { type: 'number' },
296
+ host: { type: 'string' }
297
+ } as const
298
+
299
+ const cmd2 = defineWithTypes<{ args: typeof myArgs }>()({
300
+ name: 'process',
301
+ args: myArgs, // Using pre-defined args variable
302
+ run: ctx => {
303
+ // ctx.values is typed based on myArgs
304
+ /* ... */
305
+ }
306
+ })
307
+
308
+ // Case 3: Both args and extensions (special case)
309
+ // You need plugin extensions AND you're using pre-defined args variables
310
+ const cmd3 = defineWithTypes<{
311
+ args: typeof myArgs
312
+ extensions: MyExtensions
313
+ }>()({
314
+ name: 'hybrid',
315
+ args: myArgs, // Using pre-defined args variable
316
+ run: ctx => {
317
+ // Both ctx.values and ctx.extensions are typed
318
+ /* ... */
319
+ }
320
+ })
321
+ ```
322
+
323
+ ### The `lazyWithTypes` Function
324
+
325
+ The `lazyWithTypes` function maintains type safety for lazy-loaded commands that use plugin extensions:
326
+
327
+ ```ts [commands/build.ts]
328
+ import { lazyWithTypes } from 'gunshi'
329
+ import type { LoggerExtension } from '../plugin.ts'
330
+
331
+ export const buildArgs = {
332
+ target: { type: 'enum', required: true, choices: ['dev', 'prod'] },
333
+ minify: { type: 'boolean', default: false }
334
+ } as const
335
+
336
+ // Define extensions for the command
337
+ type BuildExtensions = {
338
+ logger: LoggerExtension
339
+ }
340
+
341
+ // Use lazyWithTypes with extensions - args are automatically inferred
342
+ export const buildCommand = lazyWithTypes<{
343
+ args: typeof buildArgs
344
+ extensions: BuildExtensions
345
+ }>()(
346
+ async () => {
347
+ // Heavy dependencies can be loaded here when needed
348
+ return async ctx => {
349
+ // ctx.values is automatically inferred from args definition below
350
+ const { target, minify } = ctx.values
351
+
352
+ // Use typed extensions
353
+ ctx.extensions.logger?.log(`Building for ${target}...`)
354
+
355
+ if (minify) {
356
+ ctx.extensions.logger?.log('Minification enabled')
357
+ }
358
+ }
359
+ },
360
+ {
361
+ name: 'build',
362
+ description: 'Build the project',
363
+ args: buildArgs
364
+ }
365
+ )
366
+ ```
367
+
368
+ <!-- eslint-disable markdown/no-missing-label-refs -->
369
+
370
+ > [!TIP]
371
+ > The example fully code is [here](https://github.com/kazupon/gunshi/tree/main/playground/advanced/type-system/lazy-with-types).
372
+
373
+ <!-- eslint-enable markdown/no-missing-label-refs -->
374
+
375
+ #### Flexible Type Parameters
376
+
377
+ Similar to `defineWithTypes`, `lazyWithTypes` supports flexible type parameters:
378
+
379
+ <!-- eslint-skip -->
380
+
381
+ ```ts
382
+ // Most common: specify only extensions
383
+ lazyWithTypes<{ extensions: BuildExtensions }>()( ... )
384
+
385
+ // When using pre-defined args: specify args explicitly
386
+ lazyWithTypes<{ args: typeof buildArgs }>()( ... )
387
+
388
+ // Full control: specify both (when using pre-defined args AND extensions)
389
+ lazyWithTypes<{ args: typeof buildArgs; extensions: BuildExtensions }>()( ... )
390
+ ```
391
+
392
+ ## The `cli` Function Type Parameters
393
+
394
+ The `cli` function provides type safety for your entry command. The type parameter defines the type for the entry command's context, determining the types of `ctx.values` and `ctx.extensions` that the entry command's runner function receives:
395
+
396
+ ```ts
397
+ import { cli } from 'gunshi'
398
+ import logger from './plugin.ts'
399
+
400
+ import type { LoggerExtension } from './plugin.ts'
401
+
402
+ const entryArgs = {
403
+ verbose: { type: 'boolean', short: 'V' },
404
+ output: { type: 'string', default: 'json' }
405
+ } as const
406
+
407
+ // `cli` function with type-safe `args` and `extensions`
408
+ await cli<{ args: typeof entryArgs; extensions: { logger: LoggerExtension } }>(
409
+ process.argv.slice(2),
410
+ {
411
+ name: 'main',
412
+ description: 'CLI with type-safe extensions',
413
+ args: entryArgs,
414
+ run: async ctx => {
415
+ if (ctx.values.verbose) {
416
+ ctx.extensions.logger?.log(`Processing in verbose mode...`)
417
+ }
418
+ console.log(`Output format: ${ctx.values.output}`)
419
+ }
420
+ },
421
+ {
422
+ name: 'mycli',
423
+ version: '1.0.0',
424
+ plugins: [logger()]
425
+ }
426
+ )
427
+ ```
428
+
429
+ ## Combining Multiple Plugin Types
430
+
431
+ When working with multiple plugins, you often need to combine their extension types to create a comprehensive type definition for your commands. TypeScript's intersection operator (`&`) provides a clean way to merge multiple plugin extension types.
432
+
433
+ ### Using TypeScript's Intersection Operator (&)
434
+
435
+ The `&` operator creates an intersection type that combines multiple type definitions. This is particularly useful when your command needs to access extensions from multiple plugins.
436
+
437
+ #### With Official Gunshi Plugins
438
+
439
+ When using official Gunshi plugins that provide plugin IDs, combine their extensions using Record types. Let's break this down step by step:
440
+
441
+ **Step 1: Import Plugin IDs and Types**
442
+
443
+ First, import the plugin IDs and extension types from the official plugins. Each plugin provides a unique identifier and type definition:
444
+
445
+ ```ts
446
+ // Import plugin identifiers
447
+ import { pluginId as globalId } from '@gunshi/plugin-global'
448
+ import { pluginId as rendererId } from '@gunshi/plugin-renderer'
449
+ import { pluginId as i18nId } from '@gunshi/plugin-i18n'
450
+
451
+ // Import type definitions
452
+ import type { GlobalExtension, PluginId as GlobalId } from '@gunshi/plugin-global'
453
+ import type { UsageRendererExtension, PluginId as RendererId } from '@gunshi/plugin-renderer'
454
+ import type { I18nExtension, PluginId as I18nId } from '@gunshi/plugin-i18n'
455
+ ```
456
+
457
+ **Step 2: Combine Extension Types**
458
+
459
+ Next, create a combined type that includes all the plugin extensions your command will use. The Record types map each plugin ID to its corresponding extension:
460
+
461
+ ```ts
462
+ // Combine multiple plugin extension types using intersection (&)
463
+ type CombinedExtensions = Record<GlobalId, GlobalExtension> &
464
+ Record<RendererId, UsageRendererExtension> &
465
+ Record<I18nId, I18nExtension>
466
+ ```
467
+
468
+ **Step 3: Define the Command with Combined Extensions**
469
+
470
+ Finally, use `defineWithTypes` to create a command that can access all the combined plugin extensions with full type safety:
471
+
472
+ ```ts
473
+ // Use defineWithTypes with combined extensions
474
+ export default defineWithTypes<{ extensions: CombinedExtensions }>()({
475
+ name: 'greet',
476
+ args: {
477
+ name: { type: 'string', required: true }
478
+ },
479
+ run: async ctx => {
480
+ // Access i18n plugin extension
481
+ const locale = ctx.extensions[i18nId]?.locale
482
+ if (locale) {
483
+ console.log(`Current locale: ${locale.toString()}`)
484
+ }
485
+
486
+ // Access renderer plugin extension
487
+ const message = ctx.extensions[rendererId]?.text('welcome')
488
+ if (message) {
489
+ console.log(message)
490
+ }
491
+
492
+ // Access environment properties directly on context (not through extensions)
493
+ console.log(`Running in ${ctx.env.name || 'unknown'} environment`)
494
+ }
495
+ })
496
+ ```
497
+
498
+ #### With Custom Plugins
499
+
500
+ Custom plugins provide plugin IDs and types. Import both to create fully type-safe command definitions:
501
+
502
+ ```ts [commands/query.ts]
503
+ import { defineWithTypes } from 'gunshi'
504
+ import { pluginId as authId } from '../plugins/auth.ts'
505
+ import { pluginId as databaseId } from '../plugins/database.ts'
506
+ import { pluginId as loggerId } from '../plugins/logger.ts'
507
+
508
+ import type { AuthExtension, PluginId as AuthId } from '../plugins/auth.ts'
509
+ import type { DatabaseExtension, PluginId as DatabaseId } from '../plugins/database.ts'
510
+ import type { LoggerExtension, PluginId as LoggerId } from '../plugins/logger.ts'
511
+
512
+ type CombinedExtensions = Record<LoggerId, LoggerExtension> &
513
+ Record<AuthId, AuthExtension> &
514
+ Record<DatabaseId, DatabaseExtension>
515
+
516
+ export default defineWithTypes<{ extensions: CombinedExtensions }>()({
517
+ name: 'query',
518
+ description: 'Query database tables',
519
+ args: {
520
+ table: { type: 'string', required: true }
521
+ },
522
+ run: async ctx => {
523
+ // All extensions and arguments are fully typed
524
+ ctx.extensions[loggerId]?.log(`Querying ${ctx.values.table}`)
525
+
526
+ // Check read permissions for the specific table
527
+ if (!ctx.extensions[authId]?.hasPermission('read', ctx.values.table)) {
528
+ throw new Error(`No read access to table: ${ctx.values.table}`)
529
+ }
530
+
531
+ // Perform the query using the database extension
532
+ const dataset = await ctx.extensions[databaseId]?.query(ctx.values.table)
533
+ console.log(`Retrieved records from ${ctx.values.table}`, dataset)
534
+ }
535
+ })
536
+ ```
537
+
538
+ <!-- eslint-disable markdown/no-missing-label-refs -->
539
+
540
+ > [!TIP]
541
+ > The example fully code is [here](https://github.com/kazupon/gunshi/tree/main/playground/advanced/type-system/combine).
542
+
543
+ <!-- eslint-enable markdown/no-missing-label-refs -->
544
+
545
+ Both approaches achieve the same goal: combining multiple plugin extensions with full type safety. Use Record types when working with official plugins that provide plugin IDs, and direct intersection types for custom plugins.
546
+
547
+ ## Choosing the Right Function
548
+
549
+ ### Use `define` or `lazy` when:
550
+
551
+ - Your command doesn't use plugin extensions
552
+ - You only need type safety for command arguments
553
+ - This is the most common use case
554
+
555
+ ### Use `defineWithTypes` or `lazyWithTypes` when:
556
+
557
+ - Your command uses plugin extensions
558
+ - You need to declare expected extension types
559
+ - You want IntelliSense for plugin APIs
560
+
561
+ The choice is straightforward: if you need plugin extensions, use the `WithTypes` variants; otherwise, use the standard functions for simpler, cleaner code.