Hints and Nudges

This post first appeared 21 April 2025.

Which of these identically implemented Python functions has the best signature?1

def add_new_item_to_shopping_list(
    list_: tuple[str, ...],
    item: str
) -> tuple[str, ...]:
    return tuple((new_item, *shopping_list))
def anitsl(l: tuple[str, ...], i: str) -> tuple[str, ...]:
    return tuple((i, *l))
def add_item(shopping_list: tuple[str, ...], new_item: str) -> tuple[str, ...]:
    return tuple((shopping_list, *new_item))

Well, the second option is obviously terrible (though some style guides would claim that the only problem is that there are five too many letters in the function name).

The third one also appears at first to not be particularly great. The name “add_item” is quite generic: what exactly is being added? At least the first one is exact, if overly verbose.

However, I argue add_item is actually better than add_new_item_to_shopping_list for the following reasons.

Firstly functions are almost never taken in isolation. If you see add_item in a codebase it is likely either bound to a class or go-to-definition will show you the import line which will clarify the intent2.

But the kicker for me isn’t actually the name, its the arguments. This is something I’ve noticed doing now that my NeoVim has inlay hints enabled.

To illustrate, which of these two snippets of code contains a bug? Both have the exact same function add_item.

Is it this one?

Or this one?

Its the first one: shopping_list=todo_list is the dead giveaway!

The hint here is provided inline by my LSP attached to the editor (basedpyright at the time of writing) and shows the argument name if it differs from the variable name.

The result of this is that I find myself tending to write descriptive and semantic argument names3 and then matching these at the call points to avoid seeing the inlay hint. This makes a huge difference when types are either not available (because Python4), or too verbose. For example, we could have done a similar thing with

ShoppingList = tuple[str, ...]  # or newtypes?
ShoppingListItem = str

def add_item(shopping_list: ShoppingList, new_item: ShoppingListItem) -> ShoppingList:
    return tuple((shopping_list, *new_item))

but then the burden is shifted to understanding what ShoppingList means as a type in disguise.

I found this useful in a recent project where I had offsets in bytes and offsets in characters (not the same due to UTF-8) for some text I was working with. This was a rust project, so I could certainly have had

type OffsetInBytes(usize);
type OffsetInChars(usize);

and used the type system but found variables offset_in_bytes: usize and offset_in_chars: usize so much easier to work with5. And, with appropriately named arguments and inlay hints, I never mixed the two up!


Postscript

A possible alternative is

def add_item(*, shopping_list: tuple[str, ...], new_item: str) -> tuple[str, ...]:
    return tuple((shopping_list, *new_item))

Here we use the * marker to force all the arguments to be passed by keyword.

That is, you have to specify exactly what you are passing:

add_item(todo_list, new_item) # Raises a TypeError
add_item(shopping_list=todo_list, new_item=new_item) # Runs, but is obviously wrong

However, despite preventing errors by forcing you to type something silly like shopping_list=todo_list, this is incredibly verbose when used “correctly” as I described above as you end up having a lot of argument_name=argument_name in your code.

Its a trade-off, but I find this too much.


  1. I’ve used typing in all of them, even the insane one, because… you always should try, even if Python makes this a futile battle. ↩︎

  2. If your import line is from utils import add_item you only have yourself to blame. ↩︎

  3. This helps with disambiguating the function: if the signature is add_item(shopping_list, new_item) then even without types or other signals, its pretty clear what the function does. ↩︎

  4. Yes, Python has type hints, but they are not particularly expressive yet and often run contrary to a lot of established code bases that bought into duck typing or use trickery the type system can’t handle. ↩︎

  5. For instance you don’t have my usual problem with rust newtypes: having to re-export all the functionality of the wrapped type. ↩︎