Properties are members of the class that contain data. Properties are declared as variables in the class scope. A property can be of any built-in type or an instance of another class. Classes can zero or more properties. Classes don't have a maximum property count.
Class properties can have any number of attributes, including the hidden and static attributes. Every property definition must include a type for the property. You can define a default value for a property.
Class properties use the following syntaxes:
Syntax
[[<attribute>]...] [<property-type>] $<property-name> [= <default-value>]Syntax
[[<attribute>]...]
[<property-type>]
$<property-name> [= <default-value>]The properties of the ExampleProject1 class use built-in types without any attributes or default values.
PowerShell
class ExampleProject1 {
[string] $Name
[int] $Size
[bool] $Completed
[string] $Assignee
[datetime] $StartDate
[datetime] $EndDate
[datetime] $DueDate
}
[ExampleProject1]::new()
$null -eq ([ExampleProject1]::new()).NameOutput
Name :
Size : 0
Completed : False
StartDate : 1/1/0001 12:00:00 AM
EndDate : 1/1/0001 12:00:00 AM
DueDate : 1/1/0001 12:00:00 AM
True
The default value for the Name and Assignee properties is $null because they're typed as strings, which is a reference type. The other properties have the default value for their defined type, because they're value type properties. For more information on the default values for properties, see Default property values.
The properties for ExampleProject2 include a custom enumeration and class defined in PowerShell before the ExampleProject2 class.
PowerShell
enum ProjectState {
NotTriaged
ReadyForWork
Committed
Blocked
InProgress
Done
}
class ProjectAssignee {
[string] $DisplayName
[string] $UserName
[string] ToString() {
return "$($this.DisplayName) ($($this.UserName))"
}
}
class ExampleProject2 {
[string] $Name
[int] $Size
[ProjectState] $State
[ProjectAssignee] $Assignee
[datetime] $StartDate
[datetime] $EndDate
[datetime] $DueDate
}
[ExampleProject2]@{
Name = 'Class Property Documentation'
Size = 8
State = 'InProgress'
Assignee = @{
DisplayName = 'Mikey Lombardi'
UserName = 'michaeltlombardi'
}
StartDate = '2023-10-23'
DueDate = '2023-10-27'
}Output
Name : Class Property Documentation
Size : 8
State : InProgress
Assignee : Mikey Lombardi (michaeltlombardi)
StartDate : 10/23/2023 12:00:00 AM
EndDate : 1/1/0001 12:00:00 AM
DueDate : 10/27/2023 12:00:00 AM
The ExampleProject3 class defines the Size property as an integer that must be greater than or equal to 0 and less than or equal to 16. It uses the ValidateRange attribute to limit the value.
PowerShell
class ExampleProject3 {
[string] $Name
[ValidateRange(0, 16)] [int] $Size
[bool] $Completed
[string] $Assignee
[datetime] $StartDate
[datetime] $EndDate
[datetime] $DueDate
}
$project = [ExampleProject3]::new()
$projectOutput
Name :
Size : 0
Completed : False
Assignee :
StartDate : 1/1/0001 12:00:00 AM
EndDate : 1/1/0001 12:00:00 AM
DueDate : 1/1/0001 12:00:00 AM
When ExampleProject3 instantiates, the Size defaults to 0. Setting the property to a value within the valid range updates the value.
PowerShell
$project.Size = 8
$projectOutput
Name :
Size : 8
Completed : False
Assignee :
StartDate : 1/1/0001 12:00:00 AM
EndDate : 1/1/0001 12:00:00 AM
DueDate : 1/1/0001 12:00:00 AM
When Size is set to an invalid value outside the range, PowerShell raises an exception and the value isn't changed.
PowerShell
$project.Size = 32
$project.Size = -1
$projectOutput
SetValueInvocationException:
Line |
1 | $project.Size = 32
| ~~~~~~~~~~~~~~~~~~
| Exception setting "Size": "The 32 argument is greater than the
| maximum allowed range of 16. Supply an argument that is less than
| or equal to 16 and then try the command again."
SetValueInvocationException:
Line |
2 | $project.Size = -1
| ~~~~~~~~~~~~~~~~~~
| Exception setting "Size": "The -1 argument is less than the minimum
| allowed range of 0. Supply an argument that is greater than or
| equal to 0 and then try the command again."
Name :
Size : 8
Completed : False
Assignee :
StartDate : 1/1/0001 12:00:00 AM
EndDate : 1/1/0001 12:00:00 AM
DueDate : 1/1/0001 12:00:00 AM
The ExampleProject4 class defaults the value for the StartDate property to the current date.
PowerShell
class ExampleProject4 {
[string] $Name
[int] $Size
[bool] $Completed
[string] $Assignee
[datetime] $StartDate = (Get-Date).Date
[datetime] $EndDate
[datetime] $DueDate
}
[ExampleProject4]::new()
[ExampleProject4]::new().StartDate -eq (Get-Date).DateOutput
Name :
Size : 0
Completed : False
Assignee :
StartDate : 10/23/2023 12:00:00 AM
EndDate : 1/1/0001 12:00:00 AM
DueDate : 1/1/0001 12:00:00 AM
True
The Guid property of the ExampleProject5 class has the hidden keyword. The Guid property doesn't show in the default output for the class or in the list of properties returned by Get-Member.
PowerShell
class ExampleProject5 {
[string] $Name
[int] $Size
[bool] $Completed
[string] $Assignee
[datetime] $StartDate
[datetime] $EndDate
[datetime] $DueDate
hidden [string] $Guid = (New-Guid).Guid
}
$project = [ExampleProject5]::new()
"Project GUID: $($project.Guid)"
$project
$project | Get-Member -MemberType Properties | Format-TableOutput
Project GUID: c72cef84-057c-4649-8940-13490dcf72f0
Name :
Size : 0
Completed : False
Assignee :
StartDate : 1/1/0001 12:00:00 AM
EndDate : 1/1/0001 12:00:00 AM
DueDate : 1/1/0001 12:00:00 AM
TypeName: ExampleProject5
Name MemberType Definition
---- ---------- ----------
Assignee Property string Assignee {get;set;}
Completed Property bool Completed {get;set;}
DueDate Property datetime DueDate {get;set;}
EndDate Property datetime EndDate {get;set;}
Name Property string Name {get;set;}
Size Property int Size {get;set;}
StartDate Property datetime StartDate {get;set;}
The ExampleProject6 class defines the static Projects property as a list of all created projects. The default constructor for the class adds the new instance to the list of projects.
PowerShell
class ExampleProject6 {
[string] $Name
[int] $Size
[bool] $Completed
[string] $Assignee
[datetime] $StartDate
[datetime] $EndDate
[datetime] $DueDate
hidden [string] $Guid = (New-Guid).Guid
static [ExampleProject6[]] $Projects = @()
ExampleProject6() {
[ExampleProject6]::Projects += $this
}
}
"Project Count: $([ExampleProject6]::Projects.Count)"
$project1 = [ExampleProject6]@{ Name = 'Project_1' }
$project2 = [ExampleProject6]@{ Name = 'Project_2' }
[ExampleProject6]::Projects | Select-Object -Property Name, GuidOutput
Project Count: 0
Name Guid
---- ----
Project_1 75e7c8a0-f8d1-433a-a5be-fd7249494694
Project_2 6c501be4-e68c-4df5-8fce-e49dd8366afe
The ExampleProject7 class defines the Duration script property in the static class constructor with the Update-TypeData cmdlet. Using the Update-TypeData or Add-Member cmdlet is the only way to define advanced properties for PowerShell classes.
The Duration property returns a value of $null unless both the StartDate and EndDate properties are set and StartDate is defined to be earlier than the EndDate.
PowerShell
class ExampleProject7 {
[string] $Name
[int] $Size
[bool] $Completed
[string] $Assignee
[datetime] $StartDate
[datetime] $EndDate
[datetime] $DueDate
static [hashtable[]] $MemberDefinitions = @(
@{
MemberName = 'Duration'
MemberType = 'ScriptProperty'
Value = {
[datetime]$UnsetDate = 0
$StartNotSet = $this.StartDate -eq $UnsetDate
$EndNotSet = $this.EndDate -eq $UnsetDate
$StartAfterEnd = $this.StartDate -gt $this.EndDate
if ($StartNotSet -or $EndNotSet -or $StartAfterEnd) {
return $null
}
return $this.EndDate - $this.StartDate
}
}
)
static ExampleProject7() {
$TypeName = [ExampleProject7].Name
foreach ($Definition in [ExampleProject7]::MemberDefinitions) {
Update-TypeData -TypeName $TypeName @Definition
}
}
ExampleProject7() {}
ExampleProject7([string]$Name) {
$this.Name = $Name
}
}
$Project = [ExampleProject7]::new()
$Project
$null -eq $Project.DurationOutput
Duration :
Name :
Size : 0
Completed : False
Assignee :
StartDate : 1/1/0001 12:00:00 AM
EndDate : 1/1/0001 12:00:00 AM
DueDate : 1/1/0001 12:00:00 AM
True
The default view for an instance of the ExampleProject7 class includes the duration. Because the StartDate and EndDate properties aren't set, the Duration property is $null.
PowerShell
$Project.StartDate = '2023-01-01'
$Project.EndDate = '2023-01-08'
$ProjectOutput
Duration : 7.00:00:00
Name :
Size : 0
Completed : False
Assignee :
StartDate : 1/1/2023 12:00:00 AM
EndDate : 1/8/2023 12:00:00 AM
DueDate : 1/1/0001 12:00:00 AM
With the properties set correctly, the Duration property returns a timespan representing how long the project ran.
Every class property has an implicit default value depending on the type of the property.
If a property is a reference type, like a string or an object, the implicit default value is $null. If a property is a value type, like a number, boolean, or enumeration, the property has a default value depending on the type:
- Numeric types, like integers and floating-point numbers, default to
0 - Boolean values default to
$false - Enumerations default to
0, even the enumeration doesn't define a label for0.
For more information about default values in .NET, see Default values of C# types (C# reference).
To define an explicit default value for a property, declare the property with an assignment to the default value.
For example, this definition for the ProjectTask class defines an explicit default value for the Guid property, assigning a random GUID to each new instance.
PowerShell
class ProjectTask {
[string] $Name
[string] $Description
[string] $Guid = (New-Guid).Guid
}
[ProjectTask]::new()Output
Name Description Guid
---- ----------- ----
aa96350c-358d-465c-96d1-a49949219eec
Hidden and static properties can also have default values.
You can hide properties of a class by declaring them with the hidden keyword. Hidden class properties are:
- Not included in the default output for the class.
- Not included in the list of class members returned by the
Get-Membercmdlet. To show hidden properties withGet-Member, use the Force parameter. - Not displayed in tab completion or IntelliSense unless the completion occurs in the class that defines the hidden property.
- Public members of the class. They can be accessed and modified. Hiding a property doesn't make it private. It only hides the property as described in the previous points.
For more information about the hidden keyword, see about_Hidden.
You can define a property as belonging to the class itself instead of instances of the class by declaring the property with the static keyword. Static class properties:
- Are always available, independent of class instantiation.
- Are shared across all instances of the class.
- Are always available.
- Are modifiable. Static properties can be updated. They aren't immutable by default.
- Live for the entire session span.
Important
Static properties for classes defined in PowerShell aren't immutable. They can be overridden to any valid value, as defined by the static property's type and attributes.
When a class derives from a base class, it inherits the properties of the base class. Any properties defined on the base class, including hidden properties, are available on the derived class.
A derived class can override an inherited property by redefining it in the class definition. The property on the derived class uses the redefined type and default value, if any. If the inherited property defined a default value and the redefined property doesn't, the inherited property has no default value.
If a derived class doesn't override a static property, accessing the static property through the derived class accesses the static property of the base class. Modifying the property value through the derived class modifies the value on the base class. Any other derived class that doesn't override the static property also uses the value of the property on the base class. Updating the value of an inherited static property in a class that doesn't override the property might have unintended effects for classes derived from the same base class.
The following example shows the behavior for static and instance properties on derived classes.
PowerShell
class BaseClass {
static [string] $StaticProperty = 'Static'
[string] $InstanceProperty = 'Instance'
}
class DerivedClassA : BaseClass {}
class DerivedClassB : BaseClass {}
class DerivedClassC : DerivedClassB {
[string] $InstanceProperty
}
class DerivedClassD : BaseClass {
static [string] $StaticProperty = 'Override'
[string] $InstanceProperty = 'Override'
}
"Base instance => $([BaseClass]::new().InstanceProperty)"
"Derived instance A => $([DerivedClassA]::new().InstanceProperty)"
"Derived instance B => $([DerivedClassB]::new().InstanceProperty)"
"Derived instance C => $([DerivedClassC]::new().InstanceProperty)"
"Derived instance D => $([DerivedClassD]::new().InstanceProperty)"Output
Base instance => Instance
Derived instance A => Instance
Derived instance B => Instance
Derived instance C =>
Derived instance D => Override
The InstanceProperty for DerivedClassC is an empty string because the class redefined the property without setting a default value. For DerivedClassD the value is Override because the class redefined the property with that string as the default value.
PowerShell
"Base static => $([BaseClass]::StaticProperty)"
"Derived static A => $([DerivedClassA]::StaticProperty)"
"Derived static B => $([DerivedClassB]::StaticProperty)"
"Derived static C => $([DerivedClassC]::StaticProperty)"
"Derived static D => $([DerivedClassD]::StaticProperty)"Output
Base static => Static
Derived static A => Static
Derived static B => Static
Derived static C => Static
Derived static D => Override
Except for DerivedClassD, the value of the static property for the derived classes is the same as the base class, because they don't redefine the property. This applies even to DerivedClassC, which inherits from DerivedClassB instead of directly from BaseClass.
PowerShell
[DerivedClassA]::StaticProperty = 'Updated from A'
"Base static => $([BaseClass]::StaticProperty)"
"Derived static A => $([DerivedClassA]::StaticProperty)"
"Derived static B => $([DerivedClassB]::StaticProperty)"
"Derived static C => $([DerivedClassC]::StaticProperty)"
"Derived static D => $([DerivedClassD]::StaticProperty)"Output
Base static => Updated from A
Derived static A => Updated from A
Derived static B => Updated from A
Derived static C => Updated from A
Derived static D => Override
When StaticProperty is accessed and modified through DerivedClassA, the changed value affects every class except for DerivedClassD.
For more information about class inheritance, including a comprehensive example, see about_Classes_Inheritance.
PowerShell includes several attribute classes that you can use to enhance data type information and validate the data assigned to a property. Validation attributes allow you to test that values given to properties meet defined requirements. Validation is triggered the moment that the value is assigned.
For more information on available attributes, see about_Functions_Advanced_Parameters.
Beyond declaring properties directly in the class definition, you can define properties for instances of a class in the static constructor using the Update-TypeData cmdlet.
Use this snippet as a starting point for the pattern. Replace the placeholder text in angle brackets as needed.
PowerShell
class <ClassName> {
static [hashtable[]] $MemberDefinitions = @(
@{
MemberName = '<PropertyName>'
MemberType = '<PropertyType>'
Value = <ValueDefinition>
}
)
static <ClassName>() {
$TypeName = [<ClassName>].Name
foreach ($Definition in [<ClassName>]::MemberDefinitions) {
Update-TypeData -TypeName $TypeName @Definition
}
}
}Tip
The Add-Member cmdlet can add properties and methods to a class in non-static constructors, but the cmdlet is run every time the constructor is called. Using Update-TypeData in the static constructor ensures that the code for adding the members to the class only needs to run once in a session.
Only add properties to the class in non-static constructors when they can't be defined with Update-TypeData, like read-only properties.
The Alias attribute has no effect when used on a class property declaration. PowerShell only uses that attribute to define aliases for cmdlet, parameter, and function names.
To define an alias for a class property, use Update-TypeData with the AliasProperty MemberType.
For example, this definition of the OperablePair class defines two integer properties x and y with the aliases LeftHandSide and RightHandSide respectively.
PowerShell
class OperablePair {
[int] $x
[int] $y
static [hashtable[]] $MemberDefinitions = @(
@{
MemberType = 'AliasProperty'
MemberName = 'LeftHandSide'
Value = 'x'
}
@{
MemberType = 'AliasProperty'
MemberName = 'RightHandSide'
Value = 'y'
}
)
static OperablePair() {
$TypeName = [OperablePair].Name
foreach ($Definition in [OperablePair]::MemberDefinitions) {
Update-TypeData -TypeName $TypeName @Definition
}
}
OperablePair() {}
OperablePair([int]$x, [int]$y) {
$this.x = $x
$this.y = $y
}
# Math methods for the pair of values
[int] GetSum() { return $this.x + $this.y }
[int] GetProduct() { return $this.x * $this.y }
[int] GetDifference() { return $this.x - $this.y }
[float] GetQuotient() { return $this.x / $this.y }
[int] GetModulus() { return $this.x % $this.y }
}With the aliases defined, users can access the properties with either name.
PowerShell
$pair = [OperablePair]@{ x = 8 ; RightHandSide = 3 }
"$($pair.x) % $($pair.y) = $($pair.GetModulus())"
$pair.LeftHandSide = 3
$pair.RightHandSide = 2
"$($pair.x) x $($pair.y) = $($pair.GetProduct())"Output
8 % 3 = 2
3 x 2 = 6
To define a property that references the values of other properties, use the Update-TypeData cmdlet with the ScriptProperty MemberType.
For example, this definition of the Budget class defines the Expenses and Revenues properties as arrays of floating-point numbers. It uses the Update-TypeData cmdlet to define calculated properties for total expenses, total revenues, and net income.
PowerShell
class Budget {
[float[]] $Expenses
[float[]] $Revenues
static [hashtable[]] $MemberDefinitions = @(
@{
MemberType = 'ScriptProperty'
MemberName = 'TotalExpenses'
Value = { ($this.Expenses | Measure-Object -Sum).Sum }
}
@{
MemberType = 'ScriptProperty'
MemberName = 'TotalRevenues'
Value = { ($this.Revenues | Measure-Object -Sum).Sum }
}
@{
MemberType = 'ScriptProperty'
MemberName = 'NetIncome'
Value = { $this.TotalRevenues - $this.TotalExpenses }
}
)
static Budget() {
$TypeName = [Budget].Name
foreach ($Definition in [Budget]::MemberDefinitions) {
Update-TypeData -TypeName $TypeName @Definition
}
}
Budget() {}
Budget($Expenses, $Revenues) {
$this.Expenses = $Expenses
$this.Revenues = $Revenues
}
}
[Budget]::new()
[Budget]@{
Expenses = @(2500, 1931, 3700)
Revenues = @(2400, 2100, 4150)
}Output
TotalExpenses : 0
TotalRevenues : 0
NetIncome : 0
Expenses :
Revenues :
TotalExpenses : 8131
TotalRevenues : 8650
NetIncome : 519
Expenses : {2500, 1931, 3700}
Revenues : {2400, 2100, 4150}
PowerShell class properties can't define custom getter and setter logic directly. You can approximate this functionality by defining a backing property with the hidden keyword and using Update-TypeData to define a visible property with custom logic for getting and setting the value.
By convention, define the hidden backing property name with an underscore prefix and use camel casing. For example, instead of TaskCount, name the hidden backing property _taskCount.
In this example, the ProjectSize class defines a hidden integer property named _value. It defines Value as a ScriptProperty with custom logic for getting and setting the _value property. The setter scriptblock handles converting the string representation of the project to the correct size.
PowerShell
class ProjectSize {
hidden [ValidateSet(0, 1, 2, 3)] [int] $_value
static [hashtable[]] $MemberDefinitions = @(
@{
MemberType = 'ScriptProperty'
MemberName = 'Value'
Value = { $this._value } # Getter
SecondValue = { # Setter
$ProposedValue = $args[0]
if ($ProposedValue -is [string]) {
switch ($ProposedValue) {
'Small' { $this._value = 1 ; break }
'Medium' { $this._value = 2 ; break }
'Large' { $this._value = 3 ; break }
default { throw "Unknown size '$ProposedValue'" }
}
} else {
$this._value = $ProposedValue
}
}
}
)
static ProjectSize() {
$TypeName = [ProjectSize].Name
foreach ($Definition in [ProjectSize]::MemberDefinitions) {
Update-TypeData -TypeName $TypeName @Definition
}
}
ProjectSize() {}
ProjectSize([int]$Size) { $this.Value = $Size }
ProjectSize([string]$Size) { $this.Value = $Size }
[string] ToString() {
$Output = switch ($this._value) {
1 { 'Small' }
2 { 'Medium' }
3 { 'Large' }
default { 'Undefined' }
}
return $Output
}
}With the custom getter and setter defined, you can set the Value property as either an integer or string.
PowerShell
$size = [ProjectSize]::new()
"The initial size is: $($size._value), $size"
$size.Value = 1
"The defined size is: $($size._value), $size"
$Size.Value += 1
"The updated size is: $($size._value), $size"
$Size.Value = 'Large'
"The final size is: $($size._value), $size"Output
The initial size is: 0, Undefined
The defined size is: 1, Small
The updated size is: 2, Medium
The final size is: 3, Large
PowerShell class properties have the following limitations:
-
Static properties are always mutable. PowerShell classes can't define immutable static properties.
Workaround: None.
-
Properties can't use the ValidateScript attribute, because class property attribute arguments must be constants.
Workaround: Define a class that inherits from the ValidateArgumentsAttribute type and use that attribute instead.
-
Directly declared properties can't define custom getter and setter implementations.
Workaround: Define a hidden property and use
Update-TypeDatato define the visible getter and setter logic. -
Properties can't use the Alias attribute. The attribute only applies to parameters, cmdlets, and functions.
Workaround: Use the
Update-TypeDatacmdlet to define aliases in the class constructors. -
When a PowerShell class is converted to JSON with the
ConvertTo-Jsoncmdlet, the output JSON includes all hidden properties and their values.Workaround: None
The following examples are extracted directly from the PsCraft implementation under Private/. They show how the project uses property declarations, validation attributes, hidden backing fields, and the static [hashtable[]] $MemberDefinitions + static <Class>() pattern in a real, non-trivial build system.
Example 1 — BuildContext: static [hashtable[]] $MemberDefinitions + static BuildContext() for TotalDuration
BuildContext uses a hidden backing field (_startTime) plus a static [hashtable[]] $MemberDefinitions array and a static BuildContext() constructor to register a ScriptProperty named TotalDuration. The Update-TypeData call is currently commented out in the source — this is a real, in-the-wild example of the "leave the wiring in place but switch it off" idiom.
class BuildContext {
[string]$ProjectName
[string]$ProjectPath
[version]$BuildNumber
[string]$BuildSystem # 'GitHub', 'Azure', 'Local'
[bool]$IsCI
[bool]$IsGitHubActions
[string]$RunId
[string]$BuildOutputPath
[string]$BuildScriptPath
[string]$PSModulePath
[string]$PSModuleManifest
[string]$CommitMessage
[string]$BranchName
[string]$CommitId
[string]$ReleaseNotes
hidden [datetime] $_startTime
static [hashtable[]] $MemberDefinitions = @(
@{
MemberType = 'ScriptProperty'
MemberName = 'TotalDuration'
Value = { return [datetime]::Now - $this._startTime }
}
)
static BuildContext() {
foreach ($d in [BuildContext]::MemberDefinitions) {
# Update-TypeData -TypeName ([BuildContext].Name) @d -ErrorAction Ignore
}
}
BuildContext([string]$ProjectName, [string]$ProjectPath, [version]$BuildNumber) {
$this.Init($ProjectName, $ProjectPath, $BuildNumber)
}
hidden [void] Init([string]$ProjectName, [string]$ProjectPath, [version]$BuildNumber) {
$this._startTime = [datetime]::Now
# ... populate everything else
}
}The exact same pattern is used in BuildSummary:
class BuildSummary {
[string]$ProjectName
[string]$BuildNumber
[datetime]$StartTime
[datetime]$EndTime
[System.Collections.Generic.List[psobject]]$Tasks
[TestResult]$TestResults
[bool]$Success
static [hashtable[]] $MemberDefinitions = @(
@{
MemberType = 'ScriptProperty'
MemberName = 'TotalDuration'
Value = { return $this.EndTime - $this.StartTime }
}
)
static BuildSummary() {
foreach ($d in [BuildSummary]::MemberDefinitions) {
# Update-TypeData -TypeName ([BuildSummary].Name) @d -ErrorAction Ignore
}
}
# ...
}The static [hashtable[]] $MemberDefinitions + static <ClassName>() pattern gives you a single, declarative table of ScriptProperty / AliasProperty / ScriptMethod definitions that are installed on the type exactly once per session.
PsModule is the most property-heavy class in PsCraft. It uses both [ValidateNotNullOrEmpty()] (for [IO.DirectoryInfo]$Path) and [ValidateNotNullOrWhiteSpace()] (for [string]$Name) directly on the property declaration.
class PsModule : IDisposable {
[ValidateNotNullOrEmpty()] [String]$Name;
[ValidateNotNullOrEmpty()] [IO.DirectoryInfo]$Path;
[PsModuleData] $data;
[List[ModuleFile]]$Files;
[List[ModuleFolder]]$Folders;
static [hashtable] $Config
# ...
[void] Dispose() {
$this.Data = $null
if ($null -ne $this.Files) { $this.Files.Clear() }
if ($null -ne $this.Folders) { $this.Folders.Clear() }
}
}Because PsModule implements IDisposable, callers can use it in a try/finally block to ensure file lists and folder lists are cleared:
$mod = $null
try {
$mod = [PsModule]::Load($path)
# ...work with $mod
} finally {
if ($mod) { $mod.Dispose() }
}PsCraft shows the difference between [ValidateNotNullOrEmpty()] (rejects $null and the empty string) and [ValidateNotNullOrWhiteSpace()] (rejects whitespace-only strings too).
class PsCraft : Microsoft.PowerShell.Commands.ModuleCmdletBase {
[ValidateNotNullOrWhiteSpace()][string]$ModuleName
[ValidateNotNullOrWhiteSpace()][string]$BuildOutputPath # $RootPath/BouldOutput/$ModuleName
[ValidateNotNullOrEmpty()][IO.DirectoryInfo]$RootPath # Module Project root
[ValidateNotNullOrEmpty()][IO.DirectoryInfo]$TestsPath
[ValidateNotNullOrEmpty()][version]$ModuleVersion
[ValidateNotNullOrEmpty()][System.IO.FileInfo]$dataFile # ..strings.psd1
[ValidateNotNullOrEmpty()][System.IO.FileInfo]$buildFile
[IO.DirectoryInfo]$LocalPSRepo # unvalidated on purpose
[PsObject]$LocalizedData
[System.Management.Automation.PSCmdlet]$CallerCmdlet
[bool]$UseVerbose
[BuildContext]$BuildContext
[System.Collections.Generic.List[string]]$TaskList
PsCraft() {}
PsCraft([string]$RootPath) { [void][PsCraft]::From($RootPath, $this) }
# ...
}Rule of thumb PsCraft follows:
| Field | Use [ValidateNotNullOrWhiteSpace()] |
|---|---|
ModuleName, BuildOutputPath |
yes — whitespace-only is meaningless |
RootPath, TestsPath, dataFile, buildFile |
[ValidateNotNullOrEmpty()] is sufficient — the field is a DirectoryInfo/FileInfo so whitespace is already excluded by the type |
LocalizedData, LocalPSRepo, CallerCmdlet, UseVerbose, BuildContext, TaskList |
no validation — the field is genuinely optional in some callsites |
Example 4 — PsModuleData: validated properties + hidden backing field
PsModuleData extends Dictionary[string, Object] (see about_Classes_Inheritance) and adds typed, validated properties for the high-level state plus a hidden backing field for defaults.
class PsModuleData : System.Collections.Generic.Dictionary[string, Object] {
[ValidateNotNullOrWhiteSpace()][string]$Name
[ValidateNotNullOrEmpty()][IO.DirectoryInfo]$Path
[ReadOnlyCollection[ModuleFile]]$Files
[ReadOnlyCollection[ModuleFolder]]$Folders
hidden [PsModuleDefaults]$defaults
PsModuleData([string]$Name, [System.Management.Automation.ModuleType]$Type, [IO.DirectoryInfo]$Path) {
$this.Name = [string]::IsNullOrWhiteSpace($Name) `
? [IO.Path]::GetFileNameWithoutExtension([IO.Path]::GetRandomFileName()) `
: $Name
$this.defaults = [PsModuleDefaults]::new($this.Name, $Type, $Path)
# ...
}
# ...
}The hidden [PsModuleDefaults]$defaults field is the only reference to the defaults hash; the public surface uses GetDefaults() (see PsModuleDefaults::GetDefaults()) because PowerShell classes don't implement IDictionary.
Example 5 — BuildOrchestrator: untyped hidden fields typed at runtime
BuildOrchestrator declares $_runner, $_logger, and $_logDir as hidden untyped fields and assigns them at runtime. The reason is the parse-time-resolution limitation described in about_Classes_Inheritance.
class BuildOrchestrator : PsCraft {
[string] $Path
[string[]] $RequiredModules
[System.Management.Automation.PSCmdlet] $Cmdlet
[System.Management.Automation.ModuleType] $ModuleType = 'Script'
[bool] $HasBinarySrc = $false
[BuildContext] $Context
[scriptblock] $PSakeScriptBlock = $null
[BuildSummary] $BuildSummary = $null
hidden $_runner # [ThreadRunner] — typed at runtime; cliHelper.core type not parse-time resolvable as field
hidden $_logger # [Logger] — typed at runtime; cliHelper.logger type not parse-time resolvable as field
hidden [string] $_logDir
BuildOrchestrator(
[string]$path, [string[]]$tasks, [string[]]$requiredModules,
[System.Management.Automation.PSCmdlet]$cmdlet
) {
$this.Path = $path
$this.TaskList = [System.Collections.Generic.List[string]]::new()
if ($tasks) { $this.TaskList.AddRange($tasks) }
$this.RequiredModules = $requiredModules
$this.Cmdlet = $cmdlet
$this.Context = [BuildContext]::new(
[IO.DirectoryInfo]::new($path).BaseName, $path, '0.0.0'
)
$this.DetectModuleType()
try { $this._runner = [ThreadRunner]::new() } catch { $this._runner = $null }
$this.Init_Logger()
}
hidden [void] Init_Logger() {
$this._logDir = [IO.Path]::Combine($this.Context.BuildOutputPath, 'logs')
try {
if (!(Test-Path $this._logDir)) {
New-Item -ItemType Directory -Path $this._logDir -Force -ea Ignore | Out-Null
}
$this._logger = [Logger]::new($this._logDir)
$this._logger.AddLogAppender([ConsoleAppender]::new())
$runId = $this.Context.RunId
if ([string]::IsNullOrWhiteSpace($runId)) { $runId = 'build' }
$this._logger.AddLogAppender(
[JsonAppender]::new([IO.Path]::Combine($this._logDir, "build-$runId.json"))
)
} catch {
$this._logger = [NullLogger]::new()
}
}
[void] Dispose() {
try {
if ($null -ne $this._logger -and !$this._logger.IsDisposed) {
$this._logger.LogInfoLine('Build session ended.')
$this._logger.Dispose()
}
} catch {
[BuildLog]::WriteWarning(
"Error disposing logger: $($_ | Format-List * -Force | Out-String)"
)
}
try { $this._runner = $null } catch { $null }
}
}Key takeaways:
- A
hiddenfield is public-by-default at runtime — anyone holding aBuildOrchestratorreference can read and write$_logger— but it is excluded fromGet-Member, IntelliSense, and the default formatting of the object. Thehiddenkeyword is purely a discovery modifier, not a security one. - The
_prefix on hidden fields is the PsCraft convention (see the underscore-prefixed backing field$this._startTimeinBuildContext). It pairs with a visibleScriptProperty(TotalDuration) installed viaUpdate-TypeDatato approximate a read-only computed property. BuildOrchestrator.Dispose()is the proper place to release both the runner and the logger; do it in atry/catchso a broken logger doesn't leak.
PsModuleData::ToString is a small but useful pattern: instead of letting PowerShell fall back to <TypeName> @ <HashCode>, give the dictionary a sensible one-liner that conveys its size.
[string] ToString() {
return "@{$($this.Count) entries}"
}Now $data in a Write-Host or a string interpolation renders as @{12 entries} rather than the default PSObject dump.
SchemaNode is a tiny DTO used to describe one file or folder in a module schema.
class SchemaNode {
[string]$Key
[string]$TemplatePath
[bool]$IsRequired
SchemaNode([string]$key, [string]$templatePath) {
$this.Key = $key
$this.TemplatePath = $templatePath
$this.IsRequired = $true
}
SchemaNode([string]$key, [string]$templatePath, [bool]$isRequired) {
$this.Key = $key
$this.TemplatePath = $templatePath
$this.IsRequired = $isRequired
}
# Resolves paths by replacing placeholders like {mName}
[string] ResolvePlaceholderPath([string]$ModuleName) {
[string]$resolved = $this.TemplatePath.Replace('{mName}', $ModuleName)
return [System.IO.Path]::GetFullPath(
[System.IO.Path]::Combine((Get-Item .).FullName, $resolved)
)
}
[string] ResolvePath([string]$ModuleName) {
return $this.ResolvePlaceholderPath($ModuleName)
}
}It is consumed by Test-PsModuleSchema like this:
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[PsModuleSchema]$Schema,
[Parameter(Mandatory = $true)]
[string]$ModuleName
)
process {
foreach ($folderNode in $Schema.Folders) {
$targetPath = $folderNode.ResolvePath($ModuleName)
$exists = Test-Path -Path $targetPath -PathType Container
# ...
}
foreach ($fileNode in $Schema.Files) {
$targetPath = $fileNode.ResolvePath($ModuleName)
$exists = Test-Path -Path $targetPath -PathType Leaf
# ...
}
}The point of this example is that typed property access on a class ($folderNode.IsRequired, $fileNode.TemplatePath) is the primary API surface for consumers, even on a small DTO.
PsModule overrides the canonical trio so that two PsModule instances with the same name and path are considered equal, hashing is stable, and the object is printable.
[bool] Equals([object]$other) {
if ($null -eq $other) { return $false }
if ([object]::ReferenceEquals($this, $other)) { return $true }
$o = $other -as [PsModule]
if ($null -eq $o) { return $false }
return ($this.Name -eq $o.Name) -and ($this.Path.FullName -eq $o.Path.FullName)
}
[int] GetHashCode() {
$hash = 17
if ($null -ne $this.Name) { $hash = $hash * 23 + $this.Name.GetHashCode() }
if ($null -ne $this.Path) { $hash = $hash * 23 + $this.Path.FullName.GetHashCode() }
return $hash
}
[string] ToString() {
return "$($this.Name) @ $($this.Path.FullName)"
}Note the Equals signature is [object], not [PsModule]. Earlier revisions used [PsModule]$other and failed at parse/load time with Type must be a type provided by the runtime. The -as [PsModule] cast is the safe way to narrow inside the method body.
When you override any of these three, override all three — otherwise PsModule instances in HashSet[PsModule], Dictionary[PsModule, T], or [System.Collections.Generic.List[PsModule]].Distinct() will misbehave.
The BuildContext example above combines four PsCraft property patterns at once:
- A typed, validated property (
[string]$RunId) computed at construction time. - A hidden backing field (
hidden [datetime] $_startTime) with the_prefix convention. - A
static [hashtable[]] $MemberDefinitionsarray of one entry. - A
static BuildContext()constructor that loops over that array and (when uncommented) callsUpdate-TypeData.
# 1. typed, validated, computed
[string]$RunId
# 2. hidden backing field with the underscore convention
hidden [datetime] $_startTime
# 3. one declarative table entry
static [hashtable[]] $MemberDefinitions = @(
@{ MemberType = 'ScriptProperty'; MemberName = 'TotalDuration';
Value = { return [datetime]::Now - $this._startTime } }
)
# 4. install once per session
static BuildContext() {
foreach ($d in [BuildContext]::MemberDefinitions) {
# Update-TypeData -TypeName ([BuildContext].Name) @d -ErrorAction Ignore
}
}After that, $ctx.TotalDuration is a normal property that returns the time elapsed since the BuildContext was constructed, with no need for a custom getter method and no public visibility of _startTime.
- See about_Classes_Constructors for the constructor patterns that pair with these properties (e.g. the
Init()-chained constructors that populate the hidden backing fields). - See about_Classes_Methods for the
Equals/GetHashCode/ToStringmethod overrides and theAliasVisitor.Visit*/[PsCraft]::From([ref])patterns. - See about_Classes_Inheritance for the base classes (
Dictionary[string, Object],ModuleCmdletBase,AstVisitor,PsCraft) thatBuildContext,BuildSummary, andBuildOrchestratorderive from.