Skip to content

Fix error type when field with resolver= lacks type annotations#4360

Merged
bellini666 merged 6 commits intostrawberry-graphql:mainfrom
youngjaekwon:fix/missing-field-annotation-error
Apr 25, 2026
Merged

Fix error type when field with resolver= lacks type annotations#4360
bellini666 merged 6 commits intostrawberry-graphql:mainfrom
youngjaekwon:fix/missing-field-annotation-error

Conversation

@youngjaekwon
Copy link
Copy Markdown
Contributor

@youngjaekwon youngjaekwon commented Apr 12, 2026

Description

When using strawberry.field(resolver=fn) where both the field type annotation and the resolver return type are missing, the error raised is MissingReturnAnnotationError. This is misleading — MissingFieldAnnotationError is more appropriate since it guides the user to annotate the field directly (e.g. goodbye: int = strawberry.field(resolver=adios)).

The fix distinguishes @strawberry.field decorator usage from explicit strawberry.field(resolver=fn) assignment by comparing the resolver's __qualname__ against f"{cls.__qualname__}.{field_name}". Only the decorator pattern produces a resolver whose qualified name matches the enclosing class and field; every other shape (explicit in-class resolvers, external functions, methods borrowed from another class, module-level functions — including those sharing the field name) correctly raises MissingFieldAnnotationError.

Known limitation

When the resolver is defined inside the class body with the same name as the field and overwrites it (e.g. goodbye = strawberry.field(resolver=goodbye)), its __qualname__ is identical to what the decorator pattern would produce, making the two shapes indistinguishable. In this case, MissingReturnAnnotationError is raised instead of MissingFieldAnnotationError. This is still valid guidance — the user can fix it by adding a return type to the resolver.

Types of Changes

  • Core
  • Bugfix
  • New feature
  • Enhancement/optimization
  • Documentation

Issues Fixed or Closed by This PR

Checklist

  • My code follows the code style of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have read the CONTRIBUTING document.
  • I have added tests to cover my changes.
  • I have tested the changes and verified that they work and don't break anything (as well as I can manage).

Summary by Sourcery

Adjust error handling for untyped Strawberry GraphQL fields using explicit resolvers and expand test coverage accordingly.

Bug Fixes:

  • Raise MissingFieldAnnotationError instead of MissingReturnAnnotationError when a field defined with strawberry.field(resolver=...) lacks both a field type annotation and resolver return type, except when indistinguishable from decorator usage.

Documentation:

  • Add RELEASE.md entry describing the patch-level change in error behavior for untyped explicit resolvers.

Tests:

  • Update and extend resolver-related tests to cover explicit, external, same-name, and decorator-based untyped resolver scenarios.

When using strawberry.field(resolver=fn) where both the field annotation
and the resolver return type are missing, raise MissingFieldAnnotationError
instead of MissingReturnAnnotationError. This gives users more accurate
guidance to annotate the field directly.

The @strawberry.field decorator case still correctly raises
MissingReturnAnnotationError by detecting whether the resolver function
exists separately in the class or was applied as a decorator.

Closes strawberry-graphql#447
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Apr 12, 2026

Reviewer's Guide

Adjusts Strawberry’s field annotation validation to raise a more appropriate error for untyped fields using explicit strawberry.field(resolver=...), adds regression tests for the new behaviors, and records the change in release notes.

File-Level Changes

Change Details Files
Refine annotation checking to distinguish between decorator-based resolvers and explicit resolver= fields and raise the correct error type.
  • Extend _check_field_annotations to inspect the resolver’s wrapped function and the declaring class dict to determine whether the resolver is defined as a separate function or via the decorator pattern.
  • Introduce a resolver_defined_separately check that looks for the resolver function as another callable attribute on the class, excluding the field name itself and StrawberryField instances.
  • Raise MissingFieldAnnotationError when the field is using strawberry.field(resolver=...) without type annotations, otherwise fall back to MissingReturnAnnotationError for decorator-like usage.
strawberry/types/object_type.py
Update tests to cover the new error behavior for various resolver declaration patterns.
  • Change the existing resolver error test to expect MissingFieldAnnotationError and the new guidance message when using an untyped explicit resolver defined as a method on the type.
  • Add a test where the resolver is defined outside the class and passed via resolver=, asserting that MissingFieldAnnotationError is raised.
  • Add a test where resolver= uses the same name as the field, asserting that this remains treated as decorator-like usage and raises MissingReturnAnnotationError.
  • Add a test ensuring that the @strawberry.field decorator path still raises MissingReturnAnnotationError for an untyped resolver.
tests/fields/test_resolvers.py
Document the behavior change in release notes.
  • Create RELEASE.md with patch release type and a note describing the corrected error type for untyped fields using explicit resolvers.
RELEASE.md

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@botberry
Copy link
Copy Markdown
Member

botberry commented Apr 12, 2026

Thanks for adding the RELEASE.md file!

Below is the changelog that will be used for the release.


Raise MissingFieldAnnotationError instead of MissingReturnAnnotationError when a field using strawberry.field(resolver=...) is missing both a type annotation and a resolver return type.

This release was contributed by @youngjaekwon in #4360

Additional contributors: @pre-commit-ci[bot]

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • The resolver_defined_separately check only inspects cls.__dict__, so resolvers inherited from base classes won’t be detected as “defined separately”; if that matters for your use case, consider walking the MRO or documenting/adjusting this behavior.
  • The resolver-vs-field detection logic inside _check_field_annotations is getting fairly dense; consider extracting it into a small helper function to make the intent clearer and keep _check_field_annotations focused on control flow.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `resolver_defined_separately` check only inspects `cls.__dict__`, so resolvers inherited from base classes won’t be detected as “defined separately”; if that matters for your use case, consider walking the MRO or documenting/adjusting this behavior.
- The resolver-vs-field detection logic inside `_check_field_annotations` is getting fairly dense; consider extracting it into a small helper function to make the intent clearer and keep `_check_field_annotations` focused on control flow.

## Individual Comments

### Comment 1
<location path="strawberry/types/object_type.py" line_range="86-91" />
<code_context>
+                    # latter, MissingFieldAnnotationError is more helpful
+                    # since the user should annotate the field directly.
+                    resolver_func = field_.base_resolver.wrapped_func
+                    resolver_defined_separately = any(
+                        v is resolver_func
+                        for k, v in cls.__dict__.items()
+                        if k != field_name
+                        and callable(v)
+                        and not isinstance(v, StrawberryField)
+                    )
+                    if (
</code_context>
<issue_to_address>
**issue:** Resolver detection may miss classmethod/staticmethod-based resolvers and other descriptor wrappers.

Because this logic compares `v is resolver_func` over `cls.__dict__`, it will fail for `@classmethod` / `@staticmethod` or other descriptors used as `strawberry.field(resolver=MyClass.my_resolver)`. In those cases, `cls.__dict__` contains the descriptor (e.g. `classmethod`), not the underlying function, so `resolver_defined_separately` incorrectly remains `False`. To handle these cases, unwrap known descriptors (e.g. via `getattr(v, "__func__", v)`) before comparing to `resolver_func`.
</issue_to_address>

### Comment 2
<location path="tests/fields/test_resolvers.py" line_range="138" />
<code_context>
+    ),
 )
-def test_raises_error_when_return_annotation_missing_resolver():
+def test_raises_field_error_when_using_untyped_explicit_resolver():
     @strawberry.type
     class Query2:
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test to cover the documented "known limitation" case where the resolver has the same name as the field.

The PR description mentions the specific case `goodbye = strawberry.field(resolver=goodbye)`, where the resolver in `cls.__dict__` is overwritten and we fall back to `MissingReturnAnnotationError`. Please add a dedicated test for this scenario to guard against regressions: define a type where `goodbye` is first declared as an unannotated function, then overwritten via `goodbye = strawberry.field(resolver=goodbye)`, and assert that `MissingReturnAnnotationError` (with the expected message) is raised.
</issue_to_address>

### Comment 3
<location path="tests/fields/test_resolvers.py" line_range="131-138" />
<code_context>


 @pytest.mark.raises_strawberry_exception(
-    MissingReturnAnnotationError,
-    match='Return annotation missing for field "goodbye", did you forget to add it?',
+    MissingFieldAnnotationError,
+    match=(
+        'Unable to determine the type of field "goodbye". Either annotate it '
+        "directly, or provide a typed resolver using @strawberry.field."
+    ),
 )
-def test_raises_error_when_return_annotation_missing_resolver():
+def test_raises_field_error_when_using_untyped_explicit_resolver():
     @strawberry.type
     class Query2:
</code_context>
<issue_to_address>
**suggestion (testing):** Add/keep a dedicated test ensuring the decorator-style resolver without return annotation still raises `MissingReturnAnnotationError`.

This change now tests the `strawberry.field(resolver=fn)` path and expects `MissingFieldAnnotationError`. To also cover the decorator path introduced in `_check_field_annotations`, please add a test for the `@strawberry.field` decorator with an untyped resolver and no field annotation, asserting that `MissingReturnAnnotationError` is raised. If that test already exists, consider co-locating or briefly documenting it to clarify the distinction between the two behaviors.

Suggested implementation:

```python
@pytest.mark.raises_strawberry_exception(
    MissingFieldAnnotationError,
    match=(
        'Unable to determine the type of field "goodbye". Either annotate it '
        "directly, or provide a typed resolver using @strawberry.field."
    ),
)
def test_raises_field_error_when_using_untyped_explicit_resolver():
    @strawberry.type
    class Query2:
        def adios(self):


@pytest.mark.raises_strawberry_exception(
    MissingReturnAnnotationError,
    match='Return annotation missing for field "hola", did you forget to add it?',
)
def test_raises_error_when_using_untyped_decorator_resolver():
    @strawberry.type
    class Query:
        @strawberry.field
        def hola(self):
            return "hola"

    strawberry.Schema(query=Query)

```

If this file does not already import `MissingReturnAnnotationError`, add it to the existing imports at the top of `tests/fields/test_resolvers.py`, alongside the other Strawberry exceptions.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread strawberry/types/object_type.py Outdated
),
)
def test_raises_error_when_return_annotation_missing_resolver():
def test_raises_field_error_when_using_untyped_explicit_resolver():
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): Add a test to cover the documented "known limitation" case where the resolver has the same name as the field.

The PR description mentions the specific case goodbye = strawberry.field(resolver=goodbye), where the resolver in cls.__dict__ is overwritten and we fall back to MissingReturnAnnotationError. Please add a dedicated test for this scenario to guard against regressions: define a type where goodbye is first declared as an unannotated function, then overwritten via goodbye = strawberry.field(resolver=goodbye), and assert that MissingReturnAnnotationError (with the expected message) is raised.

Comment on lines 131 to +138
@pytest.mark.raises_strawberry_exception(
MissingReturnAnnotationError,
match='Return annotation missing for field "goodbye", did you forget to add it?',
MissingFieldAnnotationError,
match=(
'Unable to determine the type of field "goodbye". Either annotate it '
"directly, or provide a typed resolver using @strawberry.field."
),
)
def test_raises_error_when_return_annotation_missing_resolver():
def test_raises_field_error_when_using_untyped_explicit_resolver():
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): Add/keep a dedicated test ensuring the decorator-style resolver without return annotation still raises MissingReturnAnnotationError.

This change now tests the strawberry.field(resolver=fn) path and expects MissingFieldAnnotationError. To also cover the decorator path introduced in _check_field_annotations, please add a test for the @strawberry.field decorator with an untyped resolver and no field annotation, asserting that MissingReturnAnnotationError is raised. If that test already exists, consider co-locating or briefly documenting it to clarify the distinction between the two behaviors.

Suggested implementation:

@pytest.mark.raises_strawberry_exception(
    MissingFieldAnnotationError,
    match=(
        'Unable to determine the type of field "goodbye". Either annotate it '
        "directly, or provide a typed resolver using @strawberry.field."
    ),
)
def test_raises_field_error_when_using_untyped_explicit_resolver():
    @strawberry.type
    class Query2:
        def adios(self):


@pytest.mark.raises_strawberry_exception(
    MissingReturnAnnotationError,
    match='Return annotation missing for field "hola", did you forget to add it?',
)
def test_raises_error_when_using_untyped_decorator_resolver():
    @strawberry.type
    class Query:
        @strawberry.field
        def hola(self):
            return "hola"

    strawberry.Schema(query=Query)

If this file does not already import MissingReturnAnnotationError, add it to the existing imports at the top of tests/fields/test_resolvers.py, alongside the other Strawberry exceptions.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 12, 2026

Greptile Summary

This PR fixes a misleading error type when strawberry.field(resolver=fn) is used without a field type annotation and the resolver also lacks a return type. Instead of MissingReturnAnnotationError (which points users toward annotating the resolver), it now raises MissingFieldAnnotationError (which correctly directs them to annotate the field directly).

The fix uses two heuristics in _check_field_annotations: whether the resolver function still exists as a separate entry in cls.__dict__, and whether the field name differs from the resolver's __name__. The PR acknowledges a known limitation — when the resolver function has the exact same name as the field and overwrites it in the class body, the patterns remain indistinguishable and MissingReturnAnnotationError is still raised.

Confidence Score: 5/5

Safe to merge; both findings are P2 style/coverage suggestions that do not block correct behavior.

The core logic is correct for all described use cases, the known limitation is documented, and the existing test change validates the primary scenario. The two P2 comments (missing test coverage for edge cases, and callable() robustness) are improvements but do not affect correctness on supported use cases.

tests/fields/test_resolvers.py could benefit from additional test cases for the second heuristic branch and the known-limitation scenario.

Important Files Changed

Filename Overview
strawberry/types/object_type.py Adds two-heuristic check to distinguish @strawberry.field decorator from strawberry.field(resolver=fn) assignment, raising MissingFieldAnnotationError for the latter; callable() filter has a minor Python-version edge case with staticmethod/classmethod.
tests/fields/test_resolvers.py Updates one test to expect MissingFieldAnnotationError; missing coverage for external-function-with-different-name branch and the documented same-name-overwrite known limitation.
RELEASE.md Adds patch release note describing the error-type improvement; acceptable for a changelog entry.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Field has no type annotation\nand has a resolver] --> B{resolver.type_annotation\nis None?}
    B -- No --> C[Copy resolver return type\nto cls_annotations]
    B -- Yes --> D{resolver_func present\nin cls.__dict__\nunder a different key?}
    D -- Yes --> E[raise MissingFieldAnnotationError\nAnnotate the field directly]
    D -- No --> F{field_name !=\nresolver.name?}
    F -- Yes --> E
    F -- No --> G[raise MissingReturnAnnotationError\nAnnotate the resolver]
    G -.->|Known limitation| H["goodbye = strawberry.field(resolver=goodbye)\nindistinguishable from @strawberry.field"]
Loading

Comments Outside Diff (1)

  1. tests/fields/test_resolvers.py, line 138-144 (link)

    P2 Missing test coverage for both detection branches

    The single test exercises the case where resolver_defined_separately is True (resolver function is still visible in cls.__dict__ under a different key). The second branch — field_name != field_.base_resolver.name with an external function (defined outside the class) — has no direct test and neither does the documented "known limitation" case (same-name overwrite that should still raise MissingReturnAnnotationError).

    Adding these two cases would anchor the intended semantics and protect against future regressions:

    # Branch 2: external function with a different name raises MissingFieldAnnotationError
    def adios(self):
        return -1
    
    @strawberry.type
    class Query3:
        goodbye = strawberry.field(resolver=adios)
        # resolver_defined_separately=False, but field_name != resolver.name
    
    # Known-limitation: same-name overwrite still raises MissingReturnAnnotationError
    @strawberry.type
    class Query4:
        def goodbye(self):
            return -1
        goodbye = strawberry.field(resolver=goodbye)

Reviews (1): Last reviewed commit: "Fix error type when field with resolver=..." | Re-trigger Greptile

Comment thread strawberry/types/object_type.py Outdated
Comment on lines +86 to +92
resolver_defined_separately = any(
v is resolver_func
for k, v in cls.__dict__.items()
if k != field_name
and callable(v)
and not isinstance(v, StrawberryField)
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 callable() filter may exclude staticmethod/classmethod wrappers (Python < 3.10)

staticmethod and classmethod objects are only callable starting from Python 3.10. On Python 3.9, if a user passes a staticmethod resolver under a different field name, callable(v) returns False for that entry, so resolver_defined_separately will be False. The fallback field_name != field_.base_resolver.name condition would still rescue the case if the names differ, but it adds fragility. Dropping the callable() guard entirely (the identity check v is resolver_func is already specific enough) would be safer.

Suggested change
resolver_defined_separately = any(
v is resolver_func
for k, v in cls.__dict__.items()
if k != field_name
and callable(v)
and not isinstance(v, StrawberryField)
)
resolver_defined_separately = any(
v is resolver_func
for k, v in cls.__dict__.items()
if k != field_name
and not isinstance(v, StrawberryField)
)

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 12, 2026

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing youngjaekwon:fix/missing-field-annotation-error (8a005de) with main (056efc0)

Open in CodSpeed

@youngjaekwon
Copy link
Copy Markdown
Contributor Author

@sourcery-ai review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
Copy Markdown
Member

@bellini666 bellini666 left a comment

Choose a reason for hiding this comment

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

Thank you! :)

@bellini666 bellini666 merged commit 8412dcc into strawberry-graphql:main Apr 25, 2026
77 checks passed
@botberry
Copy link
Copy Markdown
Member

This PR was published as 0.315.1. Thank you for contributing!

@youngjaekwon youngjaekwon deleted the fix/missing-field-annotation-error branch April 26, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants