Elixir v1.20:渐进式类型落地,不写注解也能查出类型错误

2026-06-04 19 预计阅读时间:1 分钟
来源:oschina.net AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:8 分钟

6月3日,José Valim签发了Elixir v1.20。这个版本的意义不在新函数或新模块,而在一件事:Elixir正式成为一门渐进式类型(Gradually Typed)语言。从今天起,即使你一行类型注解都不写,编译器也能推断出类型并报出潜在错误;而当你愿意标注类型时,检查精度会进一步提升。

渐进式类型,不是"渐进式注解"

渐进式类型的核心承诺是零注解也有收益。传统渐进类型语言(如TypeScript)的典型路径是:先写无类型的代码,运行没问题,然后逐步补注解。但Elixir的做法更激进——编译器本身在做推断,你不需要额外工具或插件,不需要--type-check开关,类型信息直接融入编译流程。

这意味着两件事:

  1. 存量代码立刻受益。你仓库里那些2018年写的、没有任何类型注解的模块,v1.20编译时就能被检查出一部分类型错误。
  2. 注解是可选的精度杠杆。当你对某个函数的输入输出有明确约束时,加注解能让编译器捕获更细的错误,但你不加也不会退回到"完全无检查"的状态。

类型系统的底层:集合论类型

Elixir的类型系统基于集合论类型(Set-theoretic Types)。这不是常见的Hindley-Milner推导,而是把类型当作集合来运算——交集、并集、补集直接对应类型约束的组合。

一个直观的理解:

  • integer() 是所有整数的集合
  • float() 是所有浮点数的集合
  • integer() or float() 就是这两个集合的并集,即数值类型
  • integer() and not 0 是所有非零整数

集合论类型让Elixir能表达比传统类型系统更细的约束,比如"这个参数要么是字符串要么是nil"(string() or nil),或者"返回值是正整数"(integer() and not (0 or neg_integer()))。这些约束在函数签名里可以直接写出来,编译器也能在推断中利用它们。

实际体验:从无注解到有注解

下面用一个具体模块演示渐进式类型的两种工作方式。

无注解,编译器也能抓错

defmodule Order do
  def total(items) do
    items
    |> Enum.map(fn item -> item.price * item.quantity end)
    |> Enum.sum()
  end

  def discount(total, rate) do
    total * rate
  end
end

这段代码没有一行类型注解。但在v1.20下,如果你调用时传了错误类型:

# 编译器会警告:rate 应该是数值,这里传了字符串
Order.discount(100, "0.2")

编译器通过推断知道total * rate中的*要求两个参数都是数值,因此rate的类型被推断为number()。传入字符串时,编译器在编译期就能发出警告——不需要你标注任何类型。

加注解,精度更高

当你对业务规则有明确约束时,注解让检查更严:

defmodule Order do
  @type item :: %{price: float(), quantity: pos_integer()}
  @type total :: non_neg_integer()

  @spec total(list(item())) :: total()
  def total(items) do
    items
    |> Enum.map(fn item -> item.price * item.quantity end)
    |> Enum.sum()
  end

  @spec discount(total(), float()) :: float()
  def discount(total, rate) do
    total * rate
  end
end

加了@spec之后,编译器能检查更多场景:

  • 传入空列表?list(item())允许空列表,但Enum.map在空列表上返回0,类型兼容。
  • 传入rate为负数?float()包含负浮点数,如果你想限制为0到1之间,需要更细的集合类型(目前标准库尚无0.0..1.0的浮点区间类型,可以用运行时校验补充)。
  • total()声明为non_neg_integer(),但函数体返回的是浮点数之和——编译器会报出返回类型不匹配。

这就是渐进式类型的杠杆:推断给你基础安全网,注解给你精确安全网

启用类型检查

v1.20中类型检查默认可能不是最高严格级别。你可以在项目配置中调整:

# config/config.exs 或 mix.exs 中
config :elixir, :type_checking, :strict

或者在编译时通过环境变量控制:

# 严格模式编译整个项目
ELIXIR_TYPE_CHECKING=strict mix compile

建议在CI中用严格模式编译,本地开发用默认级别,避免推断警告干扰日常开发节奏。

上手建议与注意事项

渐进式类型不是银弹,以下是实际落地时的几个判断:

场景 建议
存量项目升级到v1.20 先用默认级别编译,看推断产生的警告数量。警告太多说明代码中隐式类型依赖较重,逐模块修复,不要一次性开strict
新项目 从第一天就给核心模块加@spec,边缘模块可以不加,让推断覆盖
公共库/SDK 必须加@spec。库的用户依赖类型信息做调用推断,无注解的库会成为下游类型链的断点
性能敏感路径 类型检查只在编译期运行,不影响运行时性能。但@spec声明的集合论类型越复杂,编译时间越长——对大型项目注意编译耗时变化

几个常见坑:

  1. 集合论类型语法还在完善中integer() and not 0这类否定类型目前可以写,但更复杂的嵌套表达式可能有解析限制,遇到报错时简化表达式即可。
  2. 动态模式匹配与类型检查的张力。Elixir重度依赖模式匹配,而模式匹配本身就是一种运行时类型约束。编译器的类型推断会尽量利用模式匹配信息,但case的多分支可能产生并集类型,推断结果有时比你预期的更宽。遇到这种情况,在函数头加@spec比在case内部加更有效。
  3. 与dialyzer的关系。dialyzer是Elixir/Erlang长期使用的静态分析工具,基于success typing。v1.20的新类型系统不是dialyzer的替代,而是编译器内建的一层更严格的检查。两者可以共存,但警告内容可能有重叠,团队需要决定以哪个为主。

检查清单——升级v1.20时做这几件事:

  • [ ] mix deps.update elixir升级到v1.20
  • [ ] 用默认级别编译,记录推断警告数量
  • [ ] 给最核心的3-5个业务模块加@spec,观察编译器额外捕获的错误
  • [ ] CI中加入ELIXIR_TYPE_CHECKING=strict mix compile步骤
  • [ ] 评估编译耗时变化,如果strict模式编译时间超过团队容忍阈值,降级为警告模式并逐步收紧

Elixir用七年时间走到了渐进式类型这一步。从v1.20开始,类型安全不再是"要么全注解要么全裸奔"的二选一,而是你可以从推断给你的底线出发,按需加注解、按需收紧。对已有Elixir项目来说,这是最低门槛的类型安全入口——升级版本,编译一下,看看推断替你抓出了什么。


相关推荐