Split View: Elixir 1.20의 점진 타입 시스템 — 어노테이션 없이 "검증된 버그"를 찾는다는 것의 실제
Elixir 1.20의 점진 타입 시스템 — 어노테이션 없이 "검증된 버그"를 찾는다는 것의 실제
- 들어가며 — 4년 만에 끝난 첫 마일스톤
- dynamic()이 any()와 다른 지점
- 1.20이 실제로 새로 하는 일
- "13개 중 12개 통과"를 원본에 대조해 봤다
- 컴파일 시간 — "가장 빠르다"는 주장의 실제 크기
- 공짜가 아닌 부분
- 업그레이드 전에 알아야 할 것
- 타입 시그니처는 왜 아직 멀었나
- 그래서 지금 무엇을 해야 하나
- 마치며
- 참고 자료
들어가며 — 4년 만에 끝난 첫 마일스톤
Elixir 팀이 집합론적 타입(set-theoretic types)을 컴파일러에 넣겠다고 발표한 건 2022년입니다. 2023년에는 Giuseppe Castagna, Guillaume Duboc, José Valim이 The Design Principles of the Elixir Type System으로 설계 원리를 발표하면서 "연구에서 개발로 넘어간다"고 선언했습니다. 그리고 2026년 6월 3일, Elixir v1.20이 나오면서 그 첫 번째 마일스톤이 끝났습니다.
마일스톤의 내용은 이렇게 요약됩니다 — 타입 어노테이션을 하나도 도입하지 않은 채로, 모든 Elixir 프로그램에 대해 타입 추론과 점진적 타입 체크를 수행한다. 당신이 코드를 한 줄도 고치지 않아도 컴파일러가 죽은 코드와 "검증된 버그(verified bugs)"를 찾아 준다는 뜻입니다. 여기서 검증된 버그란 발표문의 정의대로 실행되면 런타임에 반드시 실패하도록 보장된 타이핑 위반을 말합니다.
이 글은 하이프를 걷어내고 세 가지를 봅니다. (1) 1.20이 실제로 새로 하는 일, (2) 발표문의 수치를 원본 소스에 대조하면 무엇이 남는지, (3) 문서에 적혀 있지만 발표문에는 없는 트레이드오프. Elixir 1.18과 OTP 27/28 시절의 전반적인 생태계 지형은 모던 Elixir/Phoenix 2026 편과 모던 Erlang/BEAM 2026 편에서 다뤘으니, 여기서는 타입 시스템만 파고듭니다.
dynamic()이 any()와 다른 지점
Elixir 타입 시스템의 목표는 공식 문서 기준으로 세 가지입니다 — 건전성(sound): 추론·할당된 타입이 프로그램의 실제 동작과 일치한다. 점진성(gradual): dynamic() 타입이 있고, dynamic()이 없으면 정적 타입 시스템처럼 동작한다. 개발자 친화성: 타입을 합집합·교집합·부정이라는 기본 집합 연산으로 기술한다(그래서 "집합론적"입니다).
여기서 핵심은 dynamic()입니다. 많은 점진 타입 시스템의 any()는 타입 시스템 관점에서 "아무거나 다 된다"를 뜻하고, 그래서 위반을 아예 보고하지 않습니다. Elixir의 dynamic()은 다릅니다. 발표문의 표현으로는 두 가지 성질을 갖습니다 — 호환성(compatibility) 과 좁히기(narrowing).
호환성부터 봅시다. 발표문이 든 예를 그대로 옮기면 이렇습니다.
def percentage_or_error(value) when is_integer(value) do
value_or_error =
if value > 1 do
value
else
"not well"
end
# ... more code ...
if value > 1 do
value_or_error / 100
else
String.upcase(value_or_error)
end
end
value_or_error는 정수이거나 바이너리입니다. / 연산자는 숫자만, String.upcase는 바이너리만 받습니다. 엄격한 정적 타입 시스템이라면 위반 두 개를 보고할 겁니다. 그런데 이 프로그램은 런타임에 아무 예외도 내지 않습니다. 타입 시스템이 프로그램의 의도를 정밀하게 포착하지 못해 생기는 거짓 양성(false positive)입니다.
Elixir는 이 변수에 dynamic(integer() or binary()) 타입을 붙입니다. 그리고 dynamic() 타입 값을 함수에 넘길 때는 공급된 타입과 허용된 타입이 서로소(disjoint)일 때만 위반을 냅니다. /가 숫자만 받더라도 dynamic(integer() or binary())는 정수일 수 있으니 서로소가 아니고, 따라서 위반이 아닙니다. 반면 이렇게 바꾸면,
value_or_error =
if value > 1 do
value
else
"not well"
end
Map.fetch!(value_or_error, :some_key)
Map.fetch!는 맵을 기대하는데 이 값은 런타임에 정수 아니면 바이너리뿐입니다. 서로소이므로 위반입니다. 이것이 Elixir가 "검증된 버그만" 보고한다고 말하는 근거입니다.
그런데 검증된 버그만 보고하면서 버그를 별로 못 찾으면 쓸모가 없겠죠. 그래서 좁히기가 붙습니다.
def add_a_and_b(data) do
data.a + data.b
end
data는 dynamic()으로 시작하지만 data.a, data.b가 덧셈에 쓰이는 걸 보고 %{..., a: number(), b: number()}로 좁혀집니다(앞의 ...은 다른 키가 있어도 된다는 뜻). 그래서 .b를 빠뜨리고 data.a + data라고 쓰면 data가 먼저 맵으로 좁혀졌다가 숫자로 쓰이려 하니 위반이 납니다.
한 줄로 말하면 — Elixir의 dynamic()은 범위처럼 동작하고, 코드를 따라가며 좁혀지며, 타입 체크가 그 범위를 벗어날 때 위반을 냅니다. 다른 점진 타입 시스템이 dynamic 타입으로 타입 정보를 버리는 것과 정반대 방향입니다.
1.20이 실제로 새로 하는 일
CHANGELOG 기준으로 1.20에 새로 들어온 것들을 봅시다.
가드 추론. 이번 릴리스의 마지막 퍼즐 조각이었습니다.
def example(x, y) when is_list(x) and is_integer(y)
# x는 리스트, y는 정수
def example({:ok, x} = y) when is_binary(x) or is_integer(x)
# x는 바이너리 또는 정수, y는 :ok로 시작하는 2원소 튜플
def example(x) when is_map_key(x, :foo)
# x는 :foo 키를 가진 맵 -> %{..., foo: dynamic()}
def example(x) when not is_map_key(x, :foo)
# x는 :foo 키가 없는 맵 -> %{..., foo: not_set()}
# 따라서 함수 본문의 x.foo는 타이핑 위반
def example(x) when tuple_size(x) < 3
# 튜플 원소가 최대 2개 -> elem(x, 3)은 위반
부정(not is_map_key)이 not_set()이라는 타입으로 표현되는 게 집합론적 타입 시스템다운 지점입니다. 맵·리스트의 크기 검사는 "비어 있는지" 검사로 변환됩니다.
함수 본문 전체 추론. 1.18까지는 패턴에서만 추론했습니다. 이제 본문을 봅니다.
def sum_to_string(a, b) do
Integer.to_string(a + b)
end
+는 정수와 부동소수 둘 다 되지만, 결과가 정수를 요구하는 함수로 들어가므로 a와 b가 둘 다 정수라고 추론합니다. 역방향으로 정보가 흐르는 셈입니다.
절 사이 추론과 occurrence typing. case, cond, with에 occurrence typing이 붙었고, 앞선 절이 뒷 절을 좁힙니다.
case System.get_env("SOME_VAR") do
nil -> :not_found
value -> {:ok, String.upcase(value)}
end
System.get_env/1은 nil 아니면 바이너리를 반환하는데, 첫 절이 nil을 잡았으니 value는 바이너리일 수밖에 없고, 그래서 String.upcase가 위반 없이 통과합니다. 덤으로 중복 절(redundant clauses) 경고가 생겼습니다.
의존성을 가로지르는 추론. CHANGELOG의 "Perform type inference across applications" 항목입니다. 1.20-rc 이전에는 표준 라이브러리 함수 호출에서만 타입을 추론했고, 당신의 의존성 모듈을 호출하면 타입 체크만 하고 추론은 하지 않았습니다. 이제 의존성에서 추론한 타입 정보가 당신 앱의 추론에 흘러들어옵니다. 컴파일러를 통과하는 타입 정보량이 크게 늘어나는 변화라, Elixir 팀은 2026년 1월 로드맵 글에서 이 단계에만 릴리스 후보 하나를 통째로 배정했다고 밝혔습니다.
실제로 1.20은 릴리스 후보를 일곱 개(rc.0부터 rc.6까지) 거쳤습니다. rc.0이 2026년 1월 9일, rc.6이 5월 21일, 최종 릴리스가 6월 3일입니다. 1월 로드맵 글이 예고한 최종 릴리스 시점은 "5월"이었으니 한 달쯤 밀린 셈인데, 타입 시스템 전면 도입치고는 준수한 편입니다.
"13개 중 12개 통과"를 원본에 대조해 봤다
발표문에서 가장 눈에 띄는 수치는 이것입니다.
our implementation performs well in the "If T: Benchmark for Type Narrowing" benchmark. Elixir passes 12 of the 13 categories
If-T는 실존하는 제3자 벤치마크입니다. Hanwen Guo와 Ben Greenman이 만들었고(arXiv:2508.03830), 유타대 PLT 그룹의 utahplt/ifT-benchmark 저장소에 있습니다. 타입 좁히기(occurrence typing)를 재는 벤치마크이고, 주제마다 "타입 체크를 통과해야 하는 프로그램"과 "통과하면 안 되는 프로그램"을 짝으로 둡니다. 카테고리는 정확히 13개입니다 — positive, negative, connectives, nesting_body, struct_fields, tuple_elements, tuple_length, alias, nesting_condition, merge_with_union, predicate_2way, predicate_1way, predicate_checked.
그런데 발표문이 이 문장에 걸어 둔 링크는 저장소의 Benchmark Results 표입니다. 그 표를 열어 보면 — Elixir 열이 없습니다.
오늘(2026-07-16) 기준으로 확인한 내용은 이렇습니다. 상류 결과표가 다루는 타입 체커는 Typed Racket, TypeScript, Flow, mypy, Pyright, Sorbet, Luau, MLsem, Typed Clojure, ty, Pyrefly의 11개입니다. 저장소 트리(106개 항목)의 언어 디렉터리도 이 11개뿐이고 Elixir 디렉터리는 없습니다. README 전문에서 대소문자 무시하고 "elixir"를 찾으면 0건이며, Elixir 추가를 제안한 PR도 열린 것·닫힌 것 통틀어 없습니다.
그러니 정확히 말하면 이렇습니다 — "13개 중 12개 통과"는 Elixir 팀의 자체 측정치이고, 발표문이 링크한 결과표는 그 수치를 뒷받침하지 않습니다. 틀렸다는 뜻이 아닙니다. 상류에 재현 가능한 구현물이 올라가 있지 않아 제3자가 확인할 수 없다는 뜻입니다. 어느 카테고리 하나를 통과하지 못했는지도 공개된 바 없습니다(검색 결과 요약에는 특정 카테고리를 지목하는 문장이 떠다니지만, 어떤 1차 소스에서도 확인되지 않아 여기서는 인용하지 않습니다).
맥락을 위해 상류 표의 실제 숫자를 옮겨 두면, 자체 측정치의 위치를 가늠하는 데 도움이 됩니다.
상류 If-T 결과표(utahplt/ifT-benchmark, v1.1) — 13개 카테고리 기준
Typed Clojure : 13/13 (전 항목 통과)
Typed Racket : 12/13 (tuple_length 실패)
MLsem : 12/13 (connectives 실패)
Flow : 11/13
Pyright : 11/13
TypeScript : 10/13 (nesting_condition, predicate_1way, predicate_checked 실패)
mypy : 9/13
Elixir : 표에 없음 (발표문 자체 측정 12/13)
12/13이 사실이라면 Typed Racket과 동급이고 TypeScript보다 앞선다는 의미가 됩니다. 어노테이션이 전혀 없는 코드에서 그 정도 좁히기 정밀도가 나온다면 실제로 인상적인 결과입니다. 다만 지금 상태로는 저자 자체 측정이고, 조건과 실패 항목이 공개되지 않았으며, 링크된 표로는 대조할 수 없다는 꼬리표를 붙여야 정확합니다.
컴파일 시간 — "가장 빠르다"는 주장의 실제 크기
발표문의 또 하나의 주장은 이겁니다 — "우리 합성 벤치마크에서 이제 Elixir의 빌드 도구가 BEAM 언어 중 가장 빠르다."
근거는 José Valim 본인의 저장소 josevalim/langcompilebench입니다. 저자 자체 측정이고, 저장소 스스로 "합성 벤치마크(synthetic benchmarks)"라고 밝힙니다. 측정 대상은 100개의 독립 모듈, 각각 hello world 함수 100개를 컴파일하고, 애플리케이션을 부팅하고, 최소 테스트 스위트를 돌리는 시간입니다. 조건은 MacStudio M1, 5회 평균입니다.
Erlang/OTP 28 기준
Elixir v1.19 ~0.73s
Elixir v1.20 ~0.63s
Elixir v1.20 (interpreted defmodule) ~0.58s
Erlang (rebar3) ~0.72s
Gleam v1.14 ~0.71s
Erlang/OTP 29 기준
Elixir v1.19 ~0.65s
Elixir v1.20-rc.2 ~0.55s
Elixir v1.20-rc.2 (interpreted defmodule) ~0.50s
Erlang (rebar3) ~0.64s
Gleam v1.14 ~0.67s
숫자를 그대로 읽으면 — 1.19에서 1.20으로 오면서 0.73초가 0.63초가 됐고, Erlang의 0.72초와 Gleam의 0.71초를 앞섭니다. 사실입니다. 동시에 전체 작업이 0.7초 남짓이고 격차가 0.1초 안팎이라는 것도 사실입니다. 그리고 이 워크로드는 hello world 함수 덩어리이지 실제 코드가 아닙니다 — 타입 체크를 무겁게 돌릴 만한 코드가 애초에 없습니다.
저자 본인이 저장소에 달아 둔 단서를 그대로 옮기는 게 가장 정직합니다.
물론 이것이 같은 규모의 Elixir 프로젝트가 Gleam이나 Erlang 프로젝트보다 빠르게 컴파일된다는 뜻은 아닙니다. 원시 컴파일러 성능 외에도 변수가 더 있기 때문입니다. (…) 이론적으로 말하면
rebar3가 이 중 가장 좋은 성능을 내야 합니다.
증분 컴파일로 넘어가면 순위가 아예 뒤집힙니다. 같은 저장소의 분석에 따르면 모듈 간 의존성을 컴파일타임/런타임으로 구분하는 능력에서 갈리는데, Erlang은 (드물게 쓰이는 parse transform을 빼면) 런타임 의존성만 있어서 여기 등장하는 언어 중 증분 성능이 가장 좋습니다. 파일 하나를 바꾸면 그 파일만 다시 컴파일됩니다. Gleam은 모든 의존성을 컴파일타임으로 취급해서 가장 나쁩니다. Elixir는 그 중간입니다.
langcompilebench의 정리표
언어 | 의존성 종류 | 순환 허용 | 변경당 예상 재컴파일
Erlang | 런타임만 | 예 | 최저
Elixir | 런타임 + 컴파일타임 | 예 | 중간
Gleam | 컴파일타임만 | 아니오 | 최고
즉 "가장 빠르다"는 주장은 클린 빌드 합성 벤치마크에 한정된 이야기이고, 개발 중에 실제로 체감하는 증분 빌드에서는 Erlang이 앞선다는 게 같은 저장소의 결론입니다. 발표문만 읽으면 놓치는 맥락입니다.
공짜가 아닌 부분
문서에는 적혀 있는데 발표문에는 없는 트레이드오프들입니다.
dynamic은 항상 루트에 있다. 공식 문서의 설명대로, {:ok, dynamic()} 같은 튜플을 쓰면 Elixir는 이를 dynamic({:ok, term()})으로 다시 씁니다. 문서가 명시한 단점은 이것입니다 — 튜플·맵·리스트의 일부만 gradual하게 만들 수 없고 전체가 gradual해집니다. 대신 dynamic이 항상 루트에 명시적으로 드러나서, 정적으로 타이핑된 프로그램에 dynamic이 몰래 스며드는 걸 막아 준다는 게 장점으로 제시됩니다. 의도된 설계 결정이지만 정밀도를 내주는 거래인 건 분명합니다.
구조체 업데이트는 설계상 위반을 낸다. 문서가 직접 든 예입니다.
user = find_user_by_id(42)
%User{user | name: "John Doe"}
find_user_by_id가 런타임에 항상 User 구조체를 반환하더라도, 타입 시스템이 그걸 정적으로 증명하지 못하면 타이핑 위반을 냅니다. 문서의 표현으로는 "구조체 업데이트가 설계상 동작하는 방식"입니다. 해결책은 변수를 정의할 때 매칭해 주는 것입니다.
%User{} = user = find_user_by_id(42)
%User{user | name: "John Doe"}
"거짓 양성률이 극히 낮다"는 발표문의 주장은 수치 없이 제시된 벤더 자체 평가이고, 이런 식으로 코드를 손봐야 하는 자리가 남아 있다는 점은 같이 봐야 합니다.
interpreted defmodule은 대가가 있다. 위 벤치마크에서 가장 빠른 숫자를 낸 module_definition: :interpreted 옵션은 발표문에서 장점만 언급되지만, CHANGELOG에는 단점이 적혀 있습니다.
- 컴파일 중 발생하는 에러의 스택트레이스가 덜 정밀해질 수 있다
defmodule안의 익명 함수가 인자를 최대 20개까지만 받을 수 있다(def등으로 정의한 함수 자체는 여전히 255개까지 가능)
기본값은 :compiled이고, 켜려면 mix.exs에 elixirc_options: [module_definition: :interpreted]를 넣습니다. .beam 파일 자체는 달라지지 않습니다 — defmodule 내부를 실행하는 방식만 바뀝니다.
업그레이드 전에 알아야 할 것
OTP 요구사항. 1.20은 Erlang/OTP 27 이상을 요구하고 OTP 29와 호환됩니다. OTP 26 이하에 묶여 있다면 1.20은 아예 선택지가 아닙니다.
보안 패치가 1.20에만 있다. 1.20.1(2026년 6월 9일)에 CVE-2026-49762(GHSA-w2h8-8x3g-278p, 심각도 medium, Erlang Ecosystem Foundation 발행)가 수정됐습니다. 내용은 Version 모듈의 무제한 정수 파싱입니다. 버전 문자열의 숫자 구성 요소를 길이 제한 없이 정수로 변환하다 보니, 전부 숫자인 큰 구성 요소 하나로 초선형·비양보(non-yielding) 변환이 일어나 BEAM 스케줄러 하나를 붙잡아 두고, 더 크면 SystemLimitError로 프로세스가 죽습니다. OSV 기록에 따르면 1메가바이트 정도의 문자열 하나면 충분하고 인증도 필요 없습니다. 도달 경로는 Version.parse/1, Version.parse!/1, Version.match?/3, Version.compare/2, Version.parse_requirement/1이고, HTTP 파라미터나 패키지 메타데이터처럼 신뢰할 수 없는 입력에 이 함수들을 호출하는 앱이 대상입니다. 수정은 정수 구성 요소를 10진 14바이트로 제한하는 것입니다.
여기서 중요한 대목 — 영향 범위는 1.5.0부터 1.20.1 미만 전체입니다. 그런데 확인해 보면 2026년 6월 9일 이후 나온 릴리스는 1.20.1과 1.20.2뿐이고, v1.19.6이나 v1.18.5 태그는 존재하지 않습니다. 즉 이 취약점의 패치를 받으려면 1.20.1 이상으로 올라가는 것 외에 방법이 없습니다. 1.19 이하에 머물 계획이라면, Elixir 팀 권고대로 Version 모듈에 넘기는 데이터 크기를 직접 제한해야 합니다.
하드 디프리케이션. 1.20에서 실제로 경고를 내기 시작하는 것들입니다.
File.stream!(path, modes, lines_or_bytes)→File.stream!(path, lines_or_bytes, modes)로 인자 순서 변경- 비트 패턴 안에서 크기를 매칭할 때 핀 연산자 필요:
<<x::size(^existing_var)>> Kernel.ParallelCompiler.async/1→Kernel.ParallelCompiler.pmap/2Logger.*_backend함수들 → 핸들러 방식으로 (계속 백엔드를 쓰려면:logger_backends패키지)Logger.enable/1,Logger.disable/1→Logger.put_process_level/2,Logger.delete_process_level/1mix.exs의xref: [exclude: ...]→elixirc_options: [no_warn_undefined: ...]
잠재적 파괴 변경 두 가지. 문자열·주석과 ? 뒤의 raw CR 줄바꿈이 보안상 금지됐고, require SomeModule이 더 이상 컴파일타임에 해당 모듈로 확장되지 않습니다(런타임에 모듈을 반환하는 건 동일). CHANGELOG가 굳이 후자를 적어 둔 이유는 require(SomeMod).some_macro() 같은 코드가 깨질 수 있어서입니다.
타입 시그니처는 왜 아직 멀었나
가장 궁금한 질문 — 언제 @spec 대신 컴파일러가 강제하는 타입 시그니처를 쓸 수 있나?
발표문은 조건을 네 개 걸었습니다. 타입 시그니처는 다음이 충족될 때만 도입합니다.
- 1.20의 타입 시스템 성능에 만족할 때
- 재귀 타입(recursive types)을 효율적으로 구현할 수 있을 때
- 파라메트릭 타입(parametric types)을 효율적으로 구현할 수 있을 때
- 맵의 키-값 쌍 순회를 enumerable로 효율적으로 구현할 수 있을 때 — 여기는 아직 해법을 연구 중이라고 명시합니다
즉 남은 건 엔지니어링만이 아니라 미해결 연구 문제입니다. 1월 로드맵 글은 이 문제들을 v1.21(2026년 11월)과 v1.22(2027년 5월)에서 탐색하겠다고 밝히면서, 타입 시스템이 실용적이지 않은 것으로 판명될 수 있는 위험 두 가지를 스스로 적어 뒀습니다 — 에르고노믹스(지금까지의 개선은 전부 언어 변경 없이 무대 뒤에서 일어났고, 개발자 경험에 미칠 영향은 아직 평가되지 않았다)와 성능(현재 구현은 재귀·파라메트릭 타입을 지원하지 않으며 이들이 성능을 직접 타격할 수 있다).
남은 마일스톤 순서는 이렇습니다. 두 번째는 타입 있는 구조체(typed structs) — 구조체 필드의 타입을 네이티브로 정의해 코드베이스 전반에 전파시키는 것. 세 번째가 집합론적 타입 시그니처입니다.
그리고 여기 조용히 큰 뉴스가 하나 있습니다. 공식 문서가 명시한 내용입니다 — 기존 Erlang Typespec은 집합론적 타입에 쓰기엔 충분히 정밀하지 않아서, 이 단계가 끝나면 언어에서 단계적으로 제거되고(phased out) 후처리는 별도 라이브러리로 옮겨집니다. @spec을 열심히 써 온 코드베이스라면 언젠가 마이그레이션이 온다는 뜻입니다. 시점은 정해지지 않았습니다.
그래서 지금 무엇을 해야 하나
올려도 되는 경우. OTP 27+에 있고, 위의 디프리케이션 목록이 감당 가능하다면 올릴 이유는 충분합니다. 타입 체크는 옵트인이 아니라 그냥 켜져 있고, 코드를 고치지 않아도 됩니다. 얻는 게 공짜에 가깝습니다. 게다가 Version CVE 패치가 1.20.1 이상에만 있으므로, Version 모듈에 외부 입력을 넘기는 앱이라면 업그레이드가 사실상 보안 사안입니다.
서두를 필요 없는 경우. 타입 시그니처를 쓰고 싶어서 기다린 거라면 1.20은 그게 아닙니다. 최소 v1.21(2026년 11월), 현실적으로는 그 이후이고, 연구 문제가 안 풀리면 더 밀릴 수 있습니다. 구조체 타입도 아직입니다. OTP 26 이하에 묶여 있다면 애초에 못 올립니다.
기대치 조정. 1.20이 찾아 주는 건 "실행되면 반드시 터지는" 위반과 죽은 코드입니다. 타입이 맞는지 증명해 주는 게 아닙니다. 어노테이션이 없으니 컴파일러가 아는 건 코드에서 추론할 수 있는 것뿐이고, 애매한 곳은 dynamic()으로 남아 서로소일 때만 걸립니다. Dialyzer를 써 봤다면 익숙한 위치인데, 차이는 Elixir 1.20이 컴파일러에 내장돼 있고 별도 실행이나 PLT 빌드가 없다는 점입니다.
업그레이드 후 경고가 쏟아진다면 대부분 진짜 버그일 가능성이 높습니다. 발표문의 마지막 문장이 그 뜻입니다 — "Elixir v1.20을 써 보고, 공짜로 찾아 주는 버그들을 다 고치는 걸 잊지 마세요."
마치며
정리하면 이렇습니다. Elixir 1.20은 2022년에 시작한 타입 시스템 작업의 첫 마일스톤을 실제로 끝냈고, 이건 진짜입니다 — 어노테이션 없이 모든 프로그램을 점진적으로 타입 체크하고, dynamic()이 타입 정보를 버리는 대신 범위로 좁혀지며, 가드·함수 본문·절·의존성을 가로질러 추론이 흐릅니다. 비용은 사실상 업그레이드뿐입니다.
동시에, 발표문의 두 숫자는 원본에 대조하면 크기가 달라집니다. If-T "13개 중 12개"는 링크된 상류 표에 Elixir 열조차 없는 자체 측정치이고, 컴파일 속도 1위는 저자 본인의 hello-world 합성 벤치마크에서 0.7초 작업의 0.1초 차이이며 증분 빌드에서는 같은 저장소가 Erlang을 앞세웁니다. 두 주장 모두 거짓이 아니지만, 발표문만 읽고 얻는 인상보다는 작습니다.
그리고 정말 갖고 싶은 것 — 타입 시그니처와 타입 있는 구조체 — 은 아직 1년 넘게 남았고, Elixir 팀 스스로 "실용적이지 않은 것으로 판명될 수 있다"고 적어 둔 미해결 연구 문제 뒤에 있습니다. 그 정직함이 이 프로젝트가 4년째 신뢰를 유지하는 이유이기도 합니다.
참고 자료
- Elixir v1.20 released: now a gradually typed language (2026-06-03, José Valim)
- Elixir v1.20 CHANGELOG (원본)
- Type inference of all constructs and the next 15 months (2026-01-09) — 로드맵과 RC 일정
- Gradual set-theoretic types — 공식 문서(v1.20.2)
- The Design Principles of the Elixir Type System — Castagna, Duboc, Valim (Programming journal, 2024, Vol. 8, Issue 2, Article 4)
- If-T: A Benchmark for Type Narrowing — Guo, Greenman (arXiv:2508.03830)
- utahplt/ifT-benchmark — 상류 벤치마크와 결과표
- josevalim/langcompilebench — BEAM 언어 컴파일 시간 합성 벤치마크
- CVE-2026-49762 / GHSA-w2h8-8x3g-278p — Version 모듈 무제한 정수 파싱
- 모던 Elixir/Phoenix 2026 — 생태계 지형(관련 글)
- 모던 Erlang/BEAM 2026 — OTP와 런타임(관련 글)
Elixir 1.20's Gradual Type System — The Reality of Finding 'Verified Bugs' Without Annotations
- Introduction — The First Milestone, Closed After Four Years
- Where dynamic() Differs From any()
- What 1.20 Actually Adds
- Checking "12 of 13" Against the Primary Source
- Compile Time — How Big Is the "Fastest" Claim, Really
- What Isn't Free
- What to Know Before Upgrading
- Why Type Signatures Are Still Far Off
- So What Should You Do Now
- Closing
- References
Introduction — The First Milestone, Closed After Four Years
The Elixir team announced it would bring set-theoretic types into the compiler back in 2022. In 2023, Giuseppe Castagna, Guillaume Duboc, and José Valim laid out the design in The Design Principles of the Elixir Type System, declaring that the project was moving "from research to development." Then on June 3, 2026, Elixir v1.20 shipped, closing out that first milestone.
The milestone can be summed up like this — perform type inference and gradual type checking on every Elixir program without introducing a single type annotation. That means the compiler finds dead code and "verified bugs" without you changing a line of code. Per the announcement's own definition, a verified bug is a typing violation guaranteed to fail at runtime if the code executes.
This post strips away the hype and looks at three things: (1) what 1.20 actually adds, (2) what survives once the announcement's numbers are checked against primary sources, and (3) tradeoffs documented in the docs but absent from the announcement. The broader ecosystem landscape around Elixir 1.18 and OTP 27/28 is already covered in Modern Elixir & Phoenix 2026 and Modern Erlang and BEAM 2026, so this post digs into the type system alone.
Where dynamic() Differs From any()
Per the official docs, the Elixir type system has three goals — soundness: the inferred and assigned types match what the program actually does at runtime. Graduality: there is a dynamic() type, and without it the system behaves like a static type system. Developer-friendliness: types are described with the basic set operations — union, intersection, negation (hence "set-theoretic").
The key piece here is dynamic(). In many gradual type systems, any() means "anything goes at all" from the type system's point of view, so it never reports violations. Elixir's dynamic() is different. In the announcement's own terms, it has two properties — compatibility and narrowing.
Start with compatibility. Here is the announcement's own example, carried over unchanged.
def percentage_or_error(value) when is_integer(value) do
value_or_error =
if value > 1 do
value
else
"not well"
end
# ... more code ...
if value > 1 do
value_or_error / 100
else
String.upcase(value_or_error)
end
end
value_or_error is either an integer or a binary. / only accepts numbers, and String.upcase only accepts binaries. A strict static type system would report two violations here. Yet this program never raises an exception at runtime. It's a false positive that arises because the type system fails to precisely capture the program's intent.
Elixir attaches the type dynamic(integer() or binary()) to this variable. And when a dynamic()-typed value is passed to a function, Elixir only raises a violation when the supplied type and the accepted type are disjoint. Even though / only accepts numbers, dynamic(integer() or binary()) could be an integer, so it's not disjoint — no violation. Now change the code to this:
value_or_error =
if value > 1 do
value
else
"not well"
end
Map.fetch!(value_or_error, :some_key)
Map.fetch! expects a map, but at runtime this value can only ever be an integer or a binary. That's disjoint, so it's a violation. This is the basis for Elixir's claim that it reports "only verified bugs."
But reporting only verified bugs while catching hardly any bugs wouldn't be useful. That's where narrowing comes in.
def add_a_and_b(data) do
data.a + data.b
end
data starts as dynamic(), but seeing data.a and data.b used in an addition narrows it to %{..., a: number(), b: number()} (the leading ... means other keys are allowed). So if you drop .b and write data.a + data, data was already narrowed to a map, and then it's used as a number — that's a violation.
In one line — Elixir's dynamic() behaves like a range, narrows as it's traced through the code, and raises a violation only when a type check falls outside that range. That's the opposite direction of how other gradual type systems discard type information with their dynamic type.
What 1.20 Actually Adds
Based on the CHANGELOG, here's what's new in 1.20.
Guard inference. This was the last missing piece for this release.
def example(x, y) when is_list(x) and is_integer(y)
# x is a list, y is an integer
def example({:ok, x} = y) when is_binary(x) or is_integer(x)
# x is a binary or an integer, y is a 2-element tuple starting with :ok
def example(x) when is_map_key(x, :foo)
# x is a map with the key :foo -> %{..., foo: dynamic()}
def example(x) when not is_map_key(x, :foo)
# x is a map without the key :foo -> %{..., foo: not_set()}
# so x.foo in the function body is a typing violation
def example(x) when tuple_size(x) < 3
# the tuple has at most 2 elements -> elem(x, 3) is a violation
The fact that negation (not is_map_key) is represented with a type called not_set() is exactly what you'd expect from a set-theoretic type system. Size checks on maps and lists get converted into "is it empty" checks.
Whole-function-body inference. Through 1.18, inference only happened on patterns. Now it looks at the body too.
def sum_to_string(a, b) do
Integer.to_string(a + b)
end
+ accepts both integers and floats, but because the result flows into a function that requires an integer, both a and b are inferred to be integers. Information now flows backward.
Cross-clause inference and occurrence typing. case, cond, and with now have occurrence typing, where earlier clauses narrow later ones.
case System.get_env("SOME_VAR") do
nil -> :not_found
value -> {:ok, String.upcase(value)}
end
System.get_env/1 returns either nil or a binary. Since the first clause already caught nil, value can only be a binary, so String.upcase passes without a violation. As a bonus, this also produces redundant clause warnings.
Cross-dependency inference. This is the CHANGELOG's "Perform type inference across applications" item. Before 1.20-rc, types were only inferred from calls to standard library functions — calling into your own dependency modules only got type-checked, not inferred. Now, type information inferred from your dependencies flows into the inference for your own app. This change dramatically increases the volume of type information flowing through the compiler, and the Elixir team said in its January 2026 roadmap post that it dedicated an entire release candidate to this step alone.
In fact, 1.20 went through seven release candidates (rc.0 through rc.6). rc.0 landed January 9, 2026; rc.6 landed May 21; the final release landed June 3. The January roadmap post had projected the final release for "May," so it slipped by about a month — a fair result for a full rollout of a type system.
Checking "12 of 13" Against the Primary Source
The most eye-catching figure in the announcement is this:
our implementation performs well in the "If T: Benchmark for Type Narrowing" benchmark. Elixir passes 12 of the 13 categories
If-T is a real third-party benchmark. It was built by Hanwen Guo and Ben Greenman (arXiv:2508.03830) and lives in the University of Utah PLT group's utahplt/ifT-benchmark repository. It measures type narrowing (occurrence typing), pairing a program that "should pass type checking" with one that "should not" for every topic. There are exactly 13 categories — positive, negative, connectives, nesting_body, struct_fields, tuple_elements, tuple_length, alias, nesting_condition, merge_with_union, predicate_2way, predicate_1way, predicate_checked.
But the link the announcement attaches to this sentence points to the repository's Benchmark Results table. Open that table, and there is no Elixir column.
As of today (2026-07-16), here's what I confirmed: the upstream results table covers 11 type checkers — Typed Racket, TypeScript, Flow, mypy, Pyright, Sorbet, Luau, MLsem, Typed Clojure, ty, and Pyrefly. The repository tree (106 entries) has language directories for only those same 11 — no Elixir directory. A case-insensitive search of the full README for "elixir" returns zero hits, and there is no PR, open or closed, proposing to add Elixir.
So, stated precisely — "12 of 13" is the Elixir team's own self-reported measurement, and the results table the announcement links to does not back that number. That doesn't mean it's wrong. It means there is no reproducible implementation posted upstream, so a third party can't verify it. Which single category failed has also never been disclosed (some search summaries float a specific category, but since no primary source confirms it, this post won't cite one).
For context, here are the actual upstream numbers, which help calibrate where the self-reported figure sits.
Upstream If-T results table (utahplt/ifT-benchmark, v1.1) — out of 13 categories
Typed Clojure : 13/13 (passes every category)
Typed Racket : 12/13 (fails tuple_length)
MLsem : 12/13 (fails connectives)
Flow : 11/13
Pyright : 11/13
TypeScript : 10/13 (fails nesting_condition, predicate_1way, predicate_checked)
mypy : 9/13
Elixir : not in the table (announcement's self-reported 12/13)
If 12/13 holds up, it puts Elixir on par with Typed Racket and ahead of TypeScript. Getting that level of narrowing precision out of code with zero annotations would genuinely be impressive. But as things stand, the accurate label is: self-reported by the authors, with conditions and the failing category undisclosed, and unverifiable against the linked table.
Compile Time — How Big Is the "Fastest" Claim, Really
The announcement makes one more claim: "in our synthetic benchmark, Elixir's build tooling is now the fastest among BEAM languages."
The basis is José Valim's own repository, josevalim/langcompilebench. It's a self-reported measurement, and the repository itself states these are "synthetic benchmarks." What's measured is the time to compile 100 independent modules, each with 100 hello-world functions, boot the application, and run a minimal test suite. Conditions: a MacStudio M1, averaged over 5 runs.
On Erlang/OTP 28
Elixir v1.19 ~0.73s
Elixir v1.20 ~0.63s
Elixir v1.20 (interpreted defmodule) ~0.58s
Erlang (rebar3) ~0.72s
Gleam v1.14 ~0.71s
On Erlang/OTP 29
Elixir v1.19 ~0.65s
Elixir v1.20-rc.2 ~0.55s
Elixir v1.20-rc.2 (interpreted defmodule) ~0.50s
Erlang (rebar3) ~0.64s
Gleam v1.14 ~0.67s
Read the numbers at face value: going from 1.19 to 1.20, 0.73s became 0.63s, ahead of Erlang's 0.72s and Gleam's 0.71s. That's true. It's equally true that the whole job takes roughly 0.7 seconds and the gap is around 0.1 seconds. And this workload is a pile of hello-world functions, not real code — there's nothing here substantial enough to put real weight on the type checker.
The most honest thing to do is carry over, unchanged, the caveat the author himself attached to the repository.
Of course, this doesn't mean that an Elixir project of the same size will compile faster than an equivalent Gleam or Erlang project — there are more variables than raw compiler performance. (…) In theory,
rebar3should be the best performer of the three.
Switch to incremental compilation, and the ranking flips entirely. Per the same repository's own analysis, what matters is whether a language can separate inter-module dependencies into compile-time versus runtime. Erlang — aside from the rarely-used parse transform — has only runtime dependencies, which makes it the best performer here on incremental builds. Change one file, and only that file gets recompiled. Gleam treats every dependency as compile-time, making it the worst. Elixir sits in between.
langcompilebench summary table
Language | Dependency kind | Cycles allowed | Expected recompilation per change
Erlang | Runtime only | Yes | Lowest
Elixir | Runtime + compile-time | Yes | Medium
Gleam | Compile-time only | No | Highest
So the "fastest" claim is scoped to clean-build synthetic benchmarks — and the same repository's own conclusion is that Erlang is ahead on the incremental builds you actually feel day to day during development. That's context you'd miss reading the announcement alone.
What Isn't Free
These are tradeoffs written into the docs but absent from the announcement.
dynamic always sits at the root. As the official docs explain, if you write a tuple like {:ok, dynamic()}, Elixir rewrites it as dynamic({:ok, term()}). The docs state the downside explicitly — you can't make part of a tuple, map, or list gradual; the whole thing becomes gradual. In exchange, dynamic is always explicitly visible at the root, which is presented as a benefit — it stops dynamic from silently creeping into a statically-typed program. It's a deliberate design decision, but it clearly trades away precision.
Struct updates raise violations by design. Here's the docs' own example.
user = find_user_by_id(42)
%User{user | name: "John Doe"}
Even if find_user_by_id always returns a User struct at runtime, if the type system can't prove that statically, it raises a typing violation. In the docs' own words, "this is how struct updates are designed to work." The fix is to match the variable when you define it.
%User{} = user = find_user_by_id(42)
%User{user | name: "John Doe"}
The announcement's claim of "an extremely low false-positive rate" is a vendor self-assessment with no number attached, and it should be read alongside the fact that spots like this still require you to reshape your code.
Interpreted defmodule has a cost. The module_definition: :interpreted option, which produced the fastest numbers in the benchmark above, only gets its upside mentioned in the announcement — the CHANGELOG lists the downsides.
- Stack traces for errors raised during compilation can become less precise
- Anonymous functions inside
defmodulecan take at most 20 arguments (functions defined withdefand friends still support up to 255)
The default is :compiled; to turn this on, add elixirc_options: [module_definition: :interpreted] to mix.exs. The .beam file itself doesn't change — only the way the inside of defmodule gets executed.
What to Know Before Upgrading
OTP requirement. 1.20 requires Erlang/OTP 27 or later and is compatible with OTP 29. If you're stuck on OTP 26 or earlier, 1.20 isn't an option at all.
The security patch only exists in 1.20. 1.20.1 (June 9, 2026) fixed CVE-2026-49762 (GHSA-w2h8-8x3g-278p, severity medium, published by the Erlang Ecosystem Foundation). The issue is unbounded integer parsing in the Version module. Numeric components of a version string were converted to integers with no length limit, so a single, all-numeric, sufficiently large component triggers a superlinear, non-yielding conversion that pins down a BEAM scheduler — and larger still, kills the process with a SystemLimitError. Per the OSV entry, a single string of about 1 megabyte is enough, and no authentication is required. The reachable paths are Version.parse/1, Version.parse!/1, Version.match?/3, Version.compare/2, and Version.parse_requirement/1 — any app that calls these on untrusted input, like HTTP parameters or package metadata, is affected. The fix caps the numeric component at 14 decimal bytes.
The important part here — the affected range is everything from 1.5.0 up to, but not including, 1.20.1. But checking the releases, everything shipped after June 9, 2026 is only 1.20.1 and 1.20.2 — there's no v1.19.6 or v1.18.5 tag. In other words, the only way to get the fix for this vulnerability is to move up to 1.20.1 or later. If you plan to stay on 1.19 or below, the Elixir team's own recommendation is to cap the size of data you pass to the Version module yourself.
Hard deprecations. These start actually emitting warnings in 1.20.
File.stream!(path, modes, lines_or_bytes)→ argument order changed toFile.stream!(path, lines_or_bytes, modes)- Matching a size inside a bit pattern now requires the pin operator:
<<x::size(^existing_var)>> Kernel.ParallelCompiler.async/1→Kernel.ParallelCompiler.pmap/2- The
Logger.*_backendfunctions → the handler-based approach (keep using backends via the:logger_backendspackage) Logger.enable/1,Logger.disable/1→Logger.put_process_level/2,Logger.delete_process_level/1xref: [exclude: ...]inmix.exs→elixirc_options: [no_warn_undefined: ...]
Two potentially breaking changes. Raw CR line breaks after ? inside strings and comments are now disallowed for security reasons, and require SomeModule no longer expands to that module at compile time (it still returns the module at runtime, unchanged). The CHANGELOG specifically calls out the latter because code like require(SomeMod).some_macro() can break.
Why Type Signatures Are Still Far Off
The question everyone actually wants answered — when can you use compiler-enforced type signatures instead of @spec?
The announcement sets four conditions. Type signatures ship only once all of these are met:
- The team is satisfied with 1.20's type-system performance
- Recursive types can be implemented efficiently
- Parametric types can be implemented efficiently
- Iterating over a map's key-value pairs as an enumerable can be implemented efficiently — the announcement explicitly states this one is still an open research problem
So what's left isn't just engineering — it's unsolved research. The January roadmap post said these problems would be explored in v1.21 (November 2026) and v1.22 (May 2027), and it laid out, in its own words, two risks that could make the type system turn out impractical — ergonomics (every improvement so far has happened behind the scenes with no language changes, and the impact on developer experience hasn't been evaluated yet) and performance (the current implementation doesn't support recursive or parametric types, and they could directly hurt performance).
Here's the order of the remaining milestones. Second is typed structs — natively defining types for struct fields and propagating them across the codebase. Third is set-theoretic type signatures.
And there's a quietly big piece of news here. Stated explicitly in the official docs — existing Erlang Typespecs aren't precise enough for set-theoretic types, so once this phase wraps up, they'll be phased out of the language, with post-processing moved into a separate library. If your codebase has invested heavily in @spec, a migration is coming eventually. No timeline has been set.
So What Should You Do Now
When it's fine to upgrade. If you're on OTP 27+ and the deprecation list above is manageable, there's plenty of reason to move up. Type checking isn't opt-in — it's just on, and you don't have to touch your code. What you get is close to free. And since the Version CVE fix only exists in 1.20.1+, if your app passes external input into the Version module, upgrading is effectively a security matter.
When there's no rush. If you've been waiting to use type signatures, 1.20 isn't that. It's at least v1.21 (November 2026), realistically later, and it could slip further if the research problems don't get solved. Typed structs aren't here yet either. And if you're stuck on OTP 26 or earlier, you can't upgrade at all.
Set your expectations correctly. What 1.20 finds is violations that are "guaranteed to blow up if executed" and dead code. It doesn't prove your types are correct. With no annotations, the compiler only knows what it can infer from the code, and ambiguous spots stay dynamic(), flagged only when disjoint. If you've used Dialyzer, this territory will feel familiar — the difference is that Elixir 1.20 is built into the compiler, with no separate run or PLT build.
If warnings pour in after you upgrade, most of them are probably real bugs. That's what the announcement's closing line means — "give Elixir v1.20 a try, and don't forget to fix all the bugs it finds you for free."
Closing
To sum up: Elixir 1.20 really did close out the first milestone of the type-system work that began in 2022, and it's genuine — every program gets gradually type-checked with no annotations, dynamic() narrows through the code instead of discarding type information, and inference now flows across guards, function bodies, clauses, and dependencies. The cost is basically just the upgrade itself.
At the same time, two of the announcement's numbers shrink once checked against the source. If-T's "12 of 13" is a self-reported figure with no Elixir column at all in the linked upstream table, and the "fastest compiler" claim is a 0.1-second gap on a 0.7-second hello-world synthetic benchmark from the author himself — and on incremental builds, that same repository puts Erlang ahead. Neither claim is false, but both are smaller than the impression you'd get from the announcement alone.
And the thing people actually want — type signatures and typed structs — is still more than a year out, sitting behind unsolved research problems that the Elixir team itself admits "could turn out to be impractical." That honesty is part of why this project has kept people's trust for four years running.
References
- Elixir v1.20 released: now a gradually typed language (2026-06-03, José Valim)
- Elixir v1.20 CHANGELOG (primary source)
- Type inference of all constructs and the next 15 months (2026-01-09) — roadmap and RC schedule
- Gradual set-theoretic types — official docs (v1.20.2)
- The Design Principles of the Elixir Type System — Castagna, Duboc, Valim (Programming journal, 2024, Vol. 8, Issue 2, Article 4)
- If-T: A Benchmark for Type Narrowing — Guo, Greenman (arXiv:2508.03830)
- utahplt/ifT-benchmark — upstream benchmark and results table
- josevalim/langcompilebench — synthetic compile-time benchmark for BEAM languages
- CVE-2026-49762 / GHSA-w2h8-8x3g-278p — unbounded integer parsing in the Version module
- Modern Elixir & Phoenix 2026 — ecosystem landscape (related post)
- Modern Erlang and BEAM 2026 — OTP and runtime (related post)