Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Option's type parameter types the option too optimistically. #2437

Open
pillowfication opened this issue Oct 18, 2024 · 2 comments
Open

Option's type parameter types the option too optimistically. #2437

pillowfication opened this issue Oct 18, 2024 · 2 comments

Comments

@pillowfication
Copy link

pillowfication commented Oct 18, 2024

When creating an option, specifying a type gives TypeScript some extra type info that is very useful in the command's handler:

import yargs from 'yargs'
import { inspect } from 'util'

yargs()
  .command({
    command: 'cmd',
    builder: yargs => yargs
      .option('string', { type: 'string' })
      .option('number', { type: 'number' })
      .option('boolean', { type: 'boolean' })
      .option('count', { type: 'count' })
      .option('array', { type: 'array' }),
    handler: ({ string, number, boolean, count, array }) => {
      console.log('string:', inspect(string))   // Has type `string | undefined`
      console.log('number:', inspect(number))   // Has type `number | undefined`
      console.log('boolean:', inspect(boolean)) // Has type `boolean | undefined`
      console.log('count:', inspect(count))     // Has type `number`
      console.log('array:', inspect(array))     // Has type `(string | number)[] | undefined`
    }
  })
  .parse(process.argv.slice(2))

However, yargs doesn't always cast the value to what it says it will be. In the above example, it is still possible that the value of option string is a boolean, despite it being typed as string | undefined. This mismatch usually occurs when the option is specified multiple times, turning it into an array, or when the option is prefixed with --no-*, turning it into a boolean.

Type Expected type Could also be Example
'string' string | undefined boolean cmd --no-string
- - Array<string> cmd --string="" --string=""
- - Array<string | boolean> cmd --string="" --no-string
'number' number | undefined Array<number> cmd --number=0 --number=0
'boolean' boolean | undefined (Seems ok)
'count' number (Seems ok)
'array' (string | number)[] | undefined Array<string | number | boolean> cmd --array="" --array=0 --no-array

This has led to some unexpected runtime errors when a user unintentionally specifies an option twice.

yargs()
  .command({
    command: 'greet',
    builder: yargs => yargs
      .option('name', { alias: ['n'], type: 'string' }),
    handler: ({ name }) => {
      console.log(`Hello, ${name?.toUpperCase() ?? 'stranger'}!`)
    }
  })
  .parse(process.argv.slice(2))
> node .\script.js greet --name Bob --name Bob
TypeError: name?.toUpperCase is not a function

> node .\script.js greet -nname Bob
TypeError: name?.toUpperCase is not a function
@shadowspawn
Copy link
Member

The types available for client use are from DefinitelyTyped, and maintained separately. You might want to open an issue/discussion over there too.

If you are not using those features, you could restrict the option processing to be less flexible (which would then match the types):

@pillowfication
Copy link
Author

pillowfication commented Oct 24, 2024

Since 'duplicate-arguments-array' isn't an option for me yet (#2438), the best I've gotten so far is adding a coerce method to every single option I have.

// It's important that `coerce` remains typed as `undefined`,
// otherwise the options will be typed as `unknown` in the handler
function createType (type: 'string'): { type: 'string' }
function createType (type: 'number'): { type: 'number' }
function createType (type: 'boolean'): { type: 'boolean' }
function createType (type: 'string[]'): { type: 'string', array: true }
function createType (type: 'number[]'): { type: 'number', array: true }
function createType (type: 'boolean[]'): { type: 'boolean', array: true }
function createType (type: 'string' | 'number' | 'boolean' | 'string[]' | 'number[]' | 'boolean[]'): unknown {
  switch (type) {
    case 'string':
      return {
        type: 'string',
        coerce: (opt: unknown): string => String(Array.isArray(opt) ? opt[opt.length - 1] : opt)
      }
    case 'number':
      return {
        type: 'number',
        coerce: (opt: unknown): number => Number(Array.isArray(opt) ? opt[opt.length - 1] : opt)
      }
    case 'boolean':
      return {
        type: 'boolean',
        coerce: (opt: unknown): boolean => Boolean(Array.isArray(opt) ? opt[opt.length - 1] : opt)
      }
    case 'string[]':
      return {
        type: 'string',
        array: true,
        coerce: (opt: unknown[]): string[] => opt.map(String)
      }
    case 'number[]':
      return {
        type: 'number',
        array: true,
        coerce: (opt: unknown[]): number[] => opt.map(Number)
      }
    case 'boolean[]':
      return {
        type: 'boolean',
        array: true,
        coerce: (opt: unknown[]): boolean[] => opt.map(Boolean)
      }
  }
}
yargs()
  .command({
    command: 'cmd <pos...>',
    builder: yargs => yargs
      .positional('pos', { array: true })
      .option('str', { ...createType('string') })
      .option('str-arr', { ...createType('string[]') })
      .option('num', { ...createType('number') })
      .option('num-arr', { ...createType('number[]') }),
    handler: args => { console.log(args) }
  })
  .parse(process.argv.slice(2))

This has been sufficient for me to get types to match up correctly, however there are still ways to get around it depending on your parserConfiguration. The coerce methods here would need to be made more rigorous if you use the following features:

  • 'duplicate-arguments-array': true
    Options could become arrays when they are specified multiple times. The above coerce functions should handle this.
  • 'boolean-negation': true
    Options could become booleans when the --no prefix is used. The above coerce functions should handle this.
  • 'dot-notation': true
    Options could be objects, and objects could appear inside arrays. (E.g. cmd --foo "foo" --foo.bar "bar").
  • 'flatten-duplicate-arrays': false
    Options could be jagged arrays, so arrays inside arrays need to be handled.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants