Skip to content

feat: 4 function helpers for accepting a known set of values bound to a typed value - #37

Merged
JordanMarr merged 6 commits into
JordanMarr:mainfrom
shayanhabibi:choices
Sep 8, 2026
Merged

feat: 4 function helpers for accepting a known set of values bound to a typed value#37
JordanMarr merged 6 commits into
JordanMarr:mainfrom
shayanhabibi:choices

Conversation

@shayanhabibi

@shayanhabibi shayanhabibi commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Similar to Input.allowOnlyFromAmong. For an option/arg whose legal values are a known set, each bound to a typed value. Just takes the ceremony out of mapping from a string to your typed value for simple cases.

The lowest level utility function takes a string comparer ontop of the sequence of string * type matches which is used in the parsing. A duplicate helper is for where the action input is a range of the values.

Each comes with a case sensitive, and case insensitive function without the string comparer input.

type Proj = { Name: string; Path: string }
let projects = [
    "main", { Name = "main", Path = "main.fsproj" }
]
let _: ActionInput<Proj> = Input.option<Proj> "--project" |> Input.acceptOnlyFromChoices projects
// cli --projects main | works
// cli --projects other | fails with "'other' is not a valid choice from: main"
let _: ActionInput<Proj list> = 
    Input.option<Proj list> "--project" |> Input.acceptManyFromChoices projects 
    |> Input.arity Arity.OneOrMore
// note: acceptManyFromChoices does no deduping
// Signatures
val acceptOnlyFromChoicesWith: StringComparer -> seq<string * 'T> -> ActionInput<'T> -> ActionInput<'T>
val acceptManyFromChoicesWith: StringComparer -> seq<string * 'T> -> ActionInput<'T list> -> ActionInput<'T list>
val acceptOnlyFromChoices: seq<string * 'T> -> ActionInput<'T> -> ActionInput<'T>
val acceptOnlyFromChoicesIgnoreCase: seq<string * 'T> -> ActionInput<'T> -> ActionInput<'T>
val acceptManyFromChoices: seq<string * 'T> -> ActionInput<'T list> -> ActionInput<'T list>
val acceptOnlyFromChoicesIgnoreCase: seq<string * 'T> -> ActionInput<'T list> -> ActionInput<'T list>

…typed value (acceptOnlyFromChoices, acceptOnlyFromChoicesWith, acceptManyFromChoices, acceptManyFromChoicesWith, acceptOnlyFromChoicesIgnoreCase, acceptManyFromChoicesIgnoreCase)

@JordanMarr JordanMarr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this! I really like the core idea. Collapsing "validate the string, then map it to a typed value" into a single step is a genuine improvement, and it pairs beautifully with discriminated unions:

type Env = Dev | Staging | Prod

option<Env> "--env" |> mapFromAmong [ "dev", Dev; "staging", Staging; "prod", Prod ]

I'd like to merge it with a few changes to keep the API surface small and consistent with the rest of the library.

1. Rename to mapFromAmong / mapFromAmongWith.
I try to keep Input helper names in parity with the S.CL member they wrap (setAction ~ SetAction, acceptOnlyFromAmong ~ AcceptOnlyFromAmong). This one has no S.CL counterpart, so the next best thing is a name that sits next to its sibling and explains the difference on sight: one accepts from among a set of strings, the other maps from among them to a typed value. The With suffix for the comparer overload follows FSharp.Core (List.sortWith, Seq.compareWith).

2. Drop the IgnoreCase pair.
mapFromAmongWith StringComparer.OrdinalIgnoreCase [...] covers it, and I'd rather have two functions than four. I'll add a README example showing the ignore-case usage.

3. Hold the Many variant for a follow-up PR.
Two reasons. First, there's an ordering bug: the fold builds the list with cons and never reverses it, so --project main --project tests yields [tests; main]. Second, it targets 'T list, and an omitted option<'T list> currently throws a NullReferenceException before the parser ever runs (that's a pre-existing issue in the library, not something you introduced, but it makes the Many helper a foot-gun unless paired with required). I'd rather land the single-value version cleanly now and think about the multi-value shape separately.

4. Use one code path.
Right now the case-sensitive branch goes through AcceptOnlyFromAmong, so a bad value produces S.CL's stock "Argument 'x' not recognized. Must be one of:" message, while the case-insensitive branch produces your custom "'x' is not a valid choice from: ..." message. The completion-sources branch already drives the <dev|staging|prod> help rendering, so I think you can drop the AcceptOnlyFromAmong call and the comparer.Equals("a", "A") heuristic entirely and always use completion sources + tryParse. Same behavior, one message, less code.

5. Tests and a README bullet.
A couple of NUnit cases in Tests/ (valid value, invalid value, ignore-case via mapFromAmongWith) plus a bullet under acceptOnlyFromAmong in the README's Input Properties section, ideally with a DU as the example.

Two tiny doc nits while you're in there: the summary says "Maps an option" but it works for arguments too, and the sentence about the parser closing over the table reads more like an implementation note than user docs.

Happy to answer questions on any of this. Thanks again for the contribution, and for the input { } work too, which I'm genuinely excited about.

@shayanhabibi

Copy link
Copy Markdown
Contributor Author

Should we lift a helper for completion sources? Maybe an addCompletionSource and/or addCompletionSources?
One other thing I don't like about this methodology, is that the default value factory has to set the parsed value; which doesn't look great when the typed value is a record. The help section still prints the record, and it just takes multiple lines. However, I'm not aware of how to specifically change that outside of overriding _.ToString.

Vis-a-vis the rest; no worries, thanks!

…g to typed value

Added tests; and bullet points in readme.md

Tests demonstrate failing, correct, and undefined behaviours.
@JordanMarr

Copy link
Copy Markdown
Owner

Merging as-is — thanks for turning the changes around so quickly! I'll fold a couple of tiny README touch-ups into main directly so we can skip another round. Let's leave the completion-source helper for a separate discussion if it comes up again.

@JordanMarr
JordanMarr merged commit 67031ec into JordanMarr:main Sep 8, 2026
JordanMarr added a commit that referenced this pull request Sep 8, 2026
- README example was missing the option name argument
- Drop the "tryParse overrides mapFromAmong" note, remarks, and the test
  asserting that undefined behaviour
- Reword doc comments with inline examples

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmkZPCobsju7AauvVH8du
shayanhabibi pushed a commit to shayanhabibi/FSharp.SystemCommandLine that referenced this pull request Sep 10, 2026
- README example was missing the option name argument
- Drop the "tryParse overrides mapFromAmong" note, remarks, and the test
  asserting that undefined behaviour
- Reword doc comments with inline examples

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmkZPCobsju7AauvVH8du
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants