Skip to content

[ty] Return type inference - #29253

Merged
sharkdp merged 21 commits into
david/return-type-inference-configfrom
david/return-type-inference
Oct 15, 2026
Merged

sharkdp merged 21 commits into
david/return-type-inference-configfrom
david/return-type-inference

Conversation

@sharkdp

@sharkdp sharkdp commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

image

This PR adds return type inference as a new feature to ty. The feature is currently disabled by default. Users can activate the feature by setting analysis.return-type-inference to "always".

In a standalone function, the inferred return type is simply the union of the types of all reachable returned expressions, as shown in the image on the right. If control flow can reach the end of the function scope, we add None to the union. In a (non-final) method on a (non-final) class, we additionally union with Unknown to allow child classes to widen the intended return type. For example, imagine

class TaskBase:
    def compute(self):
        return None

class IntTask(TaskBase):
    def compute(self):
        return 42

Here, we infer a return type of None | Unknown for TaskBase.compute. Otherwise, if we inferred None, we would prohibit child classes from returning something else, which seems undesirable. Another interesting scenario is when the base class method is annotated, but the child class method is not:

class Base:
    def method(self) -> int:
        return 0

class Child(Base):
    def method(self):
        return 1

Here, we respect the base class definition and infer Child.method as returning int, not int | Unknown.

This work is based on #17371 by @mtshiba (thank you!)

There are a few notable things that are not implemented here:

  • The type of the function itself currently stays unchanged, that is: we do not infer a signature with the inferred return type. Concretely: my_function() may be inferred as int, but my_function itself would still be inferred as def my_function() -> Unknown. We have existing tests with TODOs for this and I plan to tackle this in a follow up.
  • We do not yet have return type inference for generator functions or async functions
  • We do not attempt to solve the problem of "escaping" inner types here (see tests with TODOs)
  • We do not add LSP/playground features related to this feature, but plan to do so soon (display return type hints).
  • There is a limitation around functools.wraps that's present in the ecosystem report. This will hopefully be solved by [functools] Support binding wrapped methods python/typeshed#16515
  • There is a limitation around NotImplemented which hasn't been resolved yet. The solution is described here.
  • Fixpoint iteration currently uses Divergent as a provisional value. I did think about using the new μ-types, but that currently leaves too many questions unanswered: How would we represent an anonymous μ-type? How would the (provisional) recursive marker R interact with other types during inference (especially since those operations require "closed" recursive types, and R would still be open)?

closes astral-sh/ty#128

Test Plan

  • Changed assertions in the previously reviewed & merged mdtests
  • Studied the ecosystem impact and addressed many problems

@sharkdp sharkdp added the ty The ty type checker label Oct 9, 2026
@sharkdp
sharkdp added this pull request to stack #29254 October 9, 2026 13:46
@astral-sh-bot

astral-sh-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 98.24%. The percentage of expected errors that received a diagnostic held steady at 98.24%. The number of fully passing files held steady at 134/146.

@astral-sh-bot

astral-sh-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Memory usage report

Summary

Project Old New Diff Outcome
prefect 475.51MB 476.53MB +0.22% (1.02MB) ⏫
sphinx 172.17MB 172.55MB +0.22% (387.92kB) ⏫
trio 92.35MB 92.59MB +0.26% (244.16kB) ⏫
flake8 37.93MB 37.96MB +0.09% (33.38kB) ⏫

Significant changes

Click to expand detailed breakdown

prefect

Name Old New Diff Outcome
infer_expression_types_impl 23.77MB 24.11MB +1.43% (348.38kB) ⏫
infer_definition_types 37.78MB 37.98MB +0.52% (199.27kB) ⏫
Type<'db>::apply_specialization_inner_ 2.96MB 3.09MB +4.23% (128.30kB) ⏫
infer_scope_types_impl 19.81MB 19.93MB +0.60% (122.18kB) ⏫
FunctionType 5.74MB 5.84MB +1.91% (112.06kB) ⏫
try_call_dunder_get_inner 1.08MB 1.10MB +1.72% (19.09kB) ⏫
FunctionType<'db>::literal_signature_ 2.61MB 2.63MB +0.63% (16.75kB) ⏫
all_narrowing_constraints_for_expression 3.13MB 3.14MB +0.51% (16.45kB) ⏫
function_known_decorators 1.82MB 1.83MB +0.63% (11.77kB) ⏫
infer_unpack_types 437.64kB 448.04kB +2.38% (10.40kB) ⏫
member_lookup_with_policy_inner 9.32MB 9.33MB +0.10% (9.86kB) ⏫
value_truthiness 1.58MB 1.59MB +0.46% (7.42kB) ⏫
Type<'db>::class_member_with_policy_inner_ 11.41MB 11.42MB +0.06% (6.47kB) ⏫
try_call_bin_op_result_impl 86.73kB 92.03kB +6.11% (5.30kB) ⏫
infer_statement_types_impl 684.27kB 688.23kB +0.58% (3.97kB) ⏫
... 44 more

sphinx

Name Old New Diff Outcome
infer_expression_types_impl 11.44MB 11.60MB +1.40% (163.75kB) ⏫
infer_definition_types 11.63MB 11.68MB +0.43% (51.58kB) ⏫
Type<'db>::apply_specialization_inner_ 774.00kB 822.28kB +6.24% (48.28kB) ⏫
FunctionType 1.78MB 1.81MB +2.08% (37.79kB) ⏫
infer_scope_types_impl 6.27MB 6.30MB +0.50% (32.01kB) ⏫
all_narrowing_constraints_for_expression 1.65MB 1.66MB +0.68% (11.47kB) ⏫
try_call_dunder_get_inner 290.04kB 300.74kB +3.69% (10.70kB) ⏫
member_lookup_with_policy_inner 3.59MB 3.60MB +0.19% (6.98kB) ⏫
infer_unpack_types 259.58kB 266.25kB +2.57% (6.67kB) ⏫
value_truthiness 858.59kB 861.97kB +0.39% (3.38kB) ⏫
infer_statement_types_impl 531.74kB 534.46kB +0.51% (2.72kB) ⏫
try_call_bin_op_result_impl 91.08kB 93.67kB +2.85% (2.59kB) ⏫
condition_truthiness 423.74kB 425.86kB +0.50% (2.12kB) ⏫
loop_header_reachability 275.26kB 276.95kB +0.61% (1.69kB) ⏫
analyze_non_terminal_call 474.30kB 475.62kB +0.28% (1.33kB) ⏫
... 20 more

trio

Name Old New Diff Outcome
infer_expression_types_impl 5.95MB 6.06MB +1.93% (117.72kB) ⏫
Type<'db>::apply_specialization_inner_ 656.32kB 685.45kB +4.44% (29.12kB) ⏫
FunctionType 1.21MB 1.23MB +2.23% (27.59kB) ⏫
infer_definition_types 4.70MB 4.73MB +0.56% (27.15kB) ⏫
infer_scope_types_impl 2.86MB 2.88MB +0.58% (17.02kB) ⏫
all_narrowing_constraints_for_expression 477.09kB 480.23kB +0.66% (3.15kB) ⏫
try_call_dunder_get_inner 107.22kB 110.27kB +2.84% (3.05kB) ⏫
value_truthiness 408.36kB 411.12kB +0.68% (2.76kB) ⏫
try_call_bin_op_result_impl 27.49kB 30.13kB +9.61% (2.64kB) ⏫
analyze_non_terminal_call 448.40kB 450.82kB +0.54% (2.42kB) ⏫
infer_unpack_types 91.11kB 92.80kB +1.85% (1.69kB) ⏫
function_known_decorators 329.93kB 331.21kB +0.39% (1.28kB) ⏫
member_lookup_with_policy_inner 1.23MB 1.23MB +0.10% (1.22kB) ⏫
Type<'db>::class_member_with_policy_inner_ 1.38MB 1.39MB +0.08% (1.15kB) ⏫
loop_header_reachability 96.09kB 97.10kB +1.05% (1.01kB) ⏫
... 24 more

flake8

Name Old New Diff Outcome
infer_expression_types_impl 777.29kB 788.07kB +1.39% (10.78kB) ⏫
FunctionType 297.98kB 303.93kB +2.00% (5.95kB) ⏫
Type<'db>::apply_specialization_inner_ 96.43kB 102.21kB +6.00% (5.78kB) ⏫
infer_definition_types 1.17MB 1.17MB +0.37% (4.43kB) ⏫
infer_scope_types_impl 537.57kB 539.76kB +0.41% (2.19kB) ⏫
function_known_decorators 167.21kB 167.94kB +0.44% (752.00B) ⏫
member_lookup_with_policy_inner 283.42kB 284.14kB +0.25% (736.00B) ⏫
try_call_dunder_get_inner 14.77kB 15.21kB +2.96% (448.00B) ⏫
all_narrowing_constraints_for_expression 74.91kB 75.30kB +0.53% (408.00B) ⏫
infer_unpack_types 26.91kB 27.30kB +1.45% (400.00B) ⏫
FunctionType<'db>::literal_signature_ 225.32kB 225.61kB +0.13% (304.00B) ⏫
try_call_bin_op_result_impl 3.02kB 3.27kB +8.27% (256.00B) ⏫
value_truthiness 41.48kB 41.62kB +0.34% (144.00B) ⏫
infer_statement_types_impl 30.54kB 30.68kB +0.43% (136.00B) ⏫
script_tag 8.81kB 8.93kB +1.33% (120.00B) ⏫
... 11 more

@astral-sh-bot

astral-sh-bot Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

ecosystem-analyzer results

No diagnostic changes detected ✅

Large timing changes:

Project Old Time New Time Change
paroxython 0.05s 0.08s +61%
pegen 0.05s 0.07s +58%

Full report with detailed diff (timing results)

@sharkdp
sharkdp force-pushed the david/return-type-inference branch from 5757658 to 2768ea3 Compare October 9, 2026 14:02
@codspeed

codspeed Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 184 untouched benchmarks
⏩ 60 skipped benchmarks1


Comparing david/return-type-inference (3b952b5) with david/return-type-inference-config (a631ad0)

Open in CodSpeed

Footnotes

  1. 60 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

Base automatically changed from david/return-type-inference-tests to main October 12, 2026 12:01
@sharkdp
sharkdp force-pushed the david/return-type-inference branch 2 times, most recently from dc3ba2c to 80251ba Compare October 12, 2026 12:29
@sharkdp
sharkdp force-pushed the david/return-type-inference branch from 80251ba to f2daf73 Compare October 12, 2026 12:32
@sharkdp
sharkdp removed this pull request from stack #29254 October 12, 2026 12:41
@sharkdp
sharkdp changed the base branch from main to david/return-type-inference-prep October 12, 2026 12:41
@sharkdp
sharkdp added this pull request to stack #29254 October 12, 2026 12:43
Base automatically changed from david/return-type-inference-prep to main October 12, 2026 13:15
@sharkdp
sharkdp force-pushed the david/return-type-inference branch from f2daf73 to 3343aa2 Compare October 12, 2026 13:16
@sharkdp
sharkdp force-pushed the david/return-type-inference branch from 3343aa2 to dc6472c Compare October 12, 2026 15:38
@sharkdp
sharkdp force-pushed the david/return-type-inference branch from dc6472c to 7e37569 Compare October 12, 2026 15:40
@sharkdp
sharkdp removed this pull request from stack #29254 October 12, 2026 15:41
@sharkdp
sharkdp changed the base branch from main to david/return-type-inference-prep2 October 12, 2026 15:41
@sharkdp
sharkdp added this pull request to stack #29254 October 12, 2026 15:41
Base automatically changed from david/return-type-inference-prep2 to main October 12, 2026 15:50
@sharkdp
sharkdp force-pushed the david/return-type-inference branch from 7e37569 to b9f44c5 Compare October 12, 2026 15:50
// def get(self):
// return self.value
//
if let Some(class) = class

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, does this assume that the method is a class method, and not a static method? In this case, it should be returning int:

class Box[T]:
   @staticmethod
   def identity(value: T):
       return value

x: int = Box[int].identity(1)  # error: T@Box | Unknown

@sharkdp sharkdp Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, great catch. Fixed in e7ab09f

// Add `None` if the function can implicitly return it
if is_end_of_scope_reachable(db, self.scope()) {
return_type = return_type.add(Type::none(db, env));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like protocols and abstract method stubs contribute None right now -- maybe they should be excluded?

class Provider(Protocol):
    def get(self): ...

class Impl:
    def get(self) -> int:
        return 1

def use(p: Provider) -> int:
    return p.get()  # error: None | Unknown

use(Impl())  # accepted

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great call. I added two tests for this and excluded abstract methods and protocol methods completely, for now. I attempted to explain the reasoning for doing so in the mdtests.

@sharkdp sharkdp left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the review, great findings.

}

Some(return_type.build())
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is, yes. I also spent some time to make it look straightforward, so good that you're noticing that 😄


# TODO: remove this explicit `Unknown` return type when type inference does not expose
# the internal `Wrapped` class anymore.
def personify(cls) -> Unknown:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so Animal below would be inferred as <class 'Wrapped'>. This is exactly the same scenario that is described in the "Internal structure that should not leak out" mdtest in return_type_inference.md. These nested classes should not be exposed, because at runtime, a new class would be created for every call of personify, and <class 'Wrapped'> doesn't represent that.

I have a fix for this already, I just wanted to focus this initial PR on the basic implementation.

// Add `None` if the function can implicitly return it
if is_end_of_scope_reachable(db, self.scope()) {
return_type = return_type.add(Type::none(db, env));
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great call. I added two tests for this and excluded abstract methods and protocol methods completely, for now. I attempted to explain the reasoning for doing so in the mdtests.

// def get(self):
// return self.value
//
if let Some(class) = class

@sharkdp sharkdp Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, great catch. Fixed in e7ab09f

@charliermarsh

Copy link
Copy Markdown
Member

Great work!

This branch was previously deployed

1 inactive deployment
automations — 3b952b55 Deployed Oct 15, 2026 by sharkdp via security-review / security review #91974
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ty The ty type checker

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Return type inference

2 participants