重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
migrate-nullable-references by dotnet/skills
npx skills add https://github.com/dotnet/skills --skill migrate-nullable-references包含 Shell 命令
此技能包含可能执行系统命令的 shell 命令指令(!command``)。安装前请仔细审查。
在现有代码库中启用 C# 可空引用类型 (NRT),并系统地解决所有警告。最终得到一个启用了 <Nullable>enable</Nullable>、零可空警告且公共 API 接口已准确标注的项目(或解决方案)——为编译器和使用者提供可靠的可空性信息。
<Nullable>enable</Nullable> 且无警告——迁移已完成,除非用户希望重新检查抑制项以移除不必要的抑制(参见步骤 6)| 输入 | 是否必需 | 描述 |
|---|---|---|
| 项目或解决方案路径 | 是 | 要迁移的 、 或构建入口点 |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
.csproj.sln| 迁移范围 | 否 | project-wide(默认)或 file-by-file——控制推出策略 |
| 构建命令 | 否 | 如何构建项目(例如,dotnet build、msbuild 或特定于仓库的构建脚本)。如果未提供,则从仓库中检测 |
| 测试命令 | 否 | 如何运行测试(例如,dotnet test 或特定于仓库的测试脚本)。如果未提供,则从仓库中检测 |
🛑 零运行时行为变更。 NRT 迁移严格来说是元数据和标注工作。生成的 IL 必须保持不变——没有新的分支、没有新的空值检查、没有更改的控制流、没有添加或移除的方法调用。唯一可接受的变更是可空标注(
?)、可空属性([NotNullWhen]等)、!运算符(仅元数据)和#nullable指令。如果在迁移过程中发现缺少运行时空值防护或潜在错误,不要内联修复它。相反,建议在相应位置插入// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)注释,以便用户将其作为单独的变更来处理。切勿将行为修复混入标注提交中。
提交策略: 在每个逻辑边界处提交——在启用
<Nullable>之后(步骤 2)、修复解引用警告之后(步骤 3)、标注声明之后(步骤 4)、应用可空属性之后(步骤 5)以及清理抑制项之后(步骤 6)。这使每次提交都保持专注且易于审查,并防止在后续步骤发现需要重新思考的设计问题时丢失工作。对于逐文件迁移,单独提交每个文件或相关文件批次。
可选: 运行
scripts/Get-NullableReadiness.ps1 -Path <project-or-solution>来自动执行以下检查。该脚本报告<Nullable>、<LangVersion>、<TargetFramework>、<WarningsAsErrors>设置,并统计#nullable disable指令、!运算符和#pragma warning disable CS86xx抑制项的数量。使用-Json获取机器可读的输出。
build.cmd、build.sh、Makefile)、.sln 文件或单独的 .csproj 文件。如果仓库使用自定义构建脚本,请在整个工作流程中使用它而不是 dotnet build。dotnet --version 以确认 SDK 已安装。可空引用类型 (NRT) 需要 C# 8.0+(.NET Core 3.0 / .NET Standard 2.1 或更高版本)。.csproj(或者如果属性是在仓库级别设置的,则打开 Directory.Build.props)并检查 <LangVersion> 和 <TargetFramework>。如果项目多目标,请记下所有 TFM。如果语言版本或目标框架不足,请停止。 如果
<LangVersion>低于 8.0,或者项目目标框架默认使用 C# 7.x(例如,没有显式<LangVersion>的.NET Framework 4.x),则无法按原样启用 NRT。明确告知用户:解释需要更改的内容(设置<LangVersion>8.0</LangVersion>或更高版本,或重新定位到.NET Core 3.0+/.NET 5+),并询问他们是否希望进行此更新并继续,或者中止迁移。不要静默继续或假设更新是可接受的。
<Nullable>。如果设置为 enable,则跳至步骤 5 以审核剩余的警告。? 都是使用者依赖的契约变更。要精确且保守。!,因为这些地方预期不会出现空值。重点确保测试代码能干净地编译。根据代码库大小和活动水平选择以下策略之一。向用户推荐该策略并在继续之前确认。
多项目解决方案: 按依赖顺序迁移——共享库和核心项目优先,然后是使用它们的项目。首先标注依赖项可以消除其使用者中的级联警告,并避免重复工作。
无论采用哪种策略,从中心开始向外扩展:从核心领域模型、DTO 和共享工具类型开始,这些类型依赖项少但被广泛使用。首先标注这些类型可以消除整个代码库中的级联警告,并带来最大的努力回报。然后转向依赖核心类型的高级服务、控制器和 UI 代码。这种方法最大限度地减少了每个步骤中的警告数量,并防止因项目范围启用而产生大量警告而不知所措。最好为每个项目或每个层创建至少一个 PR,以保持变更集可审查且专注。如果需要相对较少的标注,则项目范围启用和单个 PR 可能是合适的。
当项目源文件少于大约 50 个或团队希望一次完成时最佳。
.csproj 的 <PropertyGroup> 中添加 <Nullable>enable</Nullable>。当代码库很大或有多个贡献者积极开发时最佳。
.csproj 中添加 <Nullable>warnings</Nullable>。这会在不改变类型语义的情况下启用警告。<Nullable>enable</Nullable> 以激活标注——这会触发第二波警告。对于大型遗留代码库最佳,因为项目范围启用会产生无法管理的警告数量。
<Nullable>disable</Nullable>(或省略)。#nullable enable。构建检查点: 在启用
<Nullable>(或向第一批文件添加#nullable enable)之后,执行干净构建(例如,dotnet build --no-incremental,或先删除bin/obj)。增量构建仅重新编译已更改的文件,会隐藏未触及文件中的警告。记录初始警告计数——这是要减少的基线。在确认项目仍能编译之前,不要继续修复警告。在此工作流程的所有后续构建检查点中使用干净构建。
优先级: 按依赖顺序处理文件——从其他代码依赖的核心模型和共享工具开始,然后转向高级使用者。在每个文件内,首先修复公共和受保护成员(这些定义了契约),然后是内部和私有成员。此顺序最大限度地减少了级联警告:修复核心类型的标注通常会自动解决其使用者中的警告。
构建项目并处理解引用警告。这些是最常见的:
| 警告 | 含义 | 典型修复方法 |
|---|---|---|
| CS8602 | 解引用可能为空的引用 | 优先使用仅标注的修复:如果空值是有效的,则使上游类型可空(T?);或者如果可以验证该值在此点绝不为空,则使用 !。添加空值检查或 ?. 会改变运行时行为——将这些保留给单独的提交(参见上面的零行为变更规则) |
| CS8600 | 将可能的空值转换为不可空类型 | 如果空值是有效的,则在目标类型上添加 ?;或者如果可以验证该值绝不为空,则使用 !。添加空值防护会改变运行时行为 |
| CS8603 | 可能的空引用返回 | 如果方法确实可以返回空值,则将返回类型更改为可空(T?)。如果方法确实可以返回空值,不要用 ! 抑制——而是修复返回类型。这是 NRT 迁移中最重要的规则:不可空的返回类型是对每个调用者的承诺,即永远不会返回空值 |
| CS8604 | 可能的空引用参数 | 如果空值是有效的,则将参数标记为可空;或者如果参数可验证为非空,则使用 !。在传递前添加空值检查会改变运行时行为 |
❌ 不要使用
?.作为解引用警告的快速修复。 将obj.Method()替换为obj?.Method()会静默改变运行时行为——调用被跳过而不是抛出异常。仅当您有意容忍空值时才使用?.。
❌ 不要到处撒
!来抑制警告。 每个!都声称该值绝不为空。如果该声明是错误的,您就隐藏了一个NullReferenceException。请添加空值检查或将类型改为可空。
❌ 切勿使用
return null!来保持返回类型不可空。 如果方法返回null,则返回类型必须是T?。编写return null!会将空值隐藏在不可空签名后面——调用者信任签名,跳过空值检查,并在运行时得到NullReferenceException。这同样适用于null!、default!以及任何使编译器在不可空位置接受空值的强制转换。在返回值上使用!的唯一可接受情况是当该值可证明绝不为空但编译器无法理解原因时。
⚠️ 不要向值类型添加
?,除非您打算更改运行时类型。 对于引用类型,?仅是元数据。对于值类型(int、枚举、结构体),?会将类型更改为Nullable<T>,从而改变方法签名、二进制布局和装箱行为。
每个警告的决策流程图:
?(使其可空)。! 并附上解释原因的注释。if、??、is not null)。指导原则:
if、is not null、??)而不是空值宽容运算符(!)。[NotNull] 装饰参数,这会在防护调用后缩小空状态。在 Guard.Against.NullOrEmpty(value, nameof(value)) 之后,编译器已经将 string? 缩小为 string——不要在后续赋值中添加冗余的 !。在假设编译器需要帮助之前,请检查防护方法是否使用了 [NotNull]。T?——不要将空值隐藏在不可空签名后面。Debug.Assert(x != null) 就像 if 检查一样,作为空状态提示提供给编译器。在方法或块的顶部使用它,以告知流分析器有关不变量,并消除该作用域中后续的 ! 运算符。注意:Debug.Assert 会通知编译器,但在 Release 构建中被剥离——它不会在运行时防止空值。对于公共 API 边界,优先使用显式空值检查或 ArgumentNullException。!,请考虑将该参数改为可空。将 ! 保留给编译器确实无法证明非空性的情况。if (IsValid(x)) 意味着 x != null),优先向辅助方法的参数添加 [NotNullWhen(true)],而不是在每个调用点使用 !。这是一个仅元数据的变更(无行为变更),可以消除下游的 ! 运算符,同时为编译器提供真实的流信息。Init() 方法或构建器模式设置)的字段,优先在字段声明上使用 = null!,而不是在每个使用点添加 !。一个被访问 50 次的字段应该有一个 = null!,而不是五十个 field! 断言。这使字段在类型系统中保持不可空,同时承认延迟初始化。如果可能,与初始化方法上的 [MemberNotNull] 配对使用。default 的泛型方法(例如,FirstOrDefault<T>),使用 [return: MaybeNull] T 而不是 T?。在无约束泛型上编写 T? 会将值类型签名更改为 Nullable<T>,从而改变方法签名和二进制布局。[return: MaybeNull] 保留原始签名,同时传达返回值对于引用类型可能为空的信息。Where(x => x != null) 不会将 T? 缩小为 T——编译器无法通过传递给泛型方法的 lambda 跟踪可空性。使用 source.OfType<T>() 来过滤空值并进行正确的类型缩小。构建检查点: 修复解引用警告后,构建并确认在转向标注警告之前,零 CS8602/CS8600/CS8603/CS8604 警告残留。
首先根据每个成员的设计目的决定其预期的可空性——这个参数应该接受空值吗?这个返回值可能为空吗?相应地标注,然后解决任何由此产生的警告。不要让警告驱动您的标注;这会导致过度使用 ? 标注或到处撒 ! 来让编译器静音。
何时询问用户: 不要猜测 API 契约。切勿仅根据使用频率或命名约定推断可空性意图——如果意图在代码或文档中不明确,请询问用户。具体来说,在以下情况之前询问:(1) 将公共方法的返回类型更改为可空或在公共参数上添加
?——这会改变使用者依赖的 API 契约;(2) 当设计意图不明确时,决定属性应该是可空还是必需;(3) 当您无法从上下文中确定空值是否是有效状态时,在空值检查和!之间做出选择。对于内部/私有成员,如果答案从使用情况来看是显而易见的,则无需询问即可继续。
❌ 不要让警告驱动标注。 首先决定每个成员的预期可空性,然后进行标注。到处添加
?来让警告消失违背了目的——调用者必须添加不必要的空值检查。到处添加!会隐藏错误。
⚠️ 返回类型必须反映语义上的可空性,而不仅仅是编译器满意。 一个常见的错误是,因为实现使用了
default!或满足编译器的强制转换,而从返回类型中移除?。如果方法按设计可以返回空值,则其返回类型必须是可空的——无论编译器是否警告。关键模式:
- 名为
*OrDefault的方法(FirstOrDefault、SingleOrDefault、FindOrDefault)→ 返回类型必须是可空的(T?、object?、dynamic?),因为对于引用类型,“or default”意味着“or null”。ExecuteScalar和类似的数据库方法 → 返回类型必须是object?,因为当没有匹配行时,结果可以是DBNull.Value或空值。Find、TryGet*(out 参数)和查找方法 → 当项目可能不存在时,返回类型应该是可空的。- 任何在失败、未找到或空输入时返回空值的文档化或设计方法 → 可空返回类型。
当实现通过
!或default!隐藏空值时,编译器无法捕获返回类型上缺失的?。这使得标注对使用者来说是错误的——他们信任不可空签名并跳过空值检查,导致运行时出现NullReferenceException。
⚠️ 不要移除现有的
ArgumentNullException检查。 不可空参数标注仅是编译时提示——它无法防止运行时出现空值。使用旧版 C#、其他 .NET 语言、反射或!的调用者仍然可以传递空值。
⚠️ 标记缺少运行时空值验证的公共 API 方法——但不要添加检查。 在标注时,检查每个
public和protected方法:如果参数不可空(T,而不是T?),则应该有运行时空值检查(例如,ArgumentNullException.ThrowIfNull(param)或if (param is null) throw new ArgumentNullException(...))。如果没有,运行时传递的空值会导致方法体深处出现NullReferenceException,而不是在入口点出现清晰的ArgumentNullException。添加空值防护是运行时行为变更,不得作为 NRT 迁移的一部分。相反,询问用户是否希望在相应位置插入// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)注释。这对于调用者可能未启用 NRT 的库尤其重要。
对空值有定义行为的方法应接受可空参数。 如果方法优雅地处理空输入——返回空值、返回默认值或返回失败结果而不是抛出异常——则参数应为
T?,而不是T。BCL 遵循此约定:Path.GetPathRoot(string?)对空输入返回空值,而Path.GetFullPath(string)抛出异常。仅当空值导致异常时才使用不可空参数。当方法实际上容忍空值时将参数标记为不可空,会迫使调用者在调用前添加不必要的空值检查。灰色地带: 当参数既未验证、未清理,也未针对空值进行文档化时,请考虑:(1) 在您自己的代码库中是否曾经传递过空值?如果是 → 可空。(2) 调用者是否可能将空值用作“默认”或无操作占位符?如果是 → 可空。(3) 同一区域中的类似方法是否接受空值?如果是 → 为了一致性,可空。(4) 如果方法在很大程度上忽略空值且恰好能工作,但空值对于 API 的目的没有语义意义 → 不可空。当对参数的可空性与不可空性有疑问时,优先选择可空——这更安全,并且以后可以收紧。
解决解引用警告后,处理标注警告:
| 警告 | 含义 | 典型修复方法 |
|---|---|---|
| CS8618 | 不可空字段/属性未在构造函数中初始化 | 初始化该成员、使其可空(?)或使用 required(C# 11+)。对于总是在构造后但在构造函数外部(例如,由框架生命周期方法、Init() 调用或构建器模式)设置的字段,使用 = null! 来声明意图,同时使字段在每个使用点都保持不可空。如果辅助方法初始化字段,则用 [MemberNotNull(nameof(field))] 装饰它,以便编译器知道调用后字段非空 |
| CS8625 | 无法将空字面量转换为不可空类型 | 使目标可空或提供非空值 |
| CS8601 | 可能的空引用赋值 | 与 CS8600 相同的技术 |
对于每种类型,决定:这个成员是否可能为空?
?。required(C# 11+)。= null!。这使字段的类型在所有使用位置都保持不可空,同时告诉编译器“我保证在访问前会设置它。”这远比在每个使用点添加 ! 要好——一个被访问 50 次的字段需要 50 个 ! 运算符,而不是一个 = null!。如果初始化由特定方法完成,也考虑在该方法上使用 [MemberNotNull(nameof(field))]。首先将标注工作重点放在公共和受保护的 API 上——这些定义了使用者依赖的契约。内部和私有代码可以更自由地容忍 !,因为它不影响外部调用者。
公共库:跟踪破坏性变更。 如果项目是供他人使用的库,请创建一个
nullable-breaking-changes.md文件(或等效文件),并记录每个可能影响使用者的公共 API 变更。虽然向引用类型添加?仅是元数据且不是二进制破坏性变更,但对于已启用 NRT 的使用者来说,它是源代码破坏性变更——他们将获得新的警告或错误。需要记录的关键变更:
- 返回类型从
T更改为T?(使用者现在必须处理空值)- 参数从
T?更改为T(使用者不能再传递空值)- 参数从
T更改为T?(调用者中现有的空值检查变得不必要——影响小但值得注意)- 向值类型参数或返回添加
?(将T更改为Nullable<T>——二进制破坏性变更)- 在不存在的地方添加了新的
ArgumentNullException防护- 在标注过程中发现并修复的任何行为变更(例如,一个静默接受空值的方法现在抛出异常)
将此文件提交给用户审查。它也可以作为发布说明的基础。
特别注意:
required(C# 11+)、[JsonRequired](.NET 7+)或运行时验证来强制执行非空约束。领域模型代表内部不变量——优先使用具有构造函数强制执行的不可空属性,使无效状态无法表示。这种区别是迁移中最常出错的地方:将 DTO 视为领域模型会导致运行时 NullReferenceException;将领域模型视为 DTO 会导致到处出现不必要的空值检查。EventHandler? handler = SomeEvent; handler?.Invoke(...) 是惯用的。default(T) 时为空。如果 default 是该结构体的有效用法,则这些字段必须是可空的。如果预期永远不会出现 default(该结构体仅由特定 API 创建),则保持它们不可空,以避免给每个使用者带来不必要的空值检查负担。Dispose 后可能变为空,则保持其不可空。在处置后使用对象是契约违规——不要为此情况削弱标注。T?,您可以将重写声明为返回 T。参数类型必须与基类完全匹配。Object.ToString()),则将返回标注为 T?——调用者需要知道。如果空值重写极其罕见(如 Exception.Message),则标注为 T。对于广泛重写的虚方法有疑问时,优先选择 T?。IEquatable<T> 和 IComparable<T>:引用类型应实现 IEquatable<T?> 和 IComparable<T?>(使用可空的 T),因为调用者通常将空值传递给 Equals 和 CompareTo。Equals(object?) 重写:向 Equals(object? obj) 重写的参数添加 [NotNullWhen(true)]——如果 Equals 返回 true,则保证参数非空。这允许调用者在相等性测试后跳过冗余的空值检查。构建检查点: 标注声明后,构建并确认在转向可空属性之前,零 CS8618/CS8625/CS8601 警告残留。
当简单的 ? 标注无法表达空值契约时,应用来自 System.Diagnostics.CodeAnalysis 的属性——请参阅 references/nullable-attributes.md 获取完整的属性表([NotNullWhen]、[MaybeNullWhen]、[MemberNotNull]、[AllowNull]、[DisallowNull]、[DoesNotReturn] 等)以及每个属性的使用指南。
构建检查点: 应用可空属性后,构建以验证属性解决了目标警告且未引入新的警告。
可选: 重新运行
scripts/Get-NullableReadiness.ps1以获取项目中#nullable disable指令、!运算符和#pragma warning disable CS86xx抑制项的当前计数。
#nullable disable 指令或 ! 运算符。#pragma warning disable CS86 以查找被抑制的可空警告,并评估是否可以修复根本问题。构建检查点: 移除抑制项后,再次构建——移除
#nullable disable或!可能会暴露出需要修复的新警告。
Directory.Build.props)中添加 <WarningsAsErrors>nullable</WarningsAsErrors>,以永久防止可空回归。这是项目文件中等效于 dotnet build /warnaserror:nullable 的设置。T?,拒绝空值的参数是 T)。在宣布迁移完成前进行验证。 零警告本身并不意味着迁移是正确的。在报告成功之前:(1) 抽查公共 API 签名——确认
?标注与实际设计意图匹配,而不仅仅是让编译器静音;(2) 验证没有添加改变运行时行为的?.运算符(在差异中搜索?.);(3) 确认没有移除ArgumentNullException检查;(4) 检查!运算符很少见,并且每个都有合理的注释。
<Nullable>enable</Nullable>(或对于逐文件策略,每个文件包含 #nullable enable)<WarningsAsErrors>nullable</WarningsAsErrors> 以防止回归#nullable disable 指令,除非有合理的注释说明!)很少见,每个都有合理的注释nullable-breaking-changes.md 中,并已由用户审查可空迁移变更需要比典型差异更广泛的审查:
? 和 ! 是唯一的添加——没有意外的 ?.、没有移除的空值检查、没有新的分支。生成的 IL 应保持不变,除了可空元数据。?,确认其与预期设计匹配。该方法真的接受空值吗?它真的可以返回空值吗?<Nullable>enable</Nullable> 会隐式地使该作用域内每个未标注的引用类型不可空。扫描未变更的公共成员,查找实际接受空值但未标注的参数。对于库,请参阅 references/breaking-changes.md——NRT 标注是公共 API 契约的一部分,不正确的标注对使用者来说是源代码破坏性变更。
| 陷阱 | 解决方案 |
|---|---|
到处撒 ! 来抑制警告 | 空值宽容运算符会隐藏错误。请添加空值检查或将类型改为可空 |
将所有内容标记为 T? 以快速消除警告 | 过度使用 ? 标注违背了目的——调用者必须添加不必要的空值检查。仅当空值是有效值时才使用 ? |
| 构造函数未初始化所有不可空成员 | 在每个构造函数中初始化字段和属性,使用 required(C# 11+),或使成员可空 |
| 序列化绕过构造函数——不可空 ≠ 运行时安全 | 序列化器创建对象时不调用构造函数,因此不可空的 DTO 属性在运行时仍然可能为空。有关详细指导,请参阅步骤 4 中的“DTO 与领域模型” |
| 生成的代码产生警告 | 如果生成的文件包含 <auto-generated> 注释,则会自动从可空分析中排除。如果警告仍然存在,请在生成的文件顶部添加 #nullable disable,或在 .editorconfig 中配置 generated_code = true |
| 多目标项目和旧的 TFM | NRT 标注可以在旧的 TFM(例如,.NET Standard 2.0)上使用 C# 8.0+ 编译,但像 [NotNullWhen] 这样的可空属性可能不存在。使用 NuGet 上的 polyfill 包,例如 Nullable,或在内部定义这些属性 |
| 升级依赖项后警告重新出现 | 依赖项添加了可空标注。这是预期且有益的——按照步骤 3–5 修复新警告 |
| 标注时意外改变行为 | 向类型添加 ? 或向表达式添加 ! 仅是元数据,不会改变生成的 IL。但是将 obj.Method() 替换为 obj?.Method()(空值条件)会改变运行时行为——调用被静默跳过而不是抛出异常。仅当您有意容忍空值时才使用 ?.,而不是作为警告的快速修复 |
向值类型(枚举、结构体)添加 ? | 对于引用类型,? 是元数据标注,没有运行时影响。对于像 int 或枚举这样的值类型,? 会将类型更改为 Nullable<T>,从而改变方法签名、二进制布局和装箱行为。仔细检查您只在向引用类型添加 ?,除非您确实打算使值类型可空 |
| 移除现有的空参数验证 | 不可空标注仅是编译时的——调用者仍然可以在运行时传递空值。保留现有的 ArgumentNullException 检查。有关详细信息,请参阅步骤 4 |
var 从赋值表达式推断可空性 | 使用 var 时,推断的类型包括赋值表达式的可空性,这可能与显式声明 T 与 T? 相比令人惊讶。流分析从该点开始确定实际的空状态,但推断的声明类型可能带有您未预期的可空性。如果声明时的精确可空性很重要,请使用显式类型而不是 var |
| 使用未标注(可空无感知)的库 | 当依赖项未选择加入可空标注时,编译器将其所有类型视为“无感知”——您不会因解引用或分配空值而收到警告。这给人一种错误的安全感。将来自无感知 API 的返回值视为可能为空,特别是对于概念上可能返回空值的方法(字典查找、FirstOrDefault 风格的调用)。尽可能升级依赖项或包装调用 |
如果项目使用 EF Core,请参阅 references/ef-core.md——启用 NRT 可能会更改数据库架构推断和迁移输出。
如果项目使用 ASP.NET Core,请参阅 references/aspnet-core.md——启用 NRT 可能会更改 MVC 模型验证和 JSON 序列化行为。
Contains Shell Commands
This skill contains shell command directives (!command``) that may execute system commands. Review carefully before installing.
Enable C# nullable reference types (NRTs) in an existing codebase and systematically resolve all warnings. The outcome is a project (or solution) with <Nullable>enable</Nullable>, zero nullable warnings, and accurately annotated public API surfaces — giving both the compiler and consumers reliable nullability information.
<Nullable>enable</Nullable> and zero warnings — the migration is done unless the user wants to re-examine suppressions with a view to removing unnecessary ones (see Step 6)| Input | Required | Description |
|---|---|---|
| Project or solution path | Yes | The .csproj, .sln, or build entry point to migrate |
| Migration scope | No | project-wide (default) or file-by-file — controls the rollout strategy |
| Build command | No | How to build the project (e.g., dotnet build, msbuild, or a repo-specific build script). Detect from the repo if not provided |
| Test command |
🛑 Zero runtime behavior changes. NRT migration is strictly a metadata and annotation exercise. The generated IL must not change — no new branches, no new null checks, no changed control flow, no added or removed method calls. The only acceptable changes are nullable annotations (
?), nullable attributes ([NotNullWhen], etc.),!operators (metadata-only), and#nullabledirectives. If you discover a missing runtime null guard or a latent bug during migration, do not fix it inline. Instead, offer to insert a// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)comment at the site so the user can address it as a separate change. Never mix behavioral fixes into an annotation commit.
Commit strategy: Commit at each logical boundary — after enabling
<Nullable>(Step 2), after fixing dereference warnings (Step 3), after annotating declarations (Step 4), after applying nullable attributes (Step 5), and after cleaning up suppressions (Step 6). This keeps each commit focused and reviewable, and prevents losing work if a later step reveals a design issue that requires rethinking. For file-by-file migrations, commit each file or batch of related files individually.
Optional: Run
scripts/Get-NullableReadiness.ps1 -Path <project-or-solution>to automate the checks below. The script reports<Nullable>,<LangVersion>,<TargetFramework>,<WarningsAsErrors>settings and counts#nullable disabledirectives,!operators, and#pragma warning disable CS86xxsuppressions. Use-Jsonfor machine-readable output.
build.cmd, build.sh, Makefile), a .sln file, or individual .csproj files. If the repo uses a custom build script, use it instead of dotnet build throughout this workflow.dotnet --version to confirm the SDK is installed. Nullable reference types (NRTs) require C# 8.0+ (.NET Core 3.0 / .NET Standard 2.1 or later)..csproj (or if properties are set at the repo level) and check the and . If the project multi-targets, note all TFMs.Stop if the language version or target framework is insufficient. If
<LangVersion>is below 8.0, or the project targets a framework that defaults to C# 7.x (e.g.,.NET Framework 4.xwithout an explicit<LangVersion>), NRTs cannot be enabled as-is. Inform the user explicitly: explain what needs to change (set<LangVersion>8.0</LangVersion>or higher, or retarget to.NET Core 3.0+/.NET 5+), and ask whether they want to make that update and continue, or abort the migration. Do not silently proceed or assume the update is acceptable.
<Nullable> is already set. If it is set to enable, skip to Step 5 to audit remaining warnings.? on a public parameter or return type is a contract change that consumers depend on. Be precise and conservative.! more freely on test setup and assertions where null is never expected. Focus on ensuring test code compiles cleanly.Pick one of the following strategies based on codebase size and activity level. Recommend the strategy to the user and confirm before proceeding.
Multi-project solutions: Migrate in dependency order — shared libraries and core projects first, then projects that consume them. Annotating a dependency first eliminates cascading warnings in its consumers and prevents doing work twice.
Regardless of strategy, start at the center and work outward :begin with core domain models, DTOs, and shared utility types that have few dependencies but are used widely. Annotating these first eliminates cascading warnings across the codebase and gives the biggest return on effort. Then move on to higher-level services, controllers, and UI code that depend on the core types. This approach minimizes the number of warnings at each step and prevents getting overwhelmed by a flood of warnings from a large project-wide enable. Prefer to create at least one PR per project, or per layer, to keep changesets reviewable and focused. If there are relatively few annotations needed, a single project-wide enable and single PR may be appropriate.
Best when the project has fewer than roughly 50 source files or the team wants to finish in one pass.
<Nullable>enable</Nullable> to the <PropertyGroup> in the .csproj.Best when the codebase is large or under active development by multiple contributors.
<Nullable>warnings</Nullable> to the .csproj. This enables warnings without changing type semantics.<Nullable>enable</Nullable> to activate annotations — this triggers a second wave of warnings.Best for large legacy codebases where enabling project-wide would produce an unmanageable number of warnings.
<Nullable>disable</Nullable> (or omit it) at the project level.#nullable enable at the top of each file as it is migrated.Build checkpoint: After enabling
<Nullable>(or adding#nullable enableto the first batch of files), do a clean build (e.g.,dotnet build --no-incremental, or deletebin/objfirst). Incremental builds only recompile changed files and will hide warnings in untouched files. Record the initial warning count — this is the baseline to work down from. Do not proceed to fixing warnings without first confirming the project still compiles. Use clean builds for all subsequent build checkpoints in this workflow.
Prioritization: Work through files in dependency order — start with core models and shared utilities that other code depends on, then move to higher-level consumers. Within each file, fix public and protected members first (these define the contract), then internal and private members. This order minimizes cascading warnings: fixing a core type's annotations often resolves warnings in its consumers automatically.
Build the project and work through dereference warnings. These are the most common:
| Warning | Meaning | Typical fix |
|---|---|---|
| CS8602 | Dereference of a possibly null reference | Prefer annotation-only fixes: make the upstream type nullable (T?) if null is valid, or use ! if you can verify the value is never null at this point. Adding a null check or ?. changes runtime behavior — reserve those for a separate commit (see zero-behavior-change rule above) |
| CS8600 | Converting possible null to non-nullable type | Add ? to the target type if null is valid, or use ! if you can verify the value is never null. Adding a null guard changes runtime behavior |
| CS8603 | Possible null reference return | Change the return type to nullable (T?) if the method can genuinely return null. — fix the return type instead. This is the single most important rule in NRT migration: a non-nullable return type is a promise to every caller that null will never be returned |
❌ Do not use
?.as a quick fix for dereference warnings. Replacingobj.Method()withobj?.Method()silently changes runtime behavior — the call is skipped instead of throwing. Only use?.when you intentionally want to tolerate null.
❌ Do not sprinkle
!to silence warnings. Each!is a claim that the value is never null. If that claim is wrong, you have hidden aNullReferenceException. Add a null check or make the type nullable instead.
❌ Never use
return null!to keep a return type non-nullable. If a method returnsnull, the return type must beT?. Writingreturn null!hides a null behind a non-nullable signature — callers trust the signature, skip null checks, and getNullReferenceExceptionat runtime. This applies tonull!,default!, and any cast that makes the compiler accept null in a non-nullable position. The only acceptable use of!on a return value is when the value is provably never null but the compiler cannot see why.
⚠️ Do not add
?to value types unless you intend to change the runtime type. For reference types,?is metadata-only. For value types (int, enums, structs),?changes the type toNullable<T>, altering the method signature, binary layout, and boxing behavior.
Decision flowchart for each warning:
? to the declaration (make it nullable).! with a comment explaining why.if, ??, is not null).Guidance:
if, is not null, ??) over the null-forgiving operator (!).[NotNull], which narrows null state after the guard call. After Guard.Against.NullOrEmpty(value, nameof(value)), the compiler already narrows string? to string — do not add a redundant ! at the subsequent assignment. Check whether the guard method uses [NotNull] before assuming the compiler needs help.Build checkpoint: After fixing dereference warnings, build and confirm zero CS8602/CS8600/CS8603/CS8604 warnings remain before moving to annotation warnings.
Start by deciding the intended nullability of each member based on its design purpose — should this parameter accept null? Can this return value ever be null? Annotate accordingly, then address any resulting warnings. Do not let warnings drive your annotations; that leads to over-annotating with ? or scattering ! to silence the compiler.
When to ask the user: Do not guess API contracts. Never infer nullability intent from usage frequency or naming conventions alone — if intent is not explicit in code or documentation, ask the user. Specifically, ask before: (1) changing a public method's return type to nullable or adding
?to a public parameter — this changes the API contract consumers depend on; (2) deciding whether a property should be nullable vs. required when the design intent is unclear; (3) choosing between a null check and!when you cannot determine from context whether null is a valid state. For internal/private members where the answer is obvious from usage, proceed without asking.
❌ Do not let warnings drive annotations. Decide the intended nullability of each member first, then annotate. Adding
?everywhere to make warnings disappear defeats the purpose — callers must then add unnecessary null checks. Adding!everywhere hides bugs.
⚠️ Return types must reflect semantic nullability, not just compiler satisfaction. A common mistake is removing
?from a return type because the implementation usesdefault!or a cast that satisfies the compiler. If the method can return null by design, its return type must be nullable — regardless of whether the compiler warns. Key patterns:
- Methods named
*OrDefault(FirstOrDefault,SingleOrDefault,FindOrDefault) → return type must be nullable (T?,object?,dynamic?) because "or default" means "or null" for reference types.
The compiler cannot catch a missing
?on a return type when the implementation hides null behind!ordefault!. This makes the annotation wrong for consumers — they trust the non-nullable signature and skip null checks, leading toNullReferenceExceptionat runtime.
⚠️ Do not remove existing
ArgumentNullExceptionchecks. A non-nullable parameter annotation is a compile-time hint only — it does not prevent null at runtime. Callers using older C# versions, other .NET languages, reflection, or!can still pass null.
⚠️ Flag public API methods missing runtime null validation — but do not add checks. While annotating, check each
publicandprotectedmethod: if a parameter is non-nullable (T, notT?), there should be a runtime null check (e.g.,ArgumentNullException.ThrowIfNull(param)orif (param is null) throw new ArgumentNullException(...)). Without one, a null passed at runtime causes aNullReferenceExceptiondeep in the method body instead of a clearArgumentNullExceptionat the entry point. Adding a null guard is a runtime behavior change and must not be part of the NRT migration. Instead, ask the user whether they want a// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)comment inserted at the site. This is especially important for libraries where callers may not have NRTs enabled.
Methods with defined behavior for null should accept nullable parameters. If a method handles null input gracefully — returning null, returning a default, or returning a failure result instead of throwing — the parameter should be
T?, notT. The BCL follows this convention:Path.GetPathRoot(string?)returns null for null input, whilePath.GetFullPath(string)throws. Only use a non-nullable parameter when null causes an exception. Marking a parameter as non-nullable when the method actually tolerates null forces callers to add unnecessary null checks before calling.Gray areas: When a parameter is neither validated, sanitized, nor documented for null, consider: (1) Is null ever passed in your own codebase? If yes → nullable. (2) Is null likely used as a "default" or no-op placeholder by callers? If yes → nullable. (3) Do similar methods in the same area accept null? If yes → nullable for consistency. (4) If the method is largely oblivious to null and just happens to work, but null makes no semantic sense for the API's purpose → non-nullable. When in doubt between nullable and non-nullable for a parameter, prefer nullable — it is safer and can be tightened later.
After dereference warnings are resolved, address annotation warnings:
| Warning | Meaning | Typical fix |
|---|---|---|
| CS8618 | Non-nullable field/property not initialized in constructor | Initialize the member, make it nullable (?), or use required (C# 11+). For fields that are always set after construction but outside the constructor (e.g., by a framework lifecycle method, an Init() call, or a builder pattern), use = null! to declare intent while keeping the field non-nullable at every use site. If a helper method initializes fields, decorate it with [MemberNotNull(nameof(field))] so the compiler knows the field is non-null after the call |
| CS8625 | Cannot convert null literal to non-nullable type | Make the target nullable or provide a non-null value |
| CS8601 | Possible null reference assignment | Same techniques as CS8600 |
For each type, decide: should this member ever be null?
? to its declaration.required (C# 11+).= null! on the field declaration. This keeps the field's type non-nullable everywhere it is used, while telling the compiler "I guarantee this will be set before access." This is far preferable to adding ! at every use site — a field accessed 50 times would need 50 ! operators instead of one = null!. If the initialization is done by a specific method, also consider [MemberNotNull(nameof(field))] on that method.Focus annotation effort on public and protected APIs first — these define the contract that consumers depend on. Internal and private code can tolerate ! more liberally since it does not affect external callers.
Public libraries: track breaking changes. If the project is a library consumed by others, create a
nullable-breaking-changes.mdfile (or equivalent) and record every public API change that could affect consumers. While adding?to a reference type is metadata-only and not binary-breaking, it IS source-breaking for consumers who have NRTs enabled — they will get new warnings or errors. Key changes to document:
- Return types changed from
TtoT?(consumers must now handle null)- Parameters changed from
T?toT(consumers can no longer pass null)- Parameters changed from
TtoT?(existing null checks in callers become unnecessary — low impact but worth noting)?added to a value type parameter or return (changes to — binary-breaking)
Present this file to the user for review. It may also serve as the basis for release notes.
Pay special attention to:
required (C# 11+), [JsonRequired] (.NET 7+), or runtime validation to enforce non-null constraints. Domain models represent internal invariants — prefer non-nullable properties with constructor enforcement, making invalid state unrepresentable. This distinction is where migrations most often go wrong: treating a DTO as a domain model leads to runtime NullReferenceException; treating a domain model as a DTO leads to unnecessary null checks everywhere.EventHandler? handler = SomeEvent; handler?.Invoke(...) is idiomatic.default(T). If default is valid usage for the struct, those fields must be nullable. If is never expected (the struct is only created by specific APIs), keep them non-nullable to avoid burdening every consumer with unnecessary null checks.Build checkpoint: After annotating declarations, build and confirm zero CS8618/CS8625/CS8601 warnings remain before moving to nullable attributes.
When a simple ? annotation cannot express the null contract, apply attributes from System.Diagnostics.CodeAnalysis — see references/nullable-attributes.md for the full attribute table ([NotNullWhen], [MaybeNullWhen], [MemberNotNull], [AllowNull], [DisallowNull], [DoesNotReturn], etc.) with usage guidance for each.
Build checkpoint: After applying nullable attributes, build to verify the attributes resolved the targeted warnings and did not introduce new ones.
Optional: Re-run
scripts/Get-NullableReadiness.ps1to get current counts of#nullable disabledirectives,!operators, and#pragma warning disable CS86xxsuppressions across the project.
#nullable disable directives or ! operators that were added as temporary workarounds.#pragma warning disable CS86 to find suppressed nullable warnings and evaluate whether the underlying issue can be fixed instead.Build checkpoint: After removing suppressions, build again — removing a
#nullable disableor!may surface new warnings that need fixing.
<WarningsAsErrors>nullable</WarningsAsErrors> to the project file (or Directory.Build.props for the whole repo) to permanently prevent nullable regressions. This is the project-file equivalent of dotnet build /warnaserror:nullable.T?, parameters that reject null are T).Verify before claiming the migration is complete. Zero warnings alone does not mean the migration is correct. Before reporting success: (1) spot-check public API signatures — confirm
?annotations match actual design intent, not just compiler silence; (2) verify no?.operators were added that change runtime behavior (search for?.in the diff); (3) confirm noArgumentNullExceptionchecks were removed; (4) check that!operators are rare and each has a justifying comment.
<Nullable>enable</Nullable> (or #nullable enable per-file for file-by-file strategy)<WarningsAsErrors>nullable</WarningsAsErrors> added to project file to prevent regressions#nullable disable directives remain unless justified with a comment!) are rare, each with a justifying commentnullable-breaking-changes.md and reviewed by the userNullable migration changes require broader review than a typical diff:
? and ! are the only additions — no accidental ?., no removed null checks, no new branches. The generated IL should be unchanged except for nullable metadata.? added to a parameter or return type, confirm it matches the intended design. Does the method really accept null? Can it really return null?<Nullable>enable</Nullable> implicitly makes every unannotated reference type in that scope non-nullable. Scan unchanged public members for parameters that actually do accept null but were not annotated.For libraries, see references/breaking-changes.md — NRT annotations are part of the public API contract and incorrect annotations are source-breaking changes for consumers.
| Pitfall | Solution |
|---|---|
Sprinkling ! everywhere to silence warnings | The null-forgiving operator hides bugs. Add null checks or change the type to nullable instead |
Marking everything T? to eliminate warnings quickly | Over-annotating with ? defeats the purpose — callers must add unnecessary null checks. Only use ? when null is a valid value |
| Constructor does not initialize all non-nullable members | Initialize fields and properties in every constructor, use required (C# 11+), or make the member nullable |
| Serialization bypasses constructors — non-nullable ≠ runtime safety | Serializers create objects without calling constructors, so non-nullable DTO properties can still be null at runtime. See "DTOs vs domain models" in Step 4 for detailed guidance |
| Generated code produces warnings |
If the project uses EF Core, see references/ef-core.md — enabling NRTs can change database schema inference and migration output.
If the project uses ASP.NET Core, see references/aspnet-core.md — enabling NRTs can change MVC model validation and JSON serialization behavior.
Weekly Installs
51
Repository
GitHub Stars
688
First Seen
Mar 10, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
github-copilot47
kimi-cli46
gemini-cli46
amp46
cline46
codex46
minimax-docx:基于 OpenXML SDK 的 DOCX 文档自动化创建与编辑工具
1,500 周安装
| No |
How to run tests (e.g., dotnet test, or a repo-specific test script). Detect from the repo if not provided |
Directory.Build.props<LangVersion><TargetFramework>! if the method can genuinely return null| CS8604 | Possible null reference argument | Mark the parameter as nullable if null is valid, or use ! if the argument is verifiably non-null. Adding a null check before passing changes runtime behavior |
T? — do not hide nulls behind a non-nullable signature.Debug.Assert(x != null) acts as a null-state hint to the compiler just like an if check. Use it at the top of a method or block to inform the flow analyzer about invariants and eliminate subsequent ! operators in that scope. Note: Debug.Assert informs the compiler but is stripped from Release builds — it does not protect against null at runtime. For public API boundaries, prefer an explicit null check or ArgumentNullException.! at every call site of an internal method, consider making that parameter nullable instead. Reserve ! for cases where the compiler genuinely cannot prove non-nullness.if (IsValid(x)) implies x != null), prefer adding [NotNullWhen(true)] to the helper's parameter over using ! at every call site. This is a metadata-only change (no behavior change) that eliminates ! operators downstream while giving the compiler real flow information.Init() method, or a builder pattern), prefer = null! on the field declaration over adding ! at every use site. A field accessed 50 times should have one = null!, not fifty field! assertions. This keeps the field non-nullable in the type system while acknowledging the late initialization. Pair with [MemberNotNull] on the initializing method when possible.default on an unconstrained type parameter (e.g., FirstOrDefault<T>), use [return: MaybeNull] T rather than T?. Writing T? on an unconstrained generic changes value-type signatures to Nullable<T>, altering the method signature and binary layout. [return: MaybeNull] preserves the original signature while communicating that the return may be null for reference types.Where(x => x != null) does not narrow T? to T — the compiler cannot track nullability through lambdas passed to generic methods. Use source.OfType<T>() to filter nulls with correct type narrowing.ExecuteScalar and similar database methods → return type must be object? because the result can be DBNull.Value or null when no rows match.Find, TryGet* (out parameter), and lookup methods → return type should be nullable when the item may not exist.TNullable<T>ArgumentNullException guards added where none existeddefaultDispose, keep it non-nullable. Using an object after disposal is a contract violation — do not weaken annotations for that case.T?, you can declare the override as returning T. Parameter types must match the base exactly.Object.ToString()), annotate the return as T? — callers need to know. If null overrides are vanishingly rare (like Exception.Message), annotate as T. When in doubt for broadly overridden virtuals, prefer T?.IEquatable<T> and IComparable<T>: Reference types should implement IEquatable<T?> and IComparable<T?> (with nullable T), because callers commonly pass null to Equals and CompareTo.Equals(object?) overrides: Add [NotNullWhen(true)] to the parameter of Equals(object? obj) overrides — if Equals returns true, the argument is guaranteed non-null. This lets callers skip redundant null checks after an equality test.Generated files are excluded from nullable analysis automatically if they contain <auto-generated> comments. If warnings persist, add #nullable disable at the top of the generated file or configure .editorconfig with generated_code = true |
| Multi-target projects and older TFMs | NRT annotations compile on older TFMs (e.g., .NET Standard 2.0) with C# 8.0+, but nullable attributes like [NotNullWhen] may not exist. Use a polyfill package such as Nullable from NuGet, or define the attributes internally |
| Warnings reappear after upgrading a dependency | The dependency added nullable annotations. This is expected and beneficial — fix the new warnings as in Steps 3–5 |
| Accidentally changing behavior while annotating | Adding ? to a type or ! to an expression is metadata-only and does not change generated IL. But replacing obj.Method() with obj?.Method() (null-conditional) changes runtime behavior — the call is silently skipped instead of throwing. Only use ?. when you intentionally want to tolerate null, not as a quick fix for a warning |
Adding ? to a value type (enum, struct) | For reference types, ? is a metadata annotation with no runtime effect. For value types like int or an enum, ? changes the type to Nullable<T>, altering the method signature, binary layout, and boxing behavior. Double-check that you are only adding ? to reference types unless you truly intend to make a value type nullable |
| Removing existing null argument validation | Non-nullable annotations are compile-time only — callers can still pass null at runtime. Keep existing ArgumentNullException checks. See Step 4 for details |
var infers nullability from the assigned expression | When using var, the inferred type includes nullability from the assigned expression, which can be surprising compared to explicitly declaring T vs T?. Flow analysis determines the actual null-state from that point forward, but the inferred declaration type may carry nullability you did not expect. If precise nullability at the declaration matters, use an explicit type instead of var |
| Consuming unannotated (nullable-oblivious) libraries | When a dependency has not opted into nullable annotations, the compiler treats all its types as "oblivious" — you get no warnings for dereferencing or assigning null. This gives a false sense of safety. Treat return values from oblivious APIs as potentially null, especially for methods that could conceptually return null (dictionary lookups, FirstOrDefault-style calls). Upgrade dependencies or wrap calls when possible |