Property Functions, Item Functions and Transforms

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

MSBuild has no statements, but it can compute: property functions call .NET methods and MSBuild helpers inside $(...), item functions filter and reshape item lists inside @(...), and transforms map each item of a list to a new string. They are what lets one Directory.Build.props find the repository root, one target turn a list of inputs into a list of outputs, and a condition compare versions properly. This page runs most of them in a single Ledgerline project with MSBuild 18.9 (.NET SDK 10.0.402).

The three syntaxes

Form Example

String instance method on a property

$(Period.Trim()), $(PeriodTrimmed.Substring(3, 4)), $(Name.Replace('.', '-'))

Static method or property of an allowed .NET class

$([System.IO.Path]::Combine($(A), 'b')), $([System.DateTime]::UtcNow.ToString('yyyy'))

MSBuild helper

$([MSBuild]::GetPathOfFileAbove('Directory.Build.props')), $([MSBuild]::Add(40, 2))

Arguments are strings unless they look like numbers or booleans; quote string arguments with single quotes, and quote property references inside arguments ('$(TargetFramework)') so an empty value is still an argument. Calls nest, and the result of one can be the target of another ($([System.String]::Copy($(X)).ToUpperInvariant())).

A tour in one project

The project sits in src/Ledgerline of a small repository with a Directory.Build.props at its root, two *.ledger.csv journals and two .cs files:

<Project>
  <PropertyGroup>
    <Period>  q1 2026  </Period>
    <TargetFramework>net8.0</TargetFramework>
    <!-- String instance methods. -->
    <PeriodTrimmed>$(Period.Trim())</PeriodTrimmed>
    <Year>$(PeriodTrimmed.Substring(3, 4))</Year>
    <!-- Static .NET methods (from the allowed list). -->
    <JournalCount>$([System.IO.Directory]::GetFiles('journals', '*.ledger.csv').Length)</JournalCount>
    <Upper>$([System.String]::Copy($(PeriodTrimmed)).ToUpperInvariant())</Upper>
    <!-- [MSBuild]:: helpers. -->
    <RepoRoot>$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildProjectDirectory), 'Directory.Build.props'))</RepoRoot>
    <PropsPath>$([MSBuild]::GetPathOfFileAbove('Directory.Build.props'))</PropsPath>
    <RelativeFromRoot>$([MSBuild]::MakeRelative($(RepoRoot), $(MSBuildProjectDirectory)))</RelativeFromRoot>
    <Region>$([MSBuild]::ValueOrDefault('$(LEDGERLINE_REGION)', 'EU'))</Region>
    <Hash>$([MSBuild]::StableStringHash('Ledgerline'))</Hash>
    <Tfi>$([MSBuild]::GetTargetFrameworkIdentifier('$(TargetFramework)'))</Tfi>
    <Tfv>$([MSBuild]::GetTargetFrameworkVersion('$(TargetFramework)'))</Tfv>
    <IsNet8OrLater>$([MSBuild]::IsTargetFrameworkCompatible('net10.0', '$(TargetFramework)'))</IsNet8OrLater>
    <NewEnough>$([MSBuild]::VersionGreaterThanOrEquals('$(MSBuildVersion)', '17.8'))</NewEnough>
    <OnLinux>$([MSBuild]::IsOSPlatform('Linux'))</OnLinux>
    <Sum>$([MSBuild]::Add(40, 2))</Sum>
    <!-- Nested calls. -->
    <ShortName>$([System.IO.Path]::GetFileNameWithoutExtension('$([System.IO.Path]::GetFileName('$(MSBuildProjectFullPath)'))'))</ShortName>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="*.cs" />
    <Journal Include="journals/*.ledger.csv" Month="%(Filename)" />
    <Journal Update="journals/jan.ledger.csv" Audited="true" />
  </ItemGroup>

  <Target Name="Show">
    <Message Importance="high" Text="PeriodTrimmed=[$(PeriodTrimmed)] Year=$(Year) Upper=$(Upper) JournalCount=$(JournalCount)" />
    <Message Importance="high" Text="RepoRoot=$(RepoRoot)" />
    <Message Importance="high" Text="PropsPath=$(PropsPath) RelativeFromRoot=$(RelativeFromRoot)" />
    <Message Importance="high" Text="Region=$(Region) Hash=$(Hash) Sum=$(Sum) ShortName=$(ShortName)" />
    <Message Importance="high" Text="Tfi=$(Tfi) Tfv=$(Tfv) net10.0 can use net8.0 assets: $(IsNet8OrLater) MSBuild&gt;=17.8: $(NewEnough) Linux: $(OnLinux)" />
    <!-- Transforms. -->
    <Message Importance="high" Text="Generated: @(Compile->'obj/%(Filename).g.cs')" />
    <Message Importance="high" Text="Custom separator: @(Journal->'%(Filename)', ' | ')" />
    <!-- Item functions. -->
    <Message Importance="high" Text="Count=@(Journal->Count()) Audited=@(Journal->WithMetadataValue('Audited', 'true')) AnyAudited=@(Journal->AnyHaveMetadataValue('Audited', 'true'))" />
    <Message Importance="high" Text="Dirs=@(Journal->DirectoryName()->Distinct()) Reversed=@(Compile->Reverse()) Upper=@(Compile->ToUpperInvariant())" />
    <Message Importance="high" Text="Metadata=@(Journal->Metadata('Month')) HasMetadata=@(Journal->HasMetadata('Audited'))" />
  </Target>
</Project>
dotnet msbuild funcs.proj -t:Show -nologo -v:m
  PeriodTrimmed=[q1 2026] Year=2026 Upper=Q1 2026 JournalCount=2
  RepoRoot=~/funcs/repo
  PropsPath=~/funcs/repo/Directory.Build.props RelativeFromRoot=src/Ledgerline
  Region=EU Hash=1729098504 Sum=42 ShortName=funcs
  Tfi=.NETCoreApp Tfv=8.0 net10.0 can use net8.0 assets: True MSBuild>=17.8: True Linux: True
  Generated: obj/Ledger.g.cs;obj/Reader.g.cs
  Custom separator: feb.ledger | jan.ledger
  Count=2 Audited=journals/jan.ledger.csv AnyAudited=true
  Dirs=~/funcs/repo/src/Ledgerline/journals Reversed=Reader.cs;Ledger.cs Upper=LEDGER.CS;READER.CS
  Metadata=feb.ledger;jan.ledger HasMetadata=journals/jan.ledger.csv

-getProperty shows the same values without a target; setting the environment variable changes the ValueOrDefault result:

LEDGERLINE_REGION=US dotnet msbuild funcs.proj -getProperty:Region
US

The [MSBuild]:: helpers

Function Use

GetDirectoryNameOfFileAbove(startDir, file)

The directory of the nearest file at or above startDir — finding the repository root from any project.

GetPathOfFileAbove(file[, startDir])

The full path of the nearest file above the current file’s folder by default — the standard way to chain Directory.Build.props files (multi-level merging).

MakeRelative(base, path)

path relative to base.

NormalizePath(parts...), NormalizeDirectory(parts...), EnsureTrailingSlash(path)

Combine and canonicalise paths with the right separators (NormalizeDirectory adds the trailing one).

ValueOrDefault(value, default)

value unless it is empty.

StableStringHash(string)

A hash that is the same on every machine and run (unlike GetHashCode) — for stable file names. More overloads arrived with change wave 17.10.

GetTargetFrameworkIdentifier(tfm), GetTargetFrameworkVersion(tfm), GetTargetPlatformIdentifier(tfm), GetTargetPlatformVersion(tfm), IsTargetFrameworkCompatible(a, b)

Parse TFMs (net8.0 → .NETCoreApp, 8.0) and ask whether a can use assets built for b.

VersionEquals, VersionNotEquals, VersionGreaterThan, VersionGreaterThanOrEquals, VersionLessThan, VersionLessThanOrEquals

Compare versions numerically (17.10 > 17.8, which a string comparison gets wrong).

IsOSPlatform(name), IsOsUnixLike(), IsOsBsdLike()

Operating-system checks for conditions.

Add, Subtract, Multiply, Divide, Modulo, BitwiseOr, BitwiseAnd, BitwiseXor, BitwiseNot

Arithmetic.

Escape, Unescape, GetRegistryValue and GetRegistryValueFromView (Windows), ConvertToBase64/ConvertFromBase64, GetCurrentToolsDirectory, GetMSBuildSDKsPath, GetVsInstallRoot

Others listed on the reference page.

Which .NET methods are allowed

Static property functions are restricted to a list of classes — System.String, System.IO.Path, System.Math, System.Version, System.Guid, System.DateTime, System.Text.RegularExpressions.Regex, System.Runtime.InteropServices.RuntimeInformation, the integer types and a few others — plus named read-only members of System.Environment, System.IO.Directory and System.IO.File (Exists, ReadAllText, GetFiles, …). On the .NET MSBuild every static method of System.OperatingSystem is available too; msbuild.exe allows only IsOSPlatform and IsOSPlatformVersionAtLeast. Since MSBuild 17.14, an out _ argument discards an out parameter ($([System.Int32]::TryParse("123", out _))). Anything else is refused, so evaluation cannot write files:

<Project>
  <PropertyGroup>
    <Oops>$([System.IO.File]::WriteAllText('x.txt', 'hi'))</Oops>
  </PropertyGroup>
  <Target Name="T" />
</Project>
~/funcs/bad.proj(3,5): error MSB4185: The function "WriteAllText" on type "System.IO.File" is not available for execution as an MSBuild property function.

A function that throws reports MSB4184 with the .NET exception message — here Substring(3, 4) on a one-character Period:

dotnet msbuild funcs.proj -getProperty:Year -p:Period=x
~/funcs/repo/src/Ledgerline/funcs.proj(7,5): error MSB4184: The expression ""x".Substring(3, 4)" cannot be evaluated. startIndex cannot be larger than length of string. (Parameter 'startIndex')

Item functions

Item functions run on a list and return a list (or a value):

Function Result

@(X->Count())

the number of items

@(X->Distinct()), @(X->DistinctWithCase())

duplicates removed (case-insensitive / case-sensitive)

@(X->Reverse())

the list reversed

@(X->DirectoryName()), @(X->Combine('sub')), @(X->Exists()), @(X->GetPathsOfAllDirectoriesAbove())

path operations on each item

@(X->WithMetadataValue('M', 'v')), @(X->WithoutMetadataValue('M', 'v'))

filter on a metadata value

@(X->AnyHaveMetadataValue('M', 'v'))

true/false

@(X->HasMetadata('M')), @(X->Metadata('M')), @(X->ClearMetadata())

items that have M, the values of M, the items without metadata

@(X->ToUpperInvariant()), @(X->Replace('a', 'b')), …

any System.String instance method, applied to each identity

Functions chain: @(Journal->DirectoryName()->Distinct()) above gave the one folder the journals share.

Transforms

A transform maps every item through a template that may contain metadata references: @(Compile->'obj/%(Filename).g.cs') turned Ledger.cs;Reader.cs into obj/Ledger.g.cs;obj/Reader.g.cs. Any metadata can be used, several may be combined ('%(RecursiveDir)%(Filename).g%(Extension)'), and a second argument replaces the default ; separator (@(Journal->'%(Filename)', ' | ')). Because a transform preserves the order and the one-to-one relationship between inputs and outputs, a target whose Outputs is a transform of its Inputs can rebuild only the outputs whose input changed (Incremental Builds and Batching).

Common mistakes

  • Unquoted property references as arguments — an empty property removes the argument and the call fails or picks another overload; write '$(X)'.

  • Comparing versions as strings in conditions — use VersionGreaterThanOrEquals.

  • Using GetHashCode() for file names — not stable across processes; use StableStringHash.

  • Calling a method that is not on the allowed list — MSB4185; move the logic into a task.

  • @(X->'...') in a property outside a target — stored as text and expanded later (Evaluation, then execution).