Rethink `__dunder__` methods for discoverability and developer experience

Mojo is turning into an amazing language: it unlocks maximum performance while still offering modern tooling and language features that make it a joy to write.

Mojo started extremely Pythonic, and it still is, but it has also developed its own identity, which is great. However, in my opinion, __dunder__ methods don’t fit as well in a modern language like Mojo.

In this post, I’ll focus on these dunder methods:

  • __getitem__ (indexing with [])
  • __len__ (length with len())
  • __contains__ (membership with in)

Problems

Discoverability

Dunder methods are effectively hidden from a developer’s view. When you type my_list. in your IDE, autocomplete usually shows __getitem__, __len__, or __contains__ near the end, because they’re treated as “magic” methods and not something you call directly.

If you don’t already know these methods exist (and what they do), you won’t discover them through normal API exploration. That creates a steeper learning curve for new developers and makes it harder to understand what a type can do.

For example, if you have a custom Vector type, how do you discover that you can use [] for indexing? You can’t, you have to guess, try it, or read the documentation. That’s a mediocre developer experience in 2026.

IDE hover docs

All three examples above ([], len(), in) don’t show hover documentation in IDEs:

  • data[0]: no hover docs at all
  • "key" in data: no hover docs at all
  • len(data): docs for global len() function, not specific to the type of data

This means developers have to:

  1. Know that [] maps to __getitem__
  2. Manually search for __getitem__ in the documentation
  3. Read and understand the method signature

Compare that to my_list.get(0), where hovering directly shows the method’s documentation, parameters, and return type.

Global functions vs methods

Functions like len() are global rather than methods on the object. That creates several issues:

  • Discovery: How do you know if len() works on a type? You have to try it and see if it errors. How do you even know which other global functions exist that operate on your type? You have to read documentation or guess.
  • Namespacing: Global functions pollute the namespace and can lead to naming conflicts.
  • Inconsistency: Some operations are methods (data.append()), others are global functions (len(data)). That’s confusing.
  • Type safety: With methods, the IDE can suggest only valid operations. With global functions, you often won’t know until you try.

The modern approach (Rust, Swift, etc.) is to prefer methods. data.length() or data.len() makes it explicit that the operation belongs to the object.

Unsafe by default

Indexing with [] typically panics or crashes when an index is out of bounds.

That’s unsafe by default.

A better approach would be to return an Optional/Result (or provide a default value). For performance critical code, you could still expose an unsafe version like data.get_unchecked(10), but the safe version should be the default and easiest to use.

Ambiguous across types

The same syntax means different things for different types:

  • For lists: data[0] gets an element
  • For dicts: data["key"] gets a value by key
  • For strings: data[1:5] returns a slice
  • For custom types: data[x, y] might mean 2D indexing

This overloading makes the language harder to understand and reason about. Explicit method names would be clearer:

  • data.get_at_index(0) — get element at index
  • data.get_value("key") — get value by key
  • data.slice(1, 5) — get substring slice
  • data.get_2d(x, y) — get element in a 2D structure

What if we rethink dunder methods?

Instead of relying on “magic” dunder methods and operator overloading, what if Mojo embraced explicit, discoverable methods?

Proposed alternatives

Indexing

Explicit methods for indexing:

  • data.get(0)
  • data.get_or(0, default_value)
  • data.get_unchecked(0) (unsafe, no bounds checking)

Length

  • data.length() or data.len()

Membership

  • data.contains(value)

Benefits

  1. Autocomplete works: type data. and see available operations
  2. Hover documentation works: hover over .get() and see the docstring, params, and return type
  3. Explicit is better than implicit: clear method names over magic operators
  4. Safe by default: methods can return Optional or Result
  5. Consistency: operations are methods, not special cased globals
  6. Extensibility: easy to add variants like .get_mut(), .get_unchecked(), etc.

Addressing counterarguments

“But [] is familiar and widely used!”

Some might argue that indexing with [] is familiar and widely used across languages. That’s true—but sometimes we should be willing to rethink old conventions to improve clarity, safety, and developer experience.

Familiarity doesn’t mean optimal. Consider:

  • C had manual memory management (familiar) → Rust introduced ownership (better)
  • JavaScript had var (familiar) → let and const are now standard (better)
  • Many languages had null (familiar) → Modern languages use Optional (better)

“It’s more verbose!”

data.get(5) is only three characters longer than data[5], but it’s:

  • More explicit and clear
  • Shows up in autocomplete
  • Has hover documentation
1 Like

There are always pros and cons.

You focused on __getitem__ , __len__ and __contains__. These are already methods, but they are cumbersome to write because the underscores add four characters in total. You would like to remove the underscores and the associated operators or global functions.

Are there any other dunder methods you would like to replace?
We are all aware the tooling isn’t great.
Have you considered that the tooling could be improved.

You seem to dislike “getitem” in particular.
I have an idea for making it safer while preserving the ergonomics of subscripting.
Then, a feature like explicit raises could be added to enforce stricter error handling, either as a language feature or a compiler flag.

For reference: Proposal: Introduce implicit raises for Optional Error Handling

fn append(mut self, value: T) implicit raises OutOfMemoryError: ... 
fn __add__(lhs: Self, rhs: Self) implicit raises OverflowError -> Self: ...
fn __getitem__(self, index: Int) implicit raises IndexError -> T: ...

I don’t see the problem. What is the issue with learning new concepts in a new language?

1 Like

Also, see point two on managing language complexity:

Java tried to go down this route and the end result has been 30 years of complaining about the lack of a feature to enabled “native feeling” types. I feel pretty strongly about having at least getitem for indexing, but I agree that LSP hover should probably treat it foo[ as a hint to show docs for __getitem__. On __len__ I can take it or leave it, since I tend to like postfix things more so I would rather have mylist.len(). I’m also not strongly attached to __contains__, especially since most of the places where I would use it want something which is more “get_or_default()”-flavored or wants an Entry API.

As an additional point for __len__, the dunder syntax is meant to represent special handling by the compiler, typically meaning operator handling. The current implementations just call __len__ directly, which I think makes this a candidate for lifting to .len() since the meaning of dunder as “the compiler is messing with this a bit” should be preserved.

The LSP in general is in a rather poor state, especially coming from Rust which is often regarded as having one of the better LSPs. This is something that definitely needs fixing. I think it being a bit better will fix the problem, since the points you’ve raised are valid.

4 Likes

Thanks for the feedback :nerd_face:

Thinking about it a little more, for me it mostly comes down to:

  1. “chaining” (instead of nesting/global functions)
  2. discoverability

Chaining / left-to-right readability

For many people, reading and writing transformations left-to-right is more natural than nesting calls. Compare:

function3(function2(function1(data)))

with a fluent style:

data.function1().function2().function3()

Pythons “protocol” methods (dunders) often push you toward the first style because the most visible API ends up as a global function or operator:

  • Sized → __len__len(obj)
  • Absable → __abs__abs(obj)

So you get:

len(some_long_chain_of_operations)

instead of something like:

some_long_chain_of_operations.len()

Even when the dunder approach is powerful, it can work against fluent, linear pipelines—especially in codebases that prefer method chaining over free functions.

Discoverability / IDE autocomplete

Dunder methods generally shouldn’t be called directly. In autocomplete, you typically see:

obj.
    <fields and methods>
    ...
    ...
    ...
    __abs__      -->>> enables global "abs()" function; intuitive?
    __contains__ -->>> enables "in" operator; not intuitive
    __gt__       -->>> enables ">" operator; not intuitive
    __len__      -->>> enables global "len()" function; intuitive?

In contrast, an explicit method surface is immediately discoverable:

obj.
    abs()
    contains()
    gt()
    len()

This matters for accessibility: APIs that “advertise themselves” through autocomplete feel easier to learn and easier to use correctly.

Conclusion

From a usability standpoint, a language can feel cleaner and more approachable when core capabilities are expressed primarily as:

  • methods on the object
  • functions grouped in modules (instead of many global/builtin functions)
  • and when “magic functions” are the exception rather than the main gateway to functionality
1 Like

Have you considered simply modifying the documentation/LSP experience, for example through the use of a decorator? This would give Mojo the best of both worlds: familiarity with Python, and thus a good LLM experience, as well as good developer tooling.

@doc_inline
def len(self) -> Int:
    return self.__len__()

What you have forgotten to mention is that this fluent style often results in long, unreadable chains, as experience with Rust has shown.
Also, if you don’t need chaining, global functions are actually one character shorter: len(expr) vs expr.len() — five characters vs six characters.

I have never seen this. Could be something :wink:

My experience is different here! I really love it and think readability is at least better :smiley:
For smaller lines:

obj.do_a().do_b()_do_c()

is more readable and easier to follow than:

do_c(do_b(do_a(obj)))

and for longer chains:

(
    obj
    .do_a()
    .do_b()
    .do_c()
    .do_d()
)

vs

do_d(
    do_c(
        do_b(
            do_a(obj)
        )
    )
)

haha :smiley:
I do not care about that at all. Readability and clarity is sooooo but more important.
Same with ancient names like atoi which is insane from an accessibility perspective.

BUT if we are actually talking about “typing speed” I would assume .len is fasted to add to the code than len. Reason being that often we create these “chains” of operations and adding .len() at the current position of the cursor is miles faster than going back to the start of the expression and surrounding everything with len(...............)

:nerd_face:

I think some operator dunder methods are more readable and follow mathematical precedence rules like __add__ and __mul__:

a + b * c
is easier to read and much more familiar than
a.add(b.mul(c))

4 Likes