
That whole message is 817 characters long and the mismatch is one word:Listwhere the method promisedIEnumerable. Your eye can't find it because both halves share the same 40 tokens around it. A diff can, and that is the better way: split each type into one token per line, indent by nesting depth, and let a diff tool mark the lines that changed. Below is a 70-line script that does it for C#, Python, TypeScript, Java and Kotlin types, run on all three errors you sent, plus the fix for each one.
Why the IDE Tooltip Can’t Help You Here
Rider and PyCharm print the two types as flat strings, because that’s what the compiler hands them. The quick-fix under the error (“Make ‘_to_numpy’ return …”) only offers to change the annotation to whatever the code happens to produce, which papers over the bug half the time. Three things make the strings unreadable:
- Namespaces.
System.Collections.Generic.IEnumerableis 36 characters for a 11-character idea. C# tooltips always print them in full. - Repeated sub-types. A union like
dict[Any, Any] | ndarray[Any, Any] | list[ndarray[Any, Any]]shows up in both the expected and actual type, so your eye matches the wrong pair. - Nesting without line breaks. A tuple inside a list inside a dictionary is three levels deep, written on one line.
JetBrains does have one built-in route: copy the expected type, open a scratch file (Ctrl+Alt+Shift+Insert), paste the actual type, select it, and use Edit → Compare with Clipboard. It works, but it diffs characters on one line, so you still get a long red smear. The script below diffs tokens instead.
A 70-Line Script That Marks the Mismatch
Save this as typediff.py anywhere on your PATH. It strips namespaces, drops C# tuple element names (FormationData Formation becomes FormationData), breaks each type into one token per line, and uses Python’s own difflib to line the two up.
"""typediff.py - show where two type signatures differ.
Usage:
python typediff.py "EXPECTED TYPE" "ACTUAL TYPE"
python typediff.py (then paste the two types, one per line)
Works on C#, TypeScript, Python, Java, Kotlin and Rust type strings,
because all of them nest with < > [ ] ( ) and separate with , and |.
"""
import re
import sys
OPEN, CLOSE = "<[(", ">])"
def strip_namespaces(s: str) -> str:
# System.Collections.Generic.List -> List, LeoImportAPI.FBS.Foo -> Foo
return re.sub(r"\b(?:[A-Za-z_]\w*\.)+(?=[A-Za-z_]\w*)", "", s)
def tokens(s: str) -> list[str]:
s = strip_namespaces(s).replace("'", "")
raw = re.findall(r"[<>\[\](),|]|[^<>\[\](),|\s]+", s)
out: list[str] = []
for t in raw:
# "FormationData Formation" is a C# named tuple element: keep the type, drop the label
if out and out[-1] not in "<[(>]),|" and t not in "<[(>]),|":
continue
out.append(t)
return out
def outline(toks: list[str]) -> list[str]:
"""One token per line, indented by nesting depth."""
lines, depth = [], 0
for t in toks:
if t in CLOSE:
depth -= 1
lines.append(" " * depth + t)
if t in OPEN:
depth += 1
return lines
def diff(a: list[str], b: list[str]) -> list[tuple[str, str, str]]:
"""Line up the two outlines and mark each line: ' ' same, '-' only expected, '+' only actual."""
import difflib
out = []
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
if tag == "equal":
out += [(" ", x, x) for x in a[i1:i2]]
else:
left, right = a[i1:i2], b[j1:j2]
for k in range(max(len(left), len(right))):
out.append(("!", left[k] if k < len(left) else "", right[k] if k < len(right) else ""))
return out
def main() -> None:
if len(sys.argv) >= 3:
expected, actual = sys.argv[1], sys.argv[2]
else:
print("Paste the EXPECTED type, press Enter, paste the ACTUAL type, press Enter:")
expected, actual = input().strip(), input().strip()
a, b = outline(tokens(expected)), outline(tokens(actual))
width = max(len(x) for x in a + b) + 2
print(f"\n{'EXPECTED':<{width}} ACTUAL")
print("-" * (width * 2 + 2))
mismatches = 0
for mark, left, right in diff(a, b):
flag = " " if mark == " " else ">> "
if mark != " ":
mismatches += 1
print(f"{flag}{left:<{width}}{right}")
print(f"\n{mismatches} line(s) differ. Lines starting with >> are the mismatch.")
if __name__ == "__main__":
main()
Run it with the two types as arguments, or run it with no arguments and paste them when asked. Quotes from the tooltip don’t matter, the script removes them.
Error 1: C# Says List Where the Method Promised IEnumerable

python typediff.py "System.Collections.Generic.Dictionary<System.DateTime,System.Collections.Generic.IEnumerable<(...)>>" "System.Collections.Generic.Dictionary<System.DateTime,System.Collections.Generic.List<(...)>>"
What the script printed (shortened)
EXPECTED ACTUAL
------------------------------------------------------------
Dictionary Dictionary
< <
DateTime DateTime
, ,
>> IEnumerable List
< <
( (
TrainpartSequenceInfo TrainpartSequenceInfo
... ...
1 line(s) differ. Lines starting with >> are the mismatch.
One line differs. The reason the compiler won’t accept it: Dictionary<K, V> is not covariant, so a Dictionary<DateTime, List<T>> can never be passed off as a Dictionary<DateTime, IEnumerable<T>>, even though a List<T> on its own converts to IEnumerable<T> fine. You have two clean fixes.
return byDay.ToDictionary(
kv => kv.Key,
kv => (IEnumerable<(TrainpartSequenceInfo SequenceInfo,
FormationData Formation,
IEnumerable<(TrainpartInfo, FormationInfo)> TpList,
IEnumerable<DateTime> OppDates)>)kv.Value);
var byDay = new Dictionary<DateTime, IEnumerable<(TrainpartSequenceInfo SequenceInfo,
FormationData Formation,
IEnumerable<(TrainpartInfo, FormationInfo)> TpList,
IEnumerable<DateTime> OppDates)>>();
// ... fill it with lists as before; a List<T> assigns to IEnumerable<T> ...
return byDay;
Fix B is the one to pick if nothing after this method needs .Add() on the values. The tuple element names (SequenceInfo, Formation) were never part of the problem. C# ignores them when it compares tuple types, and the script drops them for the same reason.
Error 2: Python Builds a tuple[int, …] | None and the Annotation Says tuple[int]

python typediff.py "tuple[tuple[int], tuple[int], int, MOSyncVectorEnv, ConflictEnv]" "tuple[tuple[int, ...] | None, tuple[Any], int, MOSyncVectorEnv, ConflictEnv]"
What the script printed
EXPECTED ACTUAL
----------------------------------------
tuple tuple
[ [
tuple tuple
[ [
int int
>> ,
>> ...
] ]
>> |
>> None
, ,
tuple tuple
[ [
>> int Any
] ]
, ,
int int
... ...
5 line(s) differ. Lines starting with >> are the mismatch.
Two separate problems show up, and the diff keeps them apart:
- Position 1: the annotation says
tuple[int], which in Python typing means a tuple of exactly one int. The code returns a tuple of any length, and it can beNone, most likely fromenv.observation_space.shape, which Gymnasium types astuple[int, ...] | None. - Position 2:
tuple[Any]means the checker lost track of what went in, usually because it came from an unannotated space object.
def generate_envs(...) -> tuple[tuple[int, ...], tuple[int, ...], int, MOSyncVectorEnv, ConflictEnv]:
env_temp = ConflictEnv(...)
assert isinstance(env_temp.action_space, spaces.Discrete)
obs_shape = env_temp.observation_space.shape
assert obs_shape is not None, "observation space has no shape"
reward_shape: tuple[int, ...] = tuple(int(n) for n in env_temp.reward_space.shape or ())
n_actions = int(env_temp.action_space.n)
return obs_shape, reward_shape, n_actions, vector_env, env_temp
The assert is what removes None from the type, so the checker is happy and you get a clear failure message if a space ever has no shape.
Error 3: Python Loses the Key Type Because the Dict Was Matched Without One

python typediff.py "dict[str, dict[Any, Any] | ndarray[Any, Any] | list[ndarray[Any, Any]] | ndarray[Any, Any] | int]" "dict[Any, dict[str, dict[Any, Any] | ndarray[Any, Any]] | list[ndarray[Any, Any]] | ndarray[Any, Any] | int]"
What the script printed (shortened)
EXPECTED ACTUAL
----------------------------
dict dict
[ [
>> str Any
, ,
dict dict
[ [
>> Any str
, ,
>> Any dict
>> ] [
... ...
Read the first >> line and stop there. case dict(): narrows obj to a bare dict, so key in obj.items() is Any, and the comprehension you return inherits that. The later lines are the same problem one level down: the function calls itself, so the nested dict’s type is whatever the recursion returned. Fix both with one recursive alias and a str() on the key.
from typing import Any, TypeAlias
import numpy as np
from numpy.typing import NDArray
NumpyTree: TypeAlias = "dict[str, NumpyTree] | NDArray[Any] | list[NDArray[Any]] | int"
def _to_numpy(self, obj: Any, key_name: str = "", reward_type: str = "") -> NumpyTree:
match obj:
case dict():
return {{
str(key): self._to_numpy(value, key_name=f"{{key_name}}.{{key}}", reward_type=reward_type)
for key, value in obj.items()
}}
case list():
return [np.asarray(v) for v in obj]
case int():
return obj
case _:
return np.asarray(obj)
Where Your Compiler Already Does This for You
- Rust prints
expected `X`, found `Y`on two lines and, since 1.70, highlights the differing segment in colour. Nothing to add. - TypeScript:
tsc --prettycolours the message, and from 4.x it shortens repeated sub-types with.... For a long one, copy both types from the message into the script above. The tokenizer handles< >,[ ]and|. - mypy:
mypy --prettywraps long messages. Addreveal_type(byDay)in the code to print the actual type on its own line. - C#: paste the full type into a
using Alias = ...;line at the top of the file. The compiler then uses the short alias in every error, and the diff gets 400 characters shorter before you start.
