{"slug": "elixir-1-20-has-a-type-system-now-comparing-it-with-rust-and-typescript", "title": "Elixir 1.20 has a type system now: comparing it with Rust and TypeScript", "summary": "Elixir 1.20 shipped with a type system on June 3, 2026, aiming for soundness but admitting it is best-effort. A developer compared it with Rust and TypeScript, highlighting that Elixir uses set-theoretic types with negation, infers types without annotations, and defers judgment on unknowns via a dynamic() type. The analysis shows Elixir's approach differs from TypeScript's unsoundness and Rust's strong soundness.", "body_md": "*This English version is an AI translation of my original article on Qiita (in Japanese).*\n\nOn June 3, 2026, Elixir 1.20 shipped with a type system. But it aims at something a little different from the type systems we are used to, the ones where you write types to buy safety.\n\nThis article started from my own questions, which I worked through with an AI agent. We went to primary sources (the official blog, the docs, the paper), and in the end I installed Elixir 1.20 and checked how the type checker actually behaves. I may still have gotten things wrong, so if you notice anything, I would be glad to hear it in the comments.\n\nFirst, the word **sound**. In type systems, a type system is *sound* when, if it says a value has some type, you will not get a type error of that kind at runtime. The opposite, *unsound*, means a program can type-check and still hit a type error when it runs.\n\nMaking a dynamic language fully sound is hard. TypeScript openly states that it is **not** sound (it lists \"apply a sound or 'provably correct' type system\" as a non-goal). Elixir, on the other hand, lists soundness as one of the goals of its type system. But it too admits it is best-effort (\"we do not guarantee we will find every mistake\"), so it does not claim to be fully sound either. Both projects talk about their type systems in terms of sound and unsound, so I will use the same words here.\n\nThere is room for different stances on how far, and how, to pursue soundness. The key question is **how each one treats things it cannot be sure about**.\n\n| Sound? | Unknowns are... | |\n|---|---|---|\nRust |\nsound (strong) | rejected |\nElixir |\nsound (best-effort) | passed |\nTypeScript |\nunsound | passed |\n\n**Rust** rejects what it cannot be sure about. Because it refuses to let those through, it is strongly sound about types and ownership. (The `unsafe`\n\nparts, and runtime errors like out-of-bounds array access, are outside that guarantee.)\n\n**TypeScript** and **Elixir** both let unknowns through in the end. The difference is what happens before that. TypeScript is designed around writing types as much as you can, and where type information is missing (an `any`\n\n, or something made unknown by an `as`\n\ncast) it treats that as out of scope and lets it through without checking. Elixir, before letting a value through, traces the flow of the code (branches and guards) to work out the possible types, and rejects only what it can prove will always fail. Same \"letting it through,\" but the question is **whether it looks first**. That difference is what separates unsound (TypeScript) from sound (Elixir).\n\nThree things stand out.\n\n**1. dynamic() defers the judgment.** Where type information is missing, TypeScript passes it without checking (by design: it assumes you write types, and treats what you did not as out of scope). Elixir instead assigns the unknown spot a type that literally means \"unknown\" (\n\n`dynamic()`\n\n) and holds off. As that value flows through the code it gets narrowed, and Elixir reports only the spots where every possible value is certain to fail. Where only some paths might fail, it stays quiet. It is built to say only what it is sure of, and to avoid false positives. If something stays gray to the end it is missed (the project itself admits this best-effort limit), but whatever it does report is certain.**2. You can express negation in a type.** Elixir's types are built from set operations: union, intersection, and negation (set-theoretic types). So \"not nil\" is natural to write. TypeScript can also say \"exclude this\" via `Exclude`\n\nand narrowing, but Elixir has negation as a type operation itself. That is the difference.\n\n**3. It starts from writing no types.** For now, you do not write type annotations; the compiler infers types from the code and checks them. The point is to make it work on huge existing codebases without adding anything. Later, the plan is to let you write type signatures at the boundaries where you want stronger guarantees (per the official roadmap), and 1.20 is the \"inference first\" milestone on that path. Note that the long-standing `@spec`\n\nis for documentation and Dialyzer; the new type system does not use it. The new type signatures are being designed as a separate thing.\n\nOnce you can write types explicitly, guarantees should reach places inference cannot follow today (struct fields, boundaries across modules). But the team itself is cautious (\"we still need to assess the impact on how it feels to write code\" and \"it may turn out the type system is not practical\"), and it is still being evaluated. The future where you write types should be better, but the people building it are still feeling their way.\n\nI installed Elixir 1.20 and tried, without writing a single type, to see what it catches.\n\nFor example, when every branch of a `case`\n\nreturns an atom, the result is inferred to be an atom, and things stop when you hand that to a function that works on strings. Elixir calls strings \"binaries,\" so the warning says `binary()`\n\n.\n\n``` php\ndef categorize(n) do\n  label =\n    case n do\n      x when x < 0 -> :negative\n      0 -> :zero\n      _ -> :positive\n    end\n  String.length(label)\nend\nwarning: incompatible types given to String.length/1:\n    given types:     dynamic(:negative or :positive or :zero)\n    but expected one of:     binary()\n```\n\nIt also follows types that change midway through a pipeline.\n\n``` python\ndef pipeline(n) do\n  n\n  |> Integer.to_string()   # turn the integer into a string (Elixir calls strings \"binaries\")\n  |> Kernel.+(1)           # warns: trying to add an integer to a string (binary)\nend\nwarning: incompatible types given to Kernel.+/2:\n    given types:     binary(), integer()\n    but expected one of:     integer(), integer()   # and the other numeric combinations\n```\n\nNeither has any type annotations. It bundles the three `case`\n\nbranches into `:negative or :positive or :zero`\n\n, follows the type change through the pipe, and points at the single spot where every value is bound to fail.\n\nTo be honest, I tried to build an example where **TypeScript misses it but only Elixir catches it**, and I could not make one work. TypeScript catches type mismatches too, and unsound cases like out-of-bounds array access are missed by Elixir as well (best-effort). The difference is not the range of what can be caught; it is that **it catches this without my writing a single type for the example**.\n\nAfter running all this, a deeper question surfaced. If we have statically typed languages like Rust, then pushed to the limit, wouldn't those be enough? Why do dynamically typed languages need to exist at all? I wanted to find out, so I asked an AI agent. Here is the answer that came back.\n\nFirst, let me split the question a little. Rust's heaviness comes less from static types themselves and more from ownership and lifetimes (the machinery for memory safety without a GC). A statically typed language with a GC gets type inference too, so you barely write annotations. So the real divide is not \"Rust or dynamic\" but \"static or dynamic,\" with GC'd static languages sitting in between.\n\nTo be honest, the \"static is enough\" side has gotten stronger. Inference has narrowed the old \"dynamic is faster to write\" gap, and the trend of adding types to a dynamic language after the fact (TypeScript, Python, and now Elixir 1.20) is itself evidence that once code matters, people end up wanting static guarantees.\n\nEven so, there are two reasons dynamic types will not disappear in principle.\n\n1. A sound static type system must reject some programs that actually run correctly.Whether a program \"never hits a type error at runtime\" is undecidable in general ([Rice's theorem]), so if you prioritize never letting an unsafe program through, the price is that some \"actually runs, but cannot be proven, so it is rejected\" code always remains. This is not a flaw you fix by improving the type system; it is a hard ceiling. The proof is that every static language has an escape hatch (Rust's`unsafe`\n\n, the`Any`\n\nor dynamic casts in various languages). They admit there is a part static cannot cover, and quietly bring dynamic typing back in.\n\n2. Some work has types that are only determined at runtime.Messy data coming from outside, a REPL you poke at interactively, macros that generate code, hot code reloading that swaps modules while the system runs. All of these are dynamic by nature, and clash with the premise of \"check the whole thing up front.\"In short, static and dynamic just differ in\n\nwhere you pay for type correctness. Static pays up front, at compile time, for every possible execution. Dynamic pays later, at runtime, for the one execution that actually happened. Because there are situations where you cannot pay up front, or where paying up front is not worth it, dynamic typing is needed. And the real answer is not \"one or the other\" but is heading toward \"keep a dynamic base, and add static where it pays.\"\n\nReading that answer, I came to understand Elixir 1.20's reason for being, and its position, a little better.\n\nElixir is not trying to become Rust. It keeps a base you can run while still dynamic, statically stops only the certain mistakes, and plans to let you add types at the boundaries where you want guarantees later. It has chosen \"a dynamic base, with static added where it pays\" as a matter of language design.\n\nAnd this overlaps with the era of having AI write code. AI can write a lot, fast, but it does not get everything completely right. So the type system points out, at the compiler level, the mistakes that are certain to be found, and assists with the parts AI does not quite finish. Elixir 1.20's type system looks like one form of that.\n\n`@spec`\n\nis for documentation and Dialyzer)", "url": "https://wpnews.pro/news/elixir-1-20-has-a-type-system-now-comparing-it-with-rust-and-typescript", "canonical_source": "https://dev.to/geekmasahiro/elixir-120-has-a-type-system-now-comparing-it-with-rust-and-typescript-31p4", "published_at": "2026-06-20 12:12:16+00:00", "updated_at": "2026-06-20 12:36:43.872282+00:00", "lang": "en", "topics": ["large-language-models", "developer-tools"], "entities": ["Elixir", "TypeScript", "Rust", "Qiita"], "alternates": {"html": "https://wpnews.pro/news/elixir-1-20-has-a-type-system-now-comparing-it-with-rust-and-typescript", "markdown": "https://wpnews.pro/news/elixir-1-20-has-a-type-system-now-comparing-it-with-rust-and-typescript.md", "text": "https://wpnews.pro/news/elixir-1-20-has-a-type-system-now-comparing-it-with-rust-and-typescript.txt", "jsonld": "https://wpnews.pro/news/elixir-1-20-has-a-type-system-now-comparing-it-with-rust-and-typescript.jsonld"}}