This guide assumes you've read the first compiled-module post and are comfortable with
[Parameter],ValidateSet, andIDynamicParameters.
A cmdlet that does the right thing is necessary. A cmdlet that helps the user discover what to type is what makes the module feel like it belongs in the box. This post is about the parameter-binding features that get you there: argument completers, custom validators, and dynamic parameters.
Static ValidateSet When the Choices Are Known
The simplest case. The set is fixed at compile time:
[Parameter(Mandatory = true)]
[ValidateSet("Dev", "Test", "Prod")]
public string Environment { get; set; } = "";
Tab-completion works automatically. Casing is enforced unless you add IgnoreCase = true.
Dynamic ValidateSet When the Choices Come From a Live System
Most of the time, the choices aren't fixed they're "what databases exist on this server", "which subscriptions can this user see", "what queues are in this namespace". For those, write an IValidateSetValuesGenerator:
public sealed class WidgetNameValidator : IValidateSetValuesGenerator
{
public string[] GetValidValues()
{
// Called every time the parameter is parsed - keep it fast.
return WidgetCatalog.Default.GetNames().ToArray();
}
}
[Parameter(Mandatory = true)]
[ValidateSet(typeof(WidgetNameValidator))]
public string Name { get; set; } = "";
The same generator powers both validation and tab completion the user can hit Tab to cycle through valid widget names, and an invalid value gets a clear "the argument 'foo' does not belong to the set 'a,b,c'" error.
Cache aggressively.
GetValidValuesruns every time the parameter binds and at every Tab keystroke. A 200ms call here makes the shell feel broken.
Argument Completers More Power, More Responsibility
ValidateSet enforces a closed set. Sometimes you want completion suggestions without enforcing the set usernames, free-form paths, partial matches. Use IArgumentCompleter:
public sealed class WidgetNameCompleter : IArgumentCompleter
{
public IEnumerable<CompletionResult> CompleteArgument(
string commandName,
string parameterName,
string wordToComplete,
CommandAst commandAst,
IDictionary fakeBoundParameters)
{
var prefix = wordToComplete?.Trim('"', '\'') ?? "";
return WidgetCatalog.Default
.GetNames()
.Where(n => n.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.Take(50)
.Select(n => new CompletionResult(
completionText: n,
listItemText: n,
resultType: CompletionResultType.ParameterValue,
toolTip: $"Widget '{n}'"));
}
}
[Parameter(Mandatory = true)]
[ArgumentCompleter(typeof(WidgetNameCompleter))]
public string Name { get; set; } = "";
fakeBoundParameters is the gold here: it contains the values of other parameters as the user has typed them so far. That lets you build context-aware completion e.g. completing widget names for the resource group the user already typed:
public IEnumerable<CompletionResult> CompleteArgument(
string commandName, string parameterName, string wordToComplete,
CommandAst commandAst, IDictionary fakeBoundParameters)
{
var rg = fakeBoundParameters["ResourceGroup"]?.ToString();
if (string.IsNullOrEmpty(rg)) yield break;
foreach (var name in WidgetCatalog.ForGroup(rg).GetNames()
.Where(n => n.StartsWith(wordToComplete, StringComparison.OrdinalIgnoreCase)))
{
yield return new CompletionResult(name);
}
}
Completers run on every Tab. Wrap any expensive lookup in a short-lived cache (15–60 seconds) keyed on the inputs. The user notices a 100ms delay; they don't notice a stale value that's a minute old.
Quoting and Escaping
The four-argument CompletionResult constructor lets you control quoting. If your value contains spaces, return it pre-quoted:
yield return new CompletionResult(
completionText: $"'{name.Replace("'", "''")}'",
listItemText: name,
resultType: CompletionResultType.ParameterValue,
toolTip: name);
completionText is what gets inserted into the command line. listItemText is what shows in the menu. They diverge whenever the inserted form needs quoting that the menu doesn't.
Custom Validators Better Errors Than ValidatePattern
ValidatePattern works, but the error message it produces is basically "didn't match this regex" useless for users. Subclass ValidateArgumentsAttribute for control:
public sealed class ValidateWidgetNameAttribute : ValidateArgumentsAttribute
{
private static readonly Regex Allowed = new(@"^[a-z][a-z0-9-]{2,30}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
protected override void Validate(object arguments, EngineIntrinsics engineIntrinsics)
{
var s = arguments as string ?? throw new ValidationMetadataException(
"Widget name must be a string.");
if (s.Length < 3)
throw new ValidationMetadataException(
$"Widget name '{s}' is too short - minimum 3 characters.");
if (s.Length > 31)
throw new ValidationMetadataException(
$"Widget name '{s}' is too long - maximum 31 characters.");
if (!Allowed.IsMatch(s))
throw new ValidationMetadataException(
$"Widget name '{s}' must start with a lowercase letter and contain only lowercase letters, digits, and hyphens.");
}
}
[Parameter(Mandatory = true)]
[ValidateWidgetName]
public string Name { get; set; } = "";
Specific, actionable errors. The user knows why and how to fix it.
Transformers Normalize at the Boundary
Validators reject; transformers convert. Use one when the user can pass several shapes that you want normalized to one:
public sealed class WidgetIdTransformAttribute : ArgumentTransformationAttribute
{
public override object Transform(EngineIntrinsics engineIntrinsics, object inputData)
{
return inputData switch
{
Guid g => g,
string s when Guid.TryParse(s, out var g) => g,
string s when s.StartsWith("widget:")
&& Guid.TryParse(s["widget:".Length..], out var g) => g,
_ => throw new ArgumentTransformationMetadataException(
$"Cannot convert '{inputData}' to a widget id.")
};
}
}
[Parameter(Mandatory = true)]
[WidgetIdTransform]
public Guid Id { get; set; }
Now Get-Widget -Id some-guid, Get-Widget -Id 'widget:some-guid', and Get-Widget -Id $aGuid all work without the cmdlet body knowing.
Dynamic Parameters Parameters That Appear Conditionally
Some parameters only make sense when others are set. Static parameter sets cover the common case; dynamic parameters cover the rest e.g. "the -DatabaseName parameter only appears once you've passed -Server, and its valid set comes from that server."
[Cmdlet(VerbsCommon.Get, "RemoteWidget")]
public sealed class GetRemoteWidgetCmdlet : PSCmdlet, IDynamicParameters
{
[Parameter(Mandatory = true)]
public string Server { get; set; } = "";
private DynamicParameterDictionary? _dynamic;
public object? GetDynamicParameters()
{
if (string.IsNullOrEmpty(Server)) return null;
var names = ServerCatalog.For(Server).Databases;
var dict = new RuntimeDefinedParameterDictionary();
var attrs = new Collection<Attribute>
{
new ParameterAttribute { Mandatory = true, Position = 1 },
new ValidateSetAttribute(names.ToArray()),
};
dict.Add("Database", new RuntimeDefinedParameter("Database", typeof(string), attrs));
return dict;
}
protected override void ProcessRecord()
{
var dbValue = MyInvocation.BoundParameters.TryGetValue("Database", out var v)
? v?.ToString() ?? ""
: "";
WriteObject(new { Server, Database = dbValue });
}
}
The flow:
- The user types
Get-RemoteWidget -Server prod-01 -. - Powershell calls
GetDynamicParameterswith the value ofServeralready bound. - Your method returns a
RuntimeDefinedParameterDictionarydescribing-Databasewith aValidateSetpopulated from that server. - Tab completion now offers the right set of database names.
Dynamic parameters are stateful and binding-sensitive. They run on every parameter parse pass. Keep them deterministic and side-effect-free. Anything network-bound here needs heavy caching, or the shell will feel laggy.
Dynamic Parameters with IDynamicParameters and Inheritance
When several cmdlets need the same conditional parameter, factor the dictionary builder into a helper:
internal static class CommonDynamicParameters
{
public static RuntimeDefinedParameterDictionary ForServer(string server)
{
var dict = new RuntimeDefinedParameterDictionary();
var dbAttrs = new Collection<Attribute>
{
new ParameterAttribute { Mandatory = true, Position = 1 },
new ValidateSetAttribute(ServerCatalog.For(server).Databases.ToArray()),
new ArgumentCompleterAttribute(typeof(DatabaseCompleter)),
};
dict.Add("Database", new RuntimeDefinedParameter("Database", typeof(string), dbAttrs));
return dict;
}
}
Each cmdlet that needs -Database calls CommonDynamicParameters.ForServer(Server) from GetDynamicParameters. One source of truth for the contract.
Class-Wide Argument Completers (Module-Wide)
Powershell also supports module-scoped completers via Register-ArgumentCompleter. From a binary module, do this in IModuleAssemblyInitializer:
public sealed class ModuleInit : IModuleAssemblyInitializer
{
public void OnImport()
{
// Apply WidgetNameCompleter to every -Name parameter on every Get-* command in this module
var ps = System.Management.Automation.PowerShell.Create();
try
{
ps.AddCommand("Register-ArgumentCompleter")
.AddParameter("CommandName", new[] { "Get-Widget", "Remove-Widget", "Set-Widget" })
.AddParameter("ParameterName", "Name")
.AddParameter("ScriptBlock", ScriptBlock.Create(@"
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)
[MyOps.WidgetNameCompleter]::Complete($wordToComplete)
"));
ps.Invoke();
}
finally { ps.Dispose(); }
}
}
Useful when you want one source of truth for completion across many cmdlets without decorating each parameter.
Putting It Together A Real-World Combination
A Connect-Widget cmdlet that:
- Takes a
Server(free-form, completer suggests recently-used). - Then dynamically exposes
Database(validate set from the server). - Validates the database name with a transformer that strips an optional
db:prefix. - Has a
Profileparameter with a staticValidateSet.
[Cmdlet(VerbsCommon.Connect, "Widget")]
public sealed class ConnectWidgetCmdlet : PSCmdlet, IDynamicParameters
{
[Parameter(Mandatory = true, Position = 0)]
[ArgumentCompleter(typeof(RecentServerCompleter))]
public string Server { get; set; } = "";
[Parameter]
[ValidateSet("Dev","Test","Prod")]
public string Profile { get; set; } = "Dev";
public object? GetDynamicParameters() =>
string.IsNullOrEmpty(Server) ? null : CommonDynamicParameters.ForServer(Server);
protected override void ProcessRecord()
{
var db = (string?)MyInvocation.BoundParameters.GetValueOrDefault("Database");
WriteObject(new { Server, Database = db, Profile });
}
}
The user now gets:
Connect-Widget <Tab>recent server names.Connect-Widget prod-01 -<Tab>Database,Profile.Connect-Widget prod-01 -Database <Tab>only databases that exist onprod-01.Connect-Widget prod-01 -Database does-not-existclean validation error before the cmdlet body runs.
Zero of that is in the cmdlet's ProcessRecord. All of it is parameter-binding metadata.
What to Do Next
The parameter-binding layer is the most underused power in Powershell. A handful of small classes (completers, validators, transformers, dynamic-parameter dictionaries) turn a cmdlet from "type the right string and hope" into something that actively helps the user discover the right call. None of that logic belongs in ProcessRecord. All of it is metadata the binder runs before your cmdlet body even starts.
Three concrete moves on the next cmdlet you ship:
- Add an
IArgumentCompleterto one parameter that takes a name. Server names, database names, queue names, file paths inside a project root pick whichever one you have already and wire it up. Tab completion is the single highest-impact thing you can add to a cmdlet UX-wise. - Replace string parameters with custom transformer attributes for anything that should accept multiple shapes (path-or-FileInfo, DN-or-AD-object, name-or-Guid). The transformer runs once at bind time so the body of the cmdlet only ever deals with one type.
- Use
IDynamicParametersfor parameters that depend on other parameters. When-Databaseonly makes sense if-Serveris already bound, a dynamic parameter dictionary is the right answer it disappears fromGet-Helpuntil the prerequisite is set, which prevents wrong combinations rather than diagnosing them after the fact.
Pairs naturally with the publish and sign post (because a great UX still needs to be installable) and the Pester tests post (because tab completion and validators are exactly the kind of thing that quietly breaks in refactors).


