Conditional Compilation and Directives
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. The examples are complete
console programs that start with Option Strict On; they were built with warnings treated as errors and run in the .NET 10 SDK
container (Linux, arm64), once per configuration where the result depends on it. The multi-targeting example also builds a net481
assembly, which was compiled but not run, because .NET Framework does not run on Linux; that is labelled where it matters. Where an
example is meant to fail, the compiler messages shown are the ones the SDK printed. Every console example has the root namespace
ShelfApp, because some CLR and compiler messages include it.
Conditional compilation lets one source tree produce different assemblies: a Debug build with extra checks, a Release build without
them, a net481 build that avoids APIs the old framework lacks, a build with an optional feature switched on. Visual Basic does it with a
small set of directives, lines that start with # and that the compiler reads while it scans the file, before it analyses any
statement. This page covers every directive the language has, the constants that drive them, how projects define constants, the
Conditional attribute that works at the call site, and what is different from C#, whose directive set is larger. The attribute syntax
is on Attributes, Caller Information and Reflection;
the build properties are on Projects, MSBuild and the dotnet CLI.
For the C# directive set, see Preprocessor Directives and Compilation (C#).
The directives at a glance
| Directive | What it does | Scope of its effect |
|---|---|---|
|
includes or excludes whole blocks of source according to conditional-compilation constants |
the lines between the directives |
|
defines a conditional-compilation constant inside a source file |
that file only, from that line on |
|
names a collapsible block; no effect on the compiled code |
editor outlining only |
|
tells the compiler to report the enclosed lines as coming from another file and line number |
diagnostics, debug information and caller information |
|
associates a checksum with an external file named in |
debug information |
|
switches individual compiler or analyzer warnings off and on for a stretch of source (Visual Basic 14) |
from the directive to the matching enable or the end of the file |
Source: Visual Basic directives
That is the complete list: the compiler’s own diagnostic for a stray #define or #pragma names these six (see The C# directives that Visual Basic does not have).
Directives are not statements. Each one must be alone on its line, in the first position of the logical line, with no other code before
it. #If blocks may be placed anywhere that a statement, a declaration or a whole file-level construct may start, and they may nest.
Their conditions are not Visual Basic expressions; they use a small separate language, described next.
Conditional blocks: #If, #ElseIf, #Else and #End If
The block syntax mirrors the If statement, with the directive keywords in front:
#If condition Then
' compiled when the condition is True
#ElseIf otherCondition Then
' compiled when the first is False and this is True
#Else
' compiled when none of the above is True
#End If
Source: If, ElseIf and Else directives
The first program uses the two constants that every Debug build has, DEBUG and TRACE. It was built and run twice, as dotnet run (the
Debug configuration) and as dotnet run -c Release:
Option Strict On
Module Program
Private Function BuildKind() As String
#If DEBUG Then
Return "Debug"
#Else
Return "Release"
#End If
End Function
Sub Main()
Console.WriteLine("Shelf catalogue, " & BuildKind() & " build")
#If DEBUG Then
Console.WriteLine("Diagnostics: extra checks are compiled in")
#End If
#If TRACE Then
Console.WriteLine("Trace calls are compiled in")
#End If
End Sub
End Module
Debug configuration:
Shelf catalogue, Debug build
Diagnostics: extra checks are compiled in
Trace calls are compiled in
Release configuration (-c Release):
Shelf catalogue, Release build
Trace calls are compiled in
The differences between the two runs are not decided when the program starts: the Release assembly does not contain the strings or the
calls that were in the excluded blocks. That is the property that makes conditional compilation different from an If statement, and it is
the subject of the section on Compile time or run time.
The rules of the block form
-
Every
#Ifneeds a#End If; every#ElseIfand#Elseneeds an open#If;#Elseis last, and#ElseIfcannot follow it. -
There is no single-line form.
#If DEBUG Then Console.WriteLine("x")is not a one-lineIf; the compiler reads it as an#Ifblock that never ends. Nothing else may share a line with a directive. -
Thenis accepted but, in the SDK tested here, was not required:#If DEBUGon its own compiled and selected the block. Write it anyway; the documented syntax has it, and the editor reformats to it. -
The block contents must be complete logical statements or declarations. You cannot conditionally compile only the attribute list of a method, but you can conditionally declare the whole method, attributes included. Learn’s own example puts the
WebMethodattribute inside each branch of a pair of complete declarations. -
Excluded text is skipped, not compiled. The compiler looks in it for directives and for nothing else, so it may contain code that does not compile, names that do not exist, or text that is not Visual Basic at all.
-
#ElseIfis the only spelling documented. In the SDK tested here,#Else Ifwas also accepted and behaved like#ElseIf. Do not rely on it. -
Conditions are always evaluated with
Option Compare Binary, whatever the file says.
The next two programs show three of these rules. The first file has Option Compare Text, which makes "COMMUNITY" = "community" true at run time, and
the #If conditions still compare in binary mode. It also shows that a normal Const is invisible to the directives, and the
first block that is skipped contains text that is not Visual Basic:
Option Strict On
Option Compare Text
#Const Edition = "Community"
Module Program
Private Const Level As Integer = 3
Sub Main()
Console.WriteLine("Run time, Option Compare Text: " & ("COMMUNITY" = "community"))
#If Edition = "community" Then
Console.WriteLine("Compile time: case-insensitive match")
#Else
Console.WriteLine("Compile time: always compared with Option Compare Binary, no match")
#End If
#If Level = 3 Then
Console.WriteLine("A Const is visible to #If")
#Else
Console.WriteLine("A Const is not visible to #If: Level is undefined there")
#End If
End Sub
End Module
Run time, Option Compare Text: True
Compile time: always compared with Option Compare Binary, no match
A Const is not visible to #If: Level is undefined there
Option Strict On
#Region "Domain"
Friend Class Book
Friend ReadOnly Property Title As String
Friend Sub New(title As String)
Me.Title = title
End Sub
End Class
#End Region
Module Program
#Region "Helpers"
Private Function Shout(text As String) As String
#Region "Inside a method"
Return text.ToUpperInvariant()
#End Region
End Function
#Region "Nested"
Private Function Quiet(text As String) As String
Return text.ToLowerInvariant()
End Function
#End Region
#End Region
Sub Main()
#If False Then
This text is not Visual Basic at all. It is skipped, so it is never parsed.
Dim broken As = "unterminated
#ElseIf False Then
More skipped text
#Else
Console.WriteLine("The #Else branch is compiled")
#End If
Dim book As New Book("Dune")
Console.WriteLine(Shout(book.Title) & " / " & Quiet(book.Title))
End Sub
End Module
The #Else branch is compiled
DUNE / dune
Source: Const directive
Errors in the block structure
Each group of errors is a separate failing program, because the first structural error can hide the rest. The compiler messages:
| Mistake | Message printed by the SDK |
|---|---|
A missing |
|
|
|
|
|
|
|
A one-line |
|
The complete list of messages, with their causes, is in the Error reference at the end of the page.
Conditional-compilation constants and #Const
A conditional-compilation constant is a name with a value that exists only for the compiler. It is not a variable and not a Const: it cannot be
read from ordinary code, and an ordinary Const cannot be read from a directive (the last block of the program above printed that). A constant
comes from one of three places: the SDK, the project file or the command line, or a #Const line in a source file. This section is about the third.
#Const name = expression
The expression is made of literals, other conditional constants and operators. Unlike C#, where a symbol is only defined or not defined,
a Visual Basic constant has a value of type Boolean, a number, a String, a Date or Nothing. The program below defines constants of several types and
tests each one. All the #Const lines but the last are at file level, before the module; the last one is inside Main, redefines Level, and the final test spells the name in lower case:
Option Strict On
#Const Verbose = True
#Const Level = 3
#Const Edition = "Community"
#Const Ratio = 1.5
#Const Stamp = #2026-10-19#
#Const Loud = Verbose And Level > 2
#Const Label = Edition & "-Edition"
#Const Pick = If(Level > 2, "high", "low")
#Const Bits = (Level << 2) Or 1
#Const Half = Level \ 2
Module Program
Sub Main()
#If Verbose Then
Console.WriteLine("Verbose is True")
#End If
#If Level >= 3 Then
Console.WriteLine("Level is at least 3")
#End If
#If Level Then
Console.WriteLine("An Integer constant is True when it is not zero")
#End If
#If Edition = "Community" Then
Console.WriteLine("Edition is Community")
#End If
#If Ratio > 1 Then
Console.WriteLine("Ratio is greater than 1")
#End If
#If Stamp > #2026-01-01# Then
Console.WriteLine("Stamp is in 2026 or later")
#End If
#If Loud Then
Console.WriteLine("Loud is True")
#End If
#If Label = "Community-Edition" Then
Console.WriteLine("Label is Community-Edition")
#End If
#If Pick = "high" Then
Console.WriteLine("Pick is high")
#End If
#If Bits = 13 Then
Console.WriteLine("Bits is 13")
#End If
#If Half = 1 Then
Console.WriteLine("Half is 1")
#End If
#If Missing Then
Console.WriteLine("Missing is True")
#Else
Console.WriteLine("Missing is not defined, so it is False")
#End If
#If Not Missing Then
Console.WriteLine("Not Missing is True")
#End If
#Const Level = 7
#If Level = 7 Then
Console.WriteLine("Level was redefined and is now 7")
#End If
#If level = 7 Then
Console.WriteLine("Names are not case sensitive: level is 7")
#End If
End Sub
End Module
Verbose is True
Level is at least 3
An Integer constant is True when it is not zero
Edition is Community
Ratio is greater than 1
Stamp is in 2026 or later
Loud is True
Label is Community-Edition
Pick is high
Bits is 13
Half is 1
Missing is not defined, so it is False
Not Missing is True
Level was redefined and is now 7
Names are not case sensitive: level is 7
Source: Const directive
What the run shows:
-
A constant that is used but never defined has the value
Nothing, which a condition treats asFalse. A misspelled constant therefore silently removes a block instead of causing an error (see Pitfalls checklist). -
#Constis evaluated in order. The earlier blocks sawLevel = 3and the last ones sawLevel = 7, even though the redefinition is textually after the code that ran first. A constant used before it is defined isNothingat that point. -
Names are not case sensitive, as everywhere in Visual Basic:
levelandLevelare the same constant, and so aredebugandDEBUG. -
An
IntegerorDoubleisTruewhen it is not zero, so#If Level Thenworks; aStringcan only be compared, never used as a condition. -
A
#Constline can also appear inside a procedure; Learn says there are no strict placement rules. Put the lines at the top of the file, where they are visible.
What an expression may contain
The condition and the value of a #Const may use literals, constants and these operators. I tested each of them in a one-line #Const
and a #If on SDK 10.0.402:
| Allowed | Examples | Notes |
|---|---|---|
Arithmetic |
|
|
Shifts |
|
|
Comparison |
|
strings and dates compare as in |
Logical |
|
|
Concatenation of strings |
|
a |
|
|
|
Conversion function |
|
|
Literals |
|
And these are rejected; the program below contains one of each (an ordinary function call, a method call, Like, concatenating a number, and two bad comparisons):
Option Strict On
#Const Level = 3
#Const Edition = "Community"
#Const Joined = Edition & Level
#Const Length = Len(Edition)
#Const Text = Level.ToString()
#Const Matches = Edition Like "C*"
Module Program
Sub Main()
#If Edition Then
Console.WriteLine("A String cannot be used as a Boolean condition")
#End If
#If Level = "3" Then
Console.WriteLine("A String cannot be compared with an Integer")
#End If
End Sub
End Module
Program.vb(5,1): error BC30059: Constant expression is required.
Program.vb(6,20): error BC30205: End of statement expected.
Program.vb(7,20): error BC30205: End of statement expected.
Program.vb(8,18): error BC31427: Syntax error in conditional compilation expression.
Program.vb(12,1): error BC30060: Conversion from 'String' to 'Boolean' cannot occur in a constant expression.
Program.vb(15,1): error BC30060: Conversion from 'String' to 'Double' cannot occur in a constant expression.
BC30205 for Len(Edition) and Level.ToString() and BC31427 for Like show the limit: there are no function or method calls and no Like, TypeOf or Is
in a conditional-compilation expression. BC30059 ("Constant expression is required") is what the compiler says when an operand cannot be folded to a
constant, as with CStr(Level) or a string joined with an integer. The two BC30060 messages are the type-mismatch rejections: a String cannot act as a
Boolean condition and cannot be compared with a number (the rule belongs to the directive language, not to Option Strict).
If a decision needs real code, put it in an ordinary method and select the method call with #If.
A #Const belongs to one file
Learn: "Conditional compiler constants are always private to the file in which they appear." The two-file program shows it. Part1.vb defines OnlyHere;
Part2.vb tests the same name:
Option Strict On
#Const OnlyHere = True
Friend Module Part1
Friend Sub Report()
#If OnlyHere Then
Console.WriteLine("Part1: OnlyHere is True")
#Else
Console.WriteLine("Part1: OnlyHere is False")
#End If
End Sub
End Module
Option Strict On
Module Part2
Sub Main()
Part1.Report()
#If OnlyHere Then
Console.WriteLine("Part2: OnlyHere is True")
#Else
Console.WriteLine("Part2: OnlyHere is False")
#End If
End Sub
End Module
Part1: OnlyHere is True
Part2: OnlyHere is False
Source: Const directive
This is a design feature rather than a limitation: a file-level constant cannot affect other files by accident. Constants that must apply to the whole project belong in the project file, below.
A file-level constant overrides a project-level one
A constant defined with #Const replaces a constant of the same name from the project, but only for the rest of that file. The project below sets
FEATURE_EXPORT=True in DefineConstants; the file turns it off, and also turns off DEBUG in a Debug build:
<DefineConstants>FEATURE_EXPORT=True</DefineConstants>
Option Strict On
#Const FEATURE_EXPORT = False
#Const DEBUG = False
Module Program
Sub Main()
#If FEATURE_EXPORT Then
Console.WriteLine("FEATURE_EXPORT is on in this file")
#Else
Console.WriteLine("FEATURE_EXPORT is off in this file")
#End If
#If DEBUG Then
Console.WriteLine("DEBUG is on in this file")
#Else
Console.WriteLine("DEBUG is off in this file, although this is a Debug build")
#End If
End Sub
End Module
FEATURE_EXPORT is off in this file
DEBUG is off in this file, although this is a Debug build
Source: Const directive
Redefining a standard constant this way works, and it has a wider effect than the #If blocks of the file: the Conditional attribute (below) uses the same constants at the call site. A file with
#Const DEBUG = False that calls a <Conditional("DEBUG")> method in a Debug build had the call removed (the program printed only "Done"). Avoid redefining the standard constants; it makes one file disagree with the rest of the project.
The constants the SDK defines
Without writing a line, a project built with the .NET SDK has a set of constants. Some come from Visual Basic’s own targets file, some from the .NET SDK.
| Constant | Value in the tested projects | Where it comes from and what it means |
|---|---|---|
|
|
the SDK defines the upper-cased name of the build configuration ( |
|
|
the |
|
|
the name of the configuration, as written (case preserved) |
|
|
the |
|
|
the kind of output; |
|
|
the version of the Visual Basic compiler language, matching "Visual Basic 17.13" |
|
|
the |
|
|
target-framework symbols, see the next table |
|
|
one exact-version symbol and one "this version or newer" symbol for every supported target |
The DEBUG and TRACE constants are integers, not Booleans: -1 is the value of True when converted to a number. This is why #If DEBUG = -1 Then and
#If DEBUG Then both work. The next program asks the compiler about the others. It cannot print the values directly (a conditional constant is not
readable from code), so each #If sets an ordinary variable. It was run in both configurations:
Option Strict On
Module Program
Private Sub Report(name As String, isDefined As Boolean)
Console.WriteLine(name.PadRight(26) & If(isDefined, "yes", "no"))
End Sub
Sub Main()
Dim vbcVer As Boolean = False
Dim target As String = "?"
Dim config As String = "?"
Dim platform As String = "?"
Dim myType As String = "?"
#If VBC_VER = 17.13 Then
vbcVer = True
#End If
#If TARGET = "exe" Then
target = "exe"
#End If
#If CONFIG = "Debug" Then
config = "Debug"
#ElseIf CONFIG = "Release" Then
config = "Release"
#End If
#If PLATFORM = "AnyCPU" Then
platform = "AnyCPU"
#End If
#If _MyType = "Empty" Then
myType = "Empty"
#End If
Console.WriteLine("VBC_VER = 17.13 " & vbcVer)
Console.WriteLine("TARGET " & target)
Console.WriteLine("CONFIG " & config)
Console.WriteLine("PLATFORM " & platform)
Console.WriteLine("_MyType " & myType)
Console.WriteLine()
Dim isDebug As Boolean = False
Dim isRelease As Boolean = False
Dim isTrace As Boolean = False
Dim isNet As Boolean = False
Dim isCoreApp As Boolean = False
Dim isNet10 As Boolean = False
Dim isNet10Up As Boolean = False
Dim isNet11Up As Boolean = False
Dim isFramework As Boolean = False
#If DEBUG Then
isDebug = True
#End If
#If RELEASE Then
isRelease = True
#End If
#If TRACE Then
isTrace = True
#End If
#If NET Then
isNet = True
#End If
#If NETCOREAPP Then
isCoreApp = True
#End If
#If NET10_0 Then
isNet10 = True
#End If
#If NET10_0_OR_GREATER Then
isNet10Up = True
#End If
#If NET11_0_OR_GREATER Then
isNet11Up = True
#End If
#If NETFRAMEWORK Then
isFramework = True
#End If
Report("DEBUG", isDebug)
Report("RELEASE", isRelease)
Report("TRACE", isTrace)
Report("NET", isNet)
Report("NETCOREAPP", isCoreApp)
Report("NET10_0", isNet10)
Report("NET10_0_OR_GREATER", isNet10Up)
Report("NET11_0_OR_GREATER", isNet11Up)
Report("NETFRAMEWORK", isFramework)
End Sub
End Module
Debug configuration:
VBC_VER = 17.13 True
TARGET exe
CONFIG Debug
PLATFORM AnyCPU
_MyType Empty
DEBUG yes
RELEASE no
TRACE yes
NET yes
NETCOREAPP yes
NET10_0 yes
NET10_0_OR_GREATER yes
NET11_0_OR_GREATER no
NETFRAMEWORK no
Release configuration (-c Release):
VBC_VER = 17.13 True
TARGET exe
CONFIG Release
PLATFORM AnyCPU
_MyType Empty
DEBUG no
RELEASE yes
TRACE yes
NET yes
NETCOREAPP yes
NET10_0 yes
NET10_0_OR_GREATER yes
NET11_0_OR_GREATER no
NETFRAMEWORK no
Three details are worth keeping from that output. CONFIG comes through with the configuration’s own spelling; RELEASE exists because the SDK defines it from the configuration name, as it does DEBUG; and TRACE is defined in Release as well, which is why
Trace.WriteLine still works there and Debug.WriteLine does not.
Custom configurations
Any configuration name works with the .NET SDK, not just Debug and Release. Building the program below with dotnet build -c Staging defines
STAGING and sets CONFIG to "Staging", but not DEBUG and not TRACE:
Option Strict On
Module Program
Sub Main()
#If DEBUG Then
Console.WriteLine("DEBUG")
#End If
#If DEBUG = -1 Then
Console.WriteLine("DEBUG = -1")
#End If
#If STAGING Then
Console.WriteLine("STAGING is defined")
#End If
#If CONFIG = "Staging" Then
Console.WriteLine("CONFIG is Staging")
#End If
#If TRACE Then
Console.WriteLine("TRACE")
#End If
End Sub
End Module
STAGING is defined
CONFIG is Staging
Names with a dash, a period or a space are rewritten with an underscore before they become constants (Pre-Release gives PRE_RELEASE), according to the SDK targets.
Setting DisableImplicitConfigurationDefines to true stops the SDK from adding the configuration constant at all.
Target-framework symbols
The SDK adds the symbols for the target framework of each build; with several target frameworks (Multi-targeting: net481 and net10.0) each target is compiled with
its own set. I built a library once per target and listed which of a set of symbols were defined (the program in each case was a series of #If blocks that
declare an unused variable, and the compiler warning BC42024 named the blocks it had compiled):
| Target framework | Symbols that were defined (from the list tested) |
|---|---|
|
|
|
|
|
|
|
|
|
as |
Two consequences follow. A NET symbol is not a .NET Framework symbol: it means ".NET 5 or later" (NETFRAMEWORK is the old one). And a symbol such as NET8_0 is
true only on exactly that target, which is why code should use the _OR_GREATER form for "this API exists". The symbols named WINDOWS and LINUX do not come from the
operating system the build runs on: WINDOWS was defined for net10.0-windows and not for net10.0, net8.0, netstandard2.0 or net481 built on this Linux machine, and LINUX was not defined for any of them.
Asking "what operating system is the program running on?" is a run-time question (OperatingSystem.IsWindows()), answered at the end of this page.
Defining constants in the project
Constants for every file in the project go in the project file with DefineConstants. The property’s format is the one place where the Visual Basic compiler
differs most from the C# one, so it is worth stating exactly what I tested:
| Item | Visual Basic compiler | Result tested |
|---|---|---|
Separator |
a comma |
|
Value |
|
|
String value |
the value must carry double quotes; in an MSBuild file write them as |
|
Appending to the SDK’s value |
|
works; the SDK does not seed |
A semicolon in DefineConstants is wrong for Visual Basic although it is the C# separator: the value reaches the compiler as one string, which must be comma separated. The failing build prints:
vbc : error BC31030: Conditional compilation constant 'FEATURE_EXPORT=True ^^ ^^ ;MAX_ITEMS=50' is not valid: End of statement expected.
The working form, with three constants of three types, keeping everything the SDK adds:
<PropertyGroup>
<DefineConstants>FEATURE_EXPORT=True,MAX_ITEMS=50,EDITION="Community"</DefineConstants>
</PropertyGroup>
Option Strict On
Module Program
Sub Main()
#If FEATURE_EXPORT Then
Console.WriteLine("FEATURE_EXPORT is on")
#End If
#If MAX_ITEMS > 10 Then
Console.WriteLine("MAX_ITEMS is greater than 10")
#End If
#If EDITION = "Community" Then
Console.WriteLine("EDITION is Community")
#End If
#If DEBUG Then
Console.WriteLine("DEBUG is defined")
#End If
#If TRACE Then
Console.WriteLine("TRACE is defined")
#End If
#If NET10_0 Then
Console.WriteLine("NET10_0 is defined")
#End If
End Sub
End Module
FEATURE_EXPORT is on
MAX_ITEMS is greater than 10
EDITION is Community
DEBUG is defined
TRACE is defined
NET10_0 is defined
Source: -define (Visual Basic)
The output also shows that the constants the SDK defines (DEBUG, TRACE, NET10_0) are still there. They are not part of the DefineConstants property: the Visual
Basic targets append CONFIG, DEBUG, TRACE, _MyType and PLATFORM to the value the compiler finally receives.
Constants that depend on the configuration
Because DefineConstants is an ordinary MSBuild property, a condition on Configuration (or on the target framework) selects the constants. The project below
defines LEVEL=1 always and adds FAST_PATH and LEVEL=2 only in Release:
<PropertyGroup>
<DefineConstants>$(DefineConstants),LEVEL=1</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<DefineConstants>$(DefineConstants),FAST_PATH=True,LEVEL=2</DefineConstants>
</PropertyGroup>
Option Strict On
Module Program
Sub Main()
#If FAST_PATH Then
Console.WriteLine("FAST_PATH is defined")
#Else
Console.WriteLine("FAST_PATH is not defined")
#End If
#If LEVEL = 2 Then
Console.WriteLine("LEVEL is 2")
#End If
End Sub
End Module
Debug (dotnet run):
FAST_PATH is not defined
Release (dotnet run -c Release):
FAST_PATH is defined
LEVEL is 2
Source: -define (Visual Basic)
(The leading comma that appears when $(DefineConstants) is empty is harmless.) The same program can be used to test any constant you add.
DefineDebug, DefineTrace and the implicit configuration constant
DEBUG and TRACE are controlled by two Boolean properties, DefineDebug and DefineTrace. Turning DefineTrace off removes TRACE, but turning DefineDebug off does
not remove DEBUG in a Debug build: the SDK then defines the upper-cased configuration name instead (DEBUG), so the symbol is back. To remove it you also have to disable the implicit
configuration constant:
<PropertyGroup>
<DefineDebug>false</DefineDebug>
<DefineTrace>false</DefineTrace>
<DisableImplicitConfigurationDefines>true</DisableImplicitConfigurationDefines>
</PropertyGroup>
I built the program that tests DEBUG, TRACE and NET10_0 three ways, with the properties above and different values:
| Properties | Constants defined (of DEBUG, TRACE, NET10_0) |
|---|---|
|
|
the three properties above |
|
|
|
Disabling the framework or configuration constants is rarely a good idea, because the libraries you reference are built against the same assumptions; it exists for build systems that supply their own.
From the command line
An MSBuild property can be set on the command line, and a property given there replaces the project’s. A comma in the value separates the constants, so the form tested here writes it as %2c:
dotnet run -p:DefineConstants=FEATURE_EXPORT=True%2cMAX_ITEMS=50
Run on the program above, the output was:
FEATURE_EXPORT is on
MAX_ITEMS is greater than 10
DEBUG is defined
TRACE is defined
NET10_0 is defined
Source: -define (Visual Basic)
The DEBUG, TRACE and NET10_0 constants survive, because they do not live in DefineConstants. The Visual Basic command-line compiler vbc takes the same list with -define
(short form -d), comma separated, with strings quoted as \"text\"; since dotnet build is the normal route, that is rarely needed.
Scope summary (Learn’s table, restated with what the SDK adds):
| How the constant is set | Scope |
|---|---|
|
every file in the project |
SDK and targets ( |
every file in the project |
|
that file only, from that line on; replaces a project constant of the same name |
Multi-targeting: net481 and net10.0
A library that must serve both .NET Framework and modern .NET is built for several target frameworks, and conditional compilation covers the places where the APIs
differ. The project below targets net481 and net10.0; the folder Legacy is compiled only for net481, by removing it from the other targets:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net481;net10.0</TargetFrameworks>
<RootNamespace>ShelfApp</RootNamespace>
<OptionStrict>On</OptionStrict>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' != 'net481'">
<Compile Remove="Legacy\**" />
</ItemGroup>
</Project>
The source selects an implementation by symbol. Convert.ToHexString exists from .NET 5; the older framework needs BitConverter. The NETFRAMEWORK branch also calls into the
Legacy file that only the net481 build compiles:
Option Strict On
Namespace Legacy
Friend Module Net481Support
Friend Function Describe() As String
Return "compiled from the Legacy folder (net481 only)"
End Function
End Module
End Namespace
Option Strict On
Module Program
Function ToHex(data As Byte()) As String
#If NET5_0_OR_GREATER Then
Return Convert.ToHexString(data)
#Else
Return BitConverter.ToString(data).Replace("-", "")
#End If
End Function
Function RuntimeKind() As String
#If NETFRAMEWORK Then
Return ".NET Framework (NETFRAMEWORK, " & NetVersionSymbol() & ")"
#ElseIf NET10_0_OR_GREATER Then
Return ".NET 10 or later (NET10_0_OR_GREATER)"
#ElseIf NETCOREAPP Then
Return "an older .NET (NETCOREAPP)"
#Else
Return "something else"
#End If
End Function
#If NETFRAMEWORK Then
Function NetVersionSymbol() As String
#If NET481 Then
Return "NET481"
#Else
Return "another .NET Framework version"
#End If
End Function
#End If
Sub Main()
Console.WriteLine("Running on " & RuntimeKind())
Console.WriteLine("Hex: " & ToHex(New Byte() {1, 171, 255}))
#If NETFRAMEWORK Then
Console.WriteLine(Legacy.Net481Support.Describe())
#End If
End Sub
End Module
Run with dotnet run -f net10.0:
Running on .NET 10 or later (NET10_0_OR_GREATER)
Hex: 01ABFF
dotnet build compiled both targets with no warning, so the net481 assembly exists and is warning-free under TreatWarningsAsErrors; I could not run that assembly on Linux. To prove that the net481
build did take the NETFRAMEWORK and NET481 branches (and the .NET 10 build the NET10_0_OR_GREATER one), I added a deliberately unused local variable to each branch and built with warnings
as non-fatal; the compiler reported BC42024 for the variable in the NET481 branch when building net481 and for the one in the NET10_0_OR_GREATER branch when building net10.0, and for no other branch. The Linux build of net481 works because the SDK restores the
Microsoft.NETFramework.ReferenceAssemblies package, which needs one NuGet restore with network access the first time.
Guidelines that follow from those tests:
-
Test for the capability, not for the framework you remember.
NET5_0_OR_GREATERforConvert.ToHexStringsays why the branch exists. A#If NETFRAMEWORKsays who is running, which is less precise, andNETis true on .NET 5 and later only. -
Keep the
#Ifblocks small and put the difference behind a function. A public API that changes shape between targets is much harder to document and to test than one that is identical everywhere and calls a privateToHex. -
Prefer separate files for large differences. The
Compile Removeitem in the project keeps the file out of the compilation for other targets, so neither the compiler nor the analyzers see it, and a refactoring tool still sees the whole file when it is open. A file-per-target convention (Legacy,Modern) is easier to review than interleaved blocks. -
Build every target in CI. A branch that a developer’s usual target never compiles can rot.
dotnet buildwith no-fbuilds all targets. -
Reference packages conditionally the same way: an
ItemGroupwithCondition="'$(TargetFramework)' == 'net481'"adds a package or a reference for one target only.
#Region and #End Region
A region names a block of source that an editor can collapse. It has no effect on the compiled code or on name scope: a procedure declared inside a region is just a member of the type, and a variable declared in a region inside a method is an ordinary local of the method.
#Region "Name"
' any complete or partial declarations or statements
#End Region
Source: Region directive
The earlier program with the skipped block already contained regions at file level, in a module, inside a method and nested. A region can also cross an #If block, as in
the next program, whose #Region starts inside a conditional block and ends outside it; the two directive kinds are processed independently:
Option Strict On
Module Program
Sub Main()
#If DEBUG Then
#Region "Debug helpers"
Console.WriteLine("Region opened inside an #If block")
#End If
Console.WriteLine("Region still open after #End If")
#End Region
End Sub
End Module
Region opened inside an #If block
Region still open after #End If
Source: Region directive
Crossing blocks like this is legal, but not a good idea: when the #If condition is False (the same program built with -c Release) the #Region line is skipped and the #End Region is left without a match, and the build fails with BC30680: '#End Region' must be preceded by a matching '#Region'. Keep each region entirely inside
or entirely outside a conditional block. The error messages for broken regions:
Program.vb(6,8): error BC30217: String constant expected.
Program.vb(5,1): error BC30681: '#Region' statement must end with a matching '#End Region'.
Program.vb(6,1): error BC30680: '#End Region' must be preceded by a matching '#Region'.
(The first line is #Region without a name, the second a region that is never closed, the third an #End Region without a start.) Regions are a matter of taste: many teams treat a region as a sign that a class is too big, and prefer smaller classes, partial classes or extension methods
(Extension Methods and Partial Methods) over hiding code in folds.
#ExternalSource and #ExternalChecksum
Tools that generate Visual Basic from another file (a template, a markup page, an XML description) need errors, debug locations and caller information to point at the original file rather than at the generated one. The directive does it:
#ExternalSource("filename", lineNumber)
' statements reported as coming from filename starting at lineNumber
#End ExternalSource
Source: ExternalSource directive
The program below puts statements in Generated under #ExternalSource("ShelfPage.template", 100). A CallerFilePath and CallerLineNumber helper (see
Attributes, Caller Information and Reflection) reports the file and line the compiler recorded, and the stack trace of an
exception thrown inside the block names the template:
Option Strict On
Imports System.IO
Imports System.Runtime.CompilerServices
Module Program
Function Here(<CallerFilePath> Optional callerFile As String = "",
<CallerLineNumber> Optional callerLine As Integer = 0) As String
Return Path.GetFileName(callerFile) & ":" & callerLine
End Function
Sub Generated()
#ExternalSource("ShelfPage.template", 100)
Console.WriteLine("Mapped code " & Here())
Console.WriteLine("Mapped next line " & Here())
Throw New InvalidOperationException("Failed in generated code")
#End ExternalSource
End Sub
Sub Main()
Console.WriteLine("Normal code " & Here())
Try
Generated()
Catch ex As InvalidOperationException
Console.WriteLine(ex.Message)
Dim top As String = ex.StackTrace.Split(ControlChars.Lf)(0)
Console.WriteLine(Path.GetFileName(top.Substring(top.IndexOf(" in ", StringComparison.Ordinal) + 4)))
End Try
Console.WriteLine("After the block " & Here())
End Sub
End Module
Normal code Program.vb:21
Mapped code ShelfPage.template:100
Mapped next line ShelfPage.template:101
Failed in generated code
ShelfPage.template:line 102
After the block Program.vb:29
Line numbers inside the block count from the number in the directive: the first statement is line 100, the next is 101, the Throw is 102. Outside the block the numbers are the real ones of Program.vb.
Debuggers and exception stack traces show the template and its line, as the line ShelfPage.template:line 102 in the output shows.
The block is not optional once opened: leaving out the #End ExternalSource gives
Program.vb(3,1): error BC30579: '#ExternalSource' statement must end with a matching '#End ExternalSource'.
#ExternalChecksum is the companion: it associates a name, a GUID that identifies the checksum algorithm, and a hex checksum with the external file so the debugger can verify that the source it finds matches. It is a
generator’s directive, written once near the top of a generated file:
Option Strict On
#ExternalChecksum("Template.tt", "{406EA660-64CF-4C82-B6F0-42D48172A799}", "0123456789ABCDEF")
Module Program
Sub Main()
#ExternalSource("Template.tt", 7)
Console.WriteLine("Mapped to Template.tt")
#End ExternalSource
End Sub
End Module
Mapped to Template.tt
Source: ExternalSource directive
Hand-written code does not need either directive; ASP.NET (Web, HTTP and Services) and code generators are the typical users.
#Disable Warning and #Enable Warning
Visual Basic 14 (Visual Studio 2015) added directives that switch a warning off for a stretch of source and back on. They are the Visual Basic equivalent of C#'s pragma warning.
The syntax takes a comma-separated list of diagnostic IDs, or no ID, which means all warnings:
#Disable Warning BC42024 ' one warning
#Disable Warning BC42024, BC42105 ' several
#Disable Warning ' all warnings
#Enable Warning BC42024
#Enable Warning ' re-enable all
A test with eight variables and different directive forms was run with warnings not treated as errors. These were the results that matter:
-
#Disable Warning BC42024suppresses the unused-variable warning on the lines that follow, up to the matching#Enable Warning BC42024(or the end of the file). -
A list (
BC42024, BC42105) works. A bare#Disable Warningdisabled the warning too; a bare#Enable Warningswitched it back on. -
The ID is not case sensitive (
bc42024was accepted), but it must start with the prefix: a bare number (#Disable Warning 42024) fails with BC30203 "Identifier expected". -
An unknown ID (
BC99999) was accepted without complaint, so a typo silently suppresses nothing. -
The directives work for analyzer IDs too (
CA2200below).
A realistic use is to leave one deliberate construct in place and keep the rest of the build strict. The program below has TreatWarningsAsErrors on. Without the two pairs of directives
it would fail to build, with BC42024 for spare and CA2200 for Throw ex:
Option Strict On
Module Program
Sub Rethrow()
Try
Throw New InvalidOperationException("inner")
Catch ex As InvalidOperationException
#Disable Warning CA2200 ' Rethrow to preserve stack details
Throw ex
#Enable Warning CA2200
End Try
End Sub
Sub Main()
#Disable Warning BC42024 ' Unused local variable
Dim spare As Integer
#Enable Warning BC42024
Try
Rethrow()
Catch ex As InvalidOperationException
Console.WriteLine("Caught: " & ex.Message)
End Try
End Sub
End Module
Caught: inner
When a warning is not suppressed and warnings are errors, the compiler reports it as an error with the same ID. This program leaves one unused variable unsuppressed:
Option Strict On
Module Program
Sub Main()
#Disable Warning BC42024
Dim hidden As Integer
#Enable Warning BC42024
Dim shown As Integer
Console.WriteLine("Built")
End Sub
End Module
Program.vb(8,13): error BC42024: Unused local variable: 'shown'.
There are four tools for the same job, with different reach:
| Tool | Reach | Tested result |
|---|---|---|
|
a region of one file |
suppresses the lines in between (above) |
|
the whole project |
the same program without any directive builds and runs (note: the ID goes in the property with the prefix; the compiler accepted |
|
every file below the |
built; also lets you raise a diagnostic: |
|
the project |
the warning stays visible but does not fail the build: the program with a plain unused variable built and printed |
Raising the severity in .editorconfig is the useful direction in a team: it turns a warning you care about into a build error, while the directives are for local exceptions.
The .editorconfig and analyzer options are covered in XML Documentation, Analyzers and Code Style.
With dotnet_diagnostic.BC42024.severity = error and warnings not treated as errors, the program with one suppressed and one unsuppressed variable failed with:
Program.vb(6,13): error BC42024: Unused local variable: 'hidden'.
Program.vb(8,13): error BC42024: Unused local variable: 'shown'.
Both variables are reported. When the directives are present (the program with hidden and shown), only shown is reported, so a #Disable Warning directive still wins over the severity from .editorconfig:
Program.vb(8,13): error BC42024: Unused local variable: 'shown'.
The Conditional attribute and the Debug class
A directive removes source text. A Conditional attribute removes calls: a call to a method marked <Conditional("NAME")> is compiled only when NAME is defined at the call site. The
method itself is always in the assembly, and the arguments of the call are not evaluated when the call is dropped. System.Diagnostics.Debug and Trace use the same mechanism, so
Debug.WriteLine and Debug.Assert disappear from a Release build together with the cost of their arguments.
In the example, Tracing.vb declares two conditional methods, one on DEBUG and one on the custom constant AUDIT. Program.vb defines AUDIT with #Const, so its calls to Audit compile,
and it also calls Debug.Assert with an argument that has a side effect:
Option Strict On
Imports System.Diagnostics
Module Tracing
Friend Calls As Integer
Friend Function Describe() As String
Calls += 1
Return "description #" & Calls
End Function
<Conditional("DEBUG")>
Friend Sub DebugNote(message As String)
Console.WriteLine("[debug] " & message)
End Sub
<Conditional("AUDIT")>
Friend Sub Audit(message As String)
Console.WriteLine("[audit] " & message)
End Sub
End Module
Option Strict On
#Const AUDIT = True
Imports System.Diagnostics
Module Program
Sub Main()
DebugNote(Describe())
Audit(Describe())
Debug.Assert(Describe().Length > 0)
Console.WriteLine("Describe was evaluated " & Calls & " time(s)")
End Sub
End Module
Debug configuration, AUDIT defined in Program.vb:
[debug] description #1
[audit] description #2
Describe was evaluated 3 time(s)
Release configuration (-c Release), same source:
[audit] description #1
Describe was evaluated 1 time(s)
Debug configuration with the #Const AUDIT line removed from Program.vb:
[debug] description #1
Describe was evaluated 2 time(s)
Source: ConditionalAttribute
Read the three counts: in Debug, Describe ran three times (the call to DebugNote, the call to Audit and the Debug.Assert argument); in Release only the Audit call survived, because the
AUDIT constant is defined in Program.vb regardless of the configuration; and without AUDIT in Debug, Audit and its argument vanished. The rule has consequences:
-
The decision belongs to the file that makes the call, not the file that declares the method. The declaring file is irrelevant.
-
Arguments of a dropped call are not evaluated. Never put a side effect that matters (an increment, a write, an I/O call) in the argument of a conditional call or of
Debug.Assert. -
A conditional method must be a
Sub. On aFunctionthe attribute is ignored with the warningBC41007: Attribute 'Conditional' is only valid on 'Sub' declarations.(an error underTreatWarningsAsErrors), and the call is always made: a test with<Conditional("DEBUG")> Function Check() As BooleanprintedTrue. -
Debugmembers are conditional onDEBUG;Tracemembers onTRACE, which is defined in Release too.
Source: ConditionalAttribute
Compile time or run time
An If statement and an #If directive look alike, but they run at different times and the second one changes what the compiler checks. With an ordinary constant, both branches must compile:
Option Strict On
Module Program
Private Const AuditEnabled As Boolean = False
Sub Main()
If AuditEnabled Then
Console.WriteLine(MissingAuditTrail)
End If
#If AUDIT_ENABLED Then
Console.WriteLine(MissingAuditTrail)
#End If
Console.WriteLine("Done")
End Sub
End Module
Program.vb(8,31): error BC30451: 'MissingAuditTrail' is not declared. It may be inaccessible due to its protection level.
Source: If, ElseIf and Else directives
The If branch is dead code, but it is type checked, so it fails; the excluded #If block with the same text did not. That is the main difference, and also the main danger of directives: code in an excluded block can rot unseen.
The reverse choice also exists: for a condition that is only known at run time, a directive is the wrong tool, because the decision is already made when the program starts.
Option Strict On
Imports System.Diagnostics
Imports System.Runtime.InteropServices
Module Program
Sub Main()
' Compile-time decisions: one branch is compiled into the assembly.
#If DEBUG Then
Console.WriteLine("Compiled as a Debug build")
#Else
Console.WriteLine("Compiled as a Release build")
#End If
' Run-time decisions: both branches are always compiled.
Console.WriteLine("Debugger attached: " & Debugger.IsAttached)
Console.WriteLine("Running on Linux: " & OperatingSystem.IsLinux())
Console.WriteLine("Running on Windows: " & OperatingSystem.IsWindows())
Console.WriteLine("Framework: " & RuntimeInformation.FrameworkDescription)
Console.WriteLine("Is the assembly built for Debug? " & IsDebugAssembly())
End Sub
Private Function IsDebugAssembly() As Boolean
Dim attribute As DebuggableAttribute =
CType(Reflection.Assembly.GetExecutingAssembly().GetCustomAttributes(GetType(DebuggableAttribute), False)(0), DebuggableAttribute)
Return attribute.IsJITOptimizerDisabled
End Function
End Module
Debug build:
Compiled as a Debug build
Debugger attached: False
Running on Linux: True
Running on Windows: False
Framework: .NET 10.0.13
Is the assembly built for Debug? True
Release build (-c Release):
Compiled as a Release build
Debugger attached: False
Running on Linux: True
Running on Windows: False
Framework: .NET 10.0.13
Is the assembly built for Debug? False
Source: Debugger.IsAttached
The first line of each run is a compile-time decision, the other lines are run-time ones. Debugger.IsAttached and OperatingSystem.IsLinux() are evaluated by the running program, so
a single assembly can ask them; the last line reads the DebuggableAttribute that the compiler wrote and shows the effect of the Debug configuration on the assembly, a separate setting (Optimize, DebugType) from the DEBUG constant.
Platform differences that are only known at run time belong to those APIs; the [SupportedOSPlatform] attribute and the CA1416 analyzer help to keep them honest (see
Native Interop: Declare and P/Invoke).
Choose by this rule of thumb:
| The difference is… | Use |
|---|---|
a different API or type exists in different target frameworks |
|
code that must not exist at all in the product (test hooks, debugging dumps) |
|
a feature switch chosen at build time |
a constant in |
something the user or the machine decides |
an ordinary |
expensive diagnostics that should be switchable in production |
|
The C# directives that Visual Basic does not have
C# has the same idea with a bigger vocabulary. What Visual Basic lacks, and what it does instead:
| C# directive | Visual Basic | What to do instead |
|---|---|---|
|
no directive |
|
|
|
almost the same; the IDs have the compiler prefix ( |
|
|
generator use only |
|
none |
Visual Basic does not have nullable reference types; see Nothing and Nullable Value Types |
|
|
maps lines to another file in a block; no |
|
none |
a compile-time message needs an analyzer or a build step ( |
|
|
same |
|
|
C# symbols only have two states; Visual Basic constants carry values, so |
|
a comma |
see the table above; strings need |
The directive keywords are not case sensitive (a test with #if debug then, #else and #end if indented under the code compiled and selected the Debug branch), unlike C#'s lower-case-only directives, and a directive may be indented. The compiler
message when it meets a C# directive in a Visual Basic file lists the legal ones. A file containing #define FEATURE, #pragma warning disable, #nullable enable, #error stop, #warning careful, #line 5 and #undef FEATURE, one per line, gave seven times:
Program.vb(3,1): error BC30248: 'If', 'ElseIf', 'Else', 'Const', 'Region', 'ExternalSource', 'ExternalChecksum', 'Enable', 'Disable', 'End' or 'R' expected.
(one message for each of lines 3 to 9, all with the same text.)
Error reference
Every message below was printed by SDK 10.0.402 for one of the failing programs on this page (all in Option Strict On, warnings as errors off for the failing programs).
| Code | Message | Cause |
|---|---|---|
|
missing |
|
|
a stray |
|
|
a stray |
|
|
wrong order |
|
|
an operand of |
|
|
a |
|
|
|
|
BC30205 |
|
a function or method call in a constant expression |
|
a bad |
|
|
|
|
|
unclosed region |
|
|
stray |
|
|
unclosed external source |
|
|
a C# directive such as |
|
|
warning; an error with |
|
BC30203 |
|
|
BC30451 |
|
code in a branch that |
Source: Visual Basic error messages
Pitfalls checklist
-
A misspelled constant is
Nothing, so its#Ifblock silently vanishes. After adding a constant, build once with it on and check that the code is in the assembly (a deliberate unused variable in the block gives a warning that proves it). -
;inDefineConstantsfails in Visual Basic; use,. A string constant needs"in the project file. -
#Constis per file; two files that disagree about a constant compile different code. Put project-wide constants in the project. -
DefineDebug=falsedoes not removeDEBUG;NETdoes not mean .NET Framework;WINDOWSis a target-framework constant, not a build-machine one. -
Arguments to
Conditionalmethods andDebug.Assertare not evaluated when the call is dropped. -
Excluded code is not compiled: build every configuration and target in CI so it cannot rot.
-
Keep
#Ifblocks small; wrap the difference in a function instead of interleaving it with logic. -
A region is documentation for an editor, not structure for the compiler. Do not let one cross an
#Ifblock. -
Do not use
#Disable Warningwithout an ID, and add a comment saying why. An unknown ID is accepted silently. -
No conditional constant can be read at run time. If a program must report its build configuration, expose it through a function that is itself compiled conditionally, or read
DebuggableAttributeas above.
References
-
Visual Basic language reference: directives, with If, ElseIf and Else, Const, Region, ExternalSource and Disable Warning and Enable Warning
-
Target frameworks in SDK-style projects (preprocessor symbols for each target framework)
-
Attributes, Caller Information and Reflection, Projects, MSBuild and the dotnet CLI, Program Structure and Code Conventions and Preprocessor Directives and Compilation (C#)