PowerShell classes with single-argument constructors do not validate data type

I'm designing a module and using classes to type-validate my parameters. I noticed that, when attempting to type-validate input parameters, a class with a single-argument constructor appears to act as a type accelerator instead of validating data type.

Example:

Class stack {
  $a
  $b
  stack($inp) {
    $this.a = $inp
    $this.b = 'anything'
  }
}

function foo {
  Param(
    [stack]$bar
  )
  $bar
}

PS>foo -bar 'hello'
a      b
-      -
hello  anything

$bar has been type accelerated into an instantiation of stack.

Compare this to the same class with a constructor that takes 2 arguments:

Class stack {
  $a
  $b
  stack($inp,$inp2) {
    $this.a = $inp
    $this.b = 'anything'
  }
}

function foo {
  Param(
    [stack]$bar
  )
  $bar
}

PS>foo -bar 'hello'
foo : Cannot process argument transformation on parameter 'bar'. Cannot convert the "hello" value of type "System.String" to type "stack".

Now the class type is correctly validating the input parameter.

I first saw this in PS5.1 on Windows 10, but I just tried it on my private laptop with pwsh 7.2.1 and seems to be the same.

Is there a workaround to this behavior? Is it a bug?

Edit: Well, after further testing, I realized this also happens if I supply 2 input parameters for the constructor with 2 arguments, e.g., foo -bar 'hello' 'world'. So I guess it's probably intended, and I'm doing something wrong. Can I use classes to validate my data types for input parameters? How?


Solution 1:

What you're seeing is unrelated to type accelerators, which are simply short alias names for .NET type names; e.g., [regex] is short for [System.Text.RegularExpressions.Regex].

Instead, you're seeing PowerShell's flexible automatic type conversions, which include translating casts (e.g. [stack] ...) and type constraints (ditto, in the context of an assignment or inside a param(...) block) into constructor calls or ::Parse() calls, as explained in this answer.

  • Therefore, given that your [stack] class has a (non-type-constrained) single-argument constructor, something like [stack] 'hello' is automatically translated into [stack]::new('hello'), i.e. a constructor call - and that is also what happens when you pass argument 'hello' to a parameter whose type is [stack].

I suggest not fighting these automatic conversions, as they are usually helpful.

In the rare event that you do need to ensure that the type of the argument passed is exactly of the type specified in the parameter declaration (or of a derived type), you can use the following technique (using type [datetime] as an example, whose full .NET type name is System.DateTime):

function Foo {

  param(
    # Ensure that whatever argument is passed is already of type [datetime]
    [PSTypeName('System.DateTime')]
    $Bar
  )
  
  "[$Bar]"
}

Kudos to you for discovering the [PSTypeName()] attribute for this use case.

  • Without the [PSTypeName(...)] attribute, a call such as Foo 1/1/1970 would work, because the string '1/1/1970' is automatically converted to [datetime] by PowerShell.

  • With the [PSTypeName(...)] attribute, only an actual [datetime] argument is accepted (or, for types that can be sub-classed, an instance of a type derived from the specified type).

    • Important: Specify the target type's full .NET type name (e.g. 'System.DateTime' rather than just 'datetime') to target it unambiguously.

      • However, for PowerShell custom classes, their name is the full name (they are not inside a namespace), so in the case of your [stack] class, the attribute would be [PSTypeName('stack')]
    • Any type name is accepted, even if it doesn't refer to an existing .NET type or custom class, and any such non-existent type would require an argument to use a matching virtual ETS (PowerShell's Extended Type System) type name. In fact, supporting such virtual type names is the primary purpose of this attribute.[1] E.g., if [PSTypeName('Bar')] were used, you could pass a custom object with an ETS type name of Bar as follows:
      [pscustomobject] @{ PSTypeName = 'Bar'; Baz = 'quux' }


[1] To quote from the linked docs (emphasis added): "This attribute is used to restrict the type name of the parameter, when the type goes beyond the .NET type system."

Solution 2:

If you really want to validate the passed type you need to actually validate, not just cast the input as a specific type.

function foo {
Param(
    [ValidateScript({$_ -is [stack]})]
    $bar
)
    $bar
}

Doing this will not try to cast the input as a specific type, and will fail if the input type is wrong.

Solution 3:

Your 'foo' function requires a [stack] argument, it doesn't create one.

So your call should be as follow:

foo -bar ([stack]::new('fun:foo','hello'))

I don't know exactly how you will use it but if the goal is to validate arguments, I would suggest to specify the types everywhere... here is a small example but it can be improved, just an example:

Class stack {
    [string]$a
    [string]$b
    stack([string]$inp,[string]$inp2) {
        $this.a = $inp
        $this.b = $inp2
    }
}

function foo {
    Param([stack]$bar)
    $bar
}


function foo2 {
  Param([array]$bar)
  [stack]::new($bar[0],$bar[1])
}


foo -bar ([stack]::new('fun:foo','hello'))
foo2 -bar 'fun:foo2','hello'
foo2 -bar @('fun:foo2','hello2')

Solution 4:

Aha, I thought I had seen something to this effect somewhere. I luckily managed to get it working by applying the description from an article on using the PSTypeName in PSCustomObjects for type-validation to classes. It turns out that classes also work with the same syntax.

In summary, it seems one has to type [PSTypeName('stack')] to use class types to validate data types.

Class stack {
  $a
  $b
  stack($inp) {
    $this.a = $inp
    $this.b = 'anything'
  }
}

function foo {
  Param(
    [PSTypeName('stack')]$bar
  )
  $bar
}
PS>foo -bar 'hello'
foo : Cannot bind argument to parameter 'bar', because PSTypeNames of the argument do not match the PSTypeName required by the parameter: stack.

PS>$test = [stack]::new('Overflow')
PS>foo -bar $test
a        b
-        -
Overflow anything