3

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?

Blaisem
  • 557
  • 8
  • 17

4 Answers4

4

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."

mklement0
  • 382,024
  • 64
  • 607
  • 775
  • I see. Thanks for the thorough explanation. It seems I misunderstood the typing constraint on a parameter as a means of data validation. I am actually seeking something like an enum but for objects instead of a set of strictly constrained strings. Is this validateScript approach for classes the optimal way to go about this? – Blaisem Jan 13 '22 at 23:16
  • Glad to hear it helped, @Blaisem. The `ValidateScript` approach isn't great, especially in Windows PowerShell, where you cannot provide a _friendly error message_, but if you truly want to avoid _automatic type conversions_, then you either need to use this attribute or use an _unconstrained_ (or `[object]`-typed) parameter and perform custom validation in the function/script _body_. But the question is: why aren't the automatic type conversions acceptable, if they result in an instance of the expected type? – mklement0 Jan 13 '22 at 23:33
  • I've written the code to instantiate a class object in a variable, so I can dynamically reuse this variable in various places -- as function inputs, control flow conditions, reference hashtable keys via a property, etc.. Eventually, I have to deliver this module, so my goal is understandable code: communicating types concisely, robustly validating for easier debugging, and avoiding boilerplate. Reusing non-mutating variables for prominent objects within a specific context seemed to me like a way to establish a comfortable reference point to help the reader track what's going on in that context – Blaisem Jan 13 '22 at 23:53
  • But I'm new at this scale of PowerShell, so I may be going about it wrong. If I have to reconstruct the class for every function, I'm not sure if that's better than what I am doing. It's a new concept to me, so I'm not sure what to make of it yet, but maybe it's just as good or better. – Blaisem Jan 13 '22 at 23:56
  • @Blaisem, there's a lot of "automagic" going on in PowerShell, for better or worse. I suggest embracing it rather than fighting it. Do _not_ think of PowerShell as C# with different syntax - it isn't, and that mindset will be a perennial pain point. – mklement0 Jan 14 '22 at 01:38
  • 1
    thanks for the more in-depth info. To come back to the point: I believe I seem to have found a way to do this kind of validation by casting it via `[PSTypeName('')]`. Whether it's the most elegant way to code instead of using the auto conversion is something I'll have to think about, but PowerShell's versatility still catches me off guard. – Blaisem Jan 14 '22 at 01:51
  • @Blaisem, that attribute is an excellent find and indeed does what you want in a more direct way than the `[ValidateScript()]` approach - I've updated the answer accordingly. That said, the fact that this attribute is little-known and little-used is an indication that the kind of validation you're looking for is usually _not_ sought, and that instead the "automagic" type conversions are usually embraced and accepted. – mklement0 Jan 14 '22 at 02:28
3

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.

TheMadTechnician
  • 34,906
  • 3
  • 42
  • 56
1

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')
Dharman
  • 30,962
  • 25
  • 85
  • 135
ZivkoK
  • 366
  • 3
  • 6
  • I am typing everything. Just left that part out for the example. What did you mean by fun:foo? – Blaisem Jan 13 '22 at 23:18
  • 'fun:foo' is just a string I choose for the example (function:foo and function:foo2), didn't though it will be confusing – ZivkoK Jan 13 '22 at 23:26
1

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
Blaisem
  • 557
  • 8
  • 17
  • 1
    Again, kudos for discovering this use for the [`[PSTypeName(...)]`](https://learn.microsoft.com/en-US/dotnet/api/System.Management.Automation.PSTypeNameAttribute) attribute, but not that its intended use is _not_ to constrain parameters to true .NET types (including custom `class` instances) and thereby _suppress automatic type conversions_. Instead, its purpose is to support _virtual types_ provided via PowerShell's ETS (Extended Type System); emphasis added: "This attribute is used to restrict the type name of the parameter, _when the type goes beyond the .NET type system_." – mklement0 Jan 14 '22 at 03:29