Visitar URL original
Type printer prints wider types on `non_empty_list` forms · Issue #15837 · elixir-lang/elixir · GitHub
Skip to content

Type printer prints wider types on non_empty_list forms #15837

Closed

Description

@lukaszsamson

Existing issue

  • I have searched existing issues and could not find a duplicate.

Elixir and Erlang/OTP versions

Erlang/OTP 28 [erts-16.4.0.1] [source] [64-bit] [smp:12:12] [ds:12:12:10] [async-threads:1] [jit]

Interactive Elixir (1.21.0-dev)

Operating system

any

Current behavior

Module.Types.Descr printer emits non_empty_list/2 representation that when used directly produces a wider type.
The list representation stores the type of the terminator. The non_empty_list/2 constructor instead accepts a tail type and folds any list-valued part of that tail into the element type.

Repro:
When printed and reconstructed the reconstructed type admits proper lists when the original type only admits improper ones

import Module.Types.Descr

type =
  non_empty_list(
    none(),
    opt_negation(list(term()))
  )

to_quoted_string(type)
# => "non_empty_list(term(), not empty_list())"

reconstructed =
  non_empty_list(
    term(),
    opt_negation(empty_list())
  )

equal?(type, reconstructed)
# => false

subtype?(type, reconstructed)
# => true

subtype?(reconstructed, type)
# => false

proper_list = non_empty_list(integer())

disjoint?(type, proper_list)
# => true

subtype?(proper_list, reconstructed)
# => true

Another example:
reconstructed type admits [:not_an_integer] while original admits proper or improper lists of integers

original =
  non_empty_list(
    integer(),
    opt_negation(non_empty_list(term(), term()))
  )

to_quoted_string(original)
# => "non_empty_list(integer(), term())"

reconstructed = non_empty_list(integer(), term())

equal?(original, reconstructed)
# => false

subtype?(original, reconstructed)
# => true

subtype?(reconstructed, original)
# => false

Another example with gradual types:

original =
  opt_difference(
    non_empty_list(none(), term()),
    list(dynamic())
  )

to_quoted_string(original)
# => "dynamic(non_empty_list(term())) or non_empty_list(term(), not empty_list())"

reconstructed =
  opt_union(
    dynamic(non_empty_list(term())),
    non_empty_list(term(), opt_negation(empty_list()))
  )

to_quoted_string(reconstructed)
# => "non_empty_list(term(), term())"

equal?(original, reconstructed)
# => false

subtype?(original, reconstructed)
# => true

subtype?(reconstructed, original)
# => false

proper_list = non_empty_list(integer())

subtype?(proper_list, original)
# => false

subtype?(proper_list, reconstructed)
# => true

Expected behavior

Printed form should reconstruct the same type. Widening in the constructor is intentional and documented. Printer only documents "print the last type as term() if it only misses non empty lists." which suggests the current behavior is an unsafe optimization

Activity

  1. added theissue type on Sep 15, 2026
  2. added a commit that references this issue on Nov 1, 2026
    d34d39b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions