diff --git a/RELEASE.md b/RELEASE.md
new file mode 100644
index 0000000000..79d7caa328
--- /dev/null
+++ b/RELEASE.md
@@ -0,0 +1,12 @@
+Release type: minor
+
+This release changes the `strawberry.Maybe` type to provide a more consistent and intuitive API for handling optional fields in GraphQL inputs.
+
+**Breaking Change**: The `Maybe` type definition has been changed from `Union[Some[Union[T, None]], None]` to `Union[Some[T], None]`. This means:
+
+- `Maybe[str]` now only accepts string values or absent fields (refuses explicit null)
+- `Maybe[str | None]` accepts strings, null, or absent fields (maintains previous behavior)
+
+This provides a cleaner API where `if field is not None` consistently means "field was provided" for all Maybe fields. A codemod is available to automatically migrate your code: `strawberry upgrade maybe-optional`
+
+See the [breaking changes documentation](https://strawberry.rocks/docs/breaking-changes/0.279.0) for migration details.
diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md
index 9a0b946c60..8827009f06 100644
--- a/docs/breaking-changes.md
+++ b/docs/breaking-changes.md
@@ -4,6 +4,7 @@ title: List of breaking changes and deprecations
# List of breaking changes and deprecations
+- [Version 0.279.0 - 19 August 2025](./breaking-changes/0.279.0.md)
- [Version 0.268.0 - 10 May 2025](./breaking-changes/0.268.0.md)
- [Version 0.249.0 - 18 November 2024](./breaking-changes/0.249.0.md)
- [Version 0.243.0 - 25 September 2024](./breaking-changes/0.243.0.md)
diff --git a/docs/breaking-changes/0.279.0.md b/docs/breaking-changes/0.279.0.md
new file mode 100644
index 0000000000..d1a530e8ae
--- /dev/null
+++ b/docs/breaking-changes/0.279.0.md
@@ -0,0 +1,136 @@
+---
+title: 0.279.0 Breaking Changes
+slug: breaking-changes/0.279.0
+---
+
+# v0.279.0 Breaking Changes
+
+This release changes the `strawberry.Maybe` type definition to provide a more
+consistent and intuitive API for handling optional fields.
+
+## What Changed
+
+The `Maybe` type definition has been changed from:
+
+```python
+Maybe: TypeAlias = Union[Some[Union[T, None]], None]
+```
+
+to:
+
+```python
+Maybe: TypeAlias = Union[Some[T], None]
+```
+
+## Impact on Your Code
+
+### Type Annotations
+
+If you were using `Maybe[T]` and expecting to handle explicit `null` values, you
+now need to explicitly declare this with `Maybe[T | None]`:
+
+```python
+# Before (0.278.0 and earlier)
+field: strawberry.Maybe[str] # Could handle Some(None)
+
+# After (0.279.0+)
+field: strawberry.Maybe[str] # Only handles Some("value") or None (absent)
+field: strawberry.Maybe[str | None] # Handles Some("value"), Some(None), or None
+```
+
+### Runtime Behavior
+
+The runtime behavior changes to provide more consistent field checking:
+
+- `Maybe[str]` now represents "field present with non-null value" or "field
+ absent"
+- `Maybe[str | None]` represents "field present with value", "field present but
+ null", or "field absent"
+
+This means `Maybe[str]` can no longer receive explicit `null` values - they will
+cause a validation error.
+
+### Consistent Field Checking
+
+This change provides a single, consistent way to check if a field was provided,
+regardless of whether the field allows null values:
+
+```python
+@strawberry.input
+class UpdateUserInput:
+ # Can be provided with a value or not provided at all
+ name: strawberry.Maybe[str]
+
+ # Can be provided with a value, provided as null, or not provided at all
+ phone: strawberry.Maybe[str | None]
+
+
+@strawberry.mutation
+def update_user(input: UpdateUserInput) -> User:
+ # Same checking pattern for both fields
+ if input.name is not None: # Field was provided
+ user.name = input.name.value # Type checker knows this is str
+
+ if input.phone is not None: # Field was provided
+ user.phone = input.phone.value # Type checker knows this is str | None
+```
+
+The key benefit is that `if field is not None` now consistently means "field was
+provided" for all Maybe fields.
+
+## Migration
+
+### Automatic Migration
+
+Strawberry provides a codemod to automatically update your code:
+
+```bash
+strawberry upgrade maybe-optional
+```
+
+The codemod will automatically convert `Maybe[T]` to `Maybe[T | None]` to
+maintain the previous behavior.
+
+### Manual Migration
+
+Review your `Maybe` usage and decide whether you need the union type:
+
+```python
+# If you only need "present" vs "absent" (most common case)
+field: strawberry.Maybe[str]
+
+# If you need "present with value", "present but null", and "absent"
+field: strawberry.Maybe[str | None]
+```
+
+## Why This Change?
+
+After extensive discussion, we decided that changing `Maybe` to `Some[T] | None`
+(instead of `Some[T | None] | None`) provides the best developer experience
+because:
+
+1. **Consistent field checking**: You can always use `if field is not None:` to
+ check if a field was provided, regardless of whether the field allows null
+ values
+
+2. **Preserves existing behavior**: You can still get the previous behavior by
+ using `Maybe[T | None]` when you need to handle explicit nulls
+
+3. **Better type safety**: `Maybe[str]` now properly indicates that the field
+ cannot be null, while `Maybe[str | None]` explicitly allows nulls
+
+4. **Cleaner API**: There's now "one true way" to check if a field was provided,
+ making the API more intuitive
+
+The new approach provides both simplicity for common cases and flexibility for
+complex scenarios where null handling is needed.
+
+## Need Help?
+
+- See the [Maybe documentation](../types/maybe.md) for comprehensive usage
+ examples
+- Check the
+ [migration guide](../types/maybe.md#migration-from-previous-versions) for
+ detailed migration instructions
+- If you encounter issues, please
+ [report them on GitHub](https://github.com/strawberry-graphql/strawberry/issues)
diff --git a/docs/types/input-types.md b/docs/types/input-types.md
index 6c3125ee32..2d63baf656 100644
--- a/docs/types/input-types.md
+++ b/docs/types/input-types.md
@@ -84,42 +84,10 @@ type Point2D {
-When you need to distinguish between a field being set to `null` or its value
-and the field being absent, optional arguments would not suffice. In this case
-you can use `strawberry.Maybe` like so:
-
-
-
-```python
-import strawberry
-from typing import Optional
-
-
-@strawberry.input
-class UpdateUserInput:
- name: str | None = None
- phone: strawberry.Maybe[str]
-
-
-@strawberry.type
-class Mutation:
- def update_user(self, user_id: strawberry.ID, input: UpdateUserInput) -> User:
- if name := input.name:
- ... # update name...
- if input.phone:
- phone = input.phone.value # can be str | None
- ... # update phone...
-```
-
-```graphql
-type UpdateUserInput {
- name: String = null
- phone: String
-
-}
-```
-
-
+When you need to distinguish between a field being set to `null` versus being
+completely absent (common in update operations), you can use `strawberry.Maybe`.
+See the [Maybe documentation](./maybe.md) for comprehensive examples and usage
+patterns.
## API
@@ -162,6 +130,14 @@ input SearchBy @oneOf {
+
+
+OneOf inputs use `strawberry.Maybe` to distinguish between fields that are
+explicitly not provided versus those that might be set to null. See the
+[Maybe documentation](./maybe.md) for more details on this usage pattern.
+
+
+
## Deprecating fields
Fields can be deprecated using the argument `deprecation_reason`.
@@ -188,7 +164,7 @@ class Point2D:
z: Optional[float] = strawberry.field(
deprecation_reason="3D coordinates are deprecated"
)
- label: strawberry.Maybe[str]
+ label: Optional[str] = None
```
```graphql
@@ -196,7 +172,7 @@ input Point2D {
x: Float!
y: Float!
z: Float @deprecated(reason: "3D coordinates are deprecated")
- label: String
+ label: String = null
}
```
diff --git a/docs/types/maybe.md b/docs/types/maybe.md
new file mode 100644
index 0000000000..13b8678791
--- /dev/null
+++ b/docs/types/maybe.md
@@ -0,0 +1,215 @@
+---
+title: Maybe
+---
+
+# Maybe
+
+In GraphQL, there's an important distinction between a field that is `null` and
+a field that is completely absent from the input. Strawberry's `Maybe` type
+allows you to differentiate between these states:
+
+For `Maybe[str]`:
+
+1. **Field present with a value**: `Some("hello")`
+2. **Field completely absent**: `None`
+
+For `Maybe[str | None]` (when you need to handle explicit nulls):
+
+1. **Field present with a value**: `Some("hello")`
+2. **Field present but explicitly null**: `Some(None)`
+3. **Field completely absent**: `None`
+
+This is particularly useful for update operations where you need to distinguish
+between "set this field to null" and "don't change this field at all".
+
+## What problem does `strawberry.Maybe` solve?
+
+Consider this common scenario: you have a user profile with an optional phone
+number, and you want to provide an update mutation. With traditional nullable
+types, you can't distinguish between:
+
+- "Set the phone number to null" (remove the phone number)
+- "Don't change the phone number" (leave it as is)
+
+Both would be represented as `phone: null` in your GraphQL mutation.
+
+## Basic Usage
+
+Here's how to use `Maybe` in your Strawberry schema:
+
+```python
+import strawberry
+
+
+@strawberry.input
+class UpdateUserInput:
+ name: str | None = None # Traditional optional field
+ phone: strawberry.Maybe[str | None] # Maybe field
+
+
+@strawberry.type
+class User:
+ name: str
+ phone: str | None
+
+
+@strawberry.type
+class Mutation:
+ @strawberry.mutation
+ def update_user(self, user_id: str, input: UpdateUserInput) -> User:
+ user = get_user(user_id) # Your user retrieval logic
+
+ # Traditional optional field - only update if provided
+ if input.name is not None:
+ user.name = input.name
+
+ # Maybe field - check if field was provided at all
+ if input.phone is not None: # Field was provided
+ user.phone = input.phone.value # Access the actual value
+ # If input.phone is None, the field wasn't provided - no change
+
+ return user
+```
+
+## Understanding Some()
+
+When a `Maybe` field has a value (including `null`), it's wrapped in a `Some()`
+container:
+
+```python
+# Field provided with a string value
+phone = strawberry.Some("555-1234")
+print(phone.value) # "555-1234"
+
+# Field provided with null value
+phone = strawberry.Some(None)
+print(phone.value) # None
+
+# Field not provided at all
+phone = None
+print(phone) # None
+```
+
+## GraphQL Schema
+
+When you use `Maybe` in your schema, it appears as a nullable field in GraphQL:
+
+```python
+@strawberry.input
+class UpdateUserInput:
+ phone: strawberry.Maybe[str | None]
+```
+
+Generates this GraphQL schema:
+
+```graphql
+input UpdateUserInput {
+ phone: String
+}
+```
+
+## Common Patterns
+
+### Input Types for Updates
+
+`Maybe` is most commonly used in input types for update operations:
+
+```python
+@strawberry.input
+class UpdatePostInput:
+ title: strawberry.Maybe[str] # Can be set to new value or omitted
+ content: strawberry.Maybe[str]
+ published: strawberry.Maybe[bool]
+ tags: strawberry.Maybe[list[str] | None] # Can be set, cleared, or omitted
+
+
+@strawberry.type
+class Mutation:
+ @strawberry.mutation
+ def update_post(self, post_id: str, input: UpdatePostInput) -> Post:
+ post = get_post(post_id)
+
+ # Only update fields that were explicitly provided
+ if input.title:
+ post.title = input.title.value
+ if input.content:
+ post.content = input.content.value
+ if input.published:
+ post.published = input.published.value
+ if input.tags:
+ post.tags = input.tags.value # Could be None to clear tags
+
+ return post
+```
+
+## Maybe vs Optional vs Nullable
+
+Understanding the differences between these approaches:
+
+| Type | Python | GraphQL | Absent | Null | Value |
+| ------------------------------- | ---------- | --------- | -------- | ------------- | -------------- |
+| `str` | Required | `String!` | ❌ Error | ❌ Error | ✅ Value |
+| `str \| None` | Optional | `String` | ✅ None | ✅ None | ✅ Value |
+| `strawberry.Maybe[str]` | Maybe | `String` | ✅ None | ❌ Error | ✅ Some(value) |
+| `strawberry.Maybe[str \| None]` | Maybe+Null | `String` | ✅ None | ✅ Some(None) | ✅ Some(value) |
+
+## Best Practices
+
+### When to Use Maybe
+
+Use `Maybe` when you need to distinguish between:
+
+- Field not provided (no change)
+- Field provided with null (clear/remove)
+- Field provided with value (set/update)
+
+Common use cases:
+
+- Update mutations
+- Patch operations
+- Optional filters that need to distinguish between "not filtering" and
+ "filtering by null"
+
+### When to Use Optional Instead
+
+Use regular optional types (`str | None`) when:
+
+- You only need two states: value or null
+- The field absence and null have the same meaning
+- You're defining output types (GraphQL responses)
+
+### Error Handling
+
+Always check if a `Maybe` field was provided before accessing its value:
+
+```python
+# Good
+if input.phone is not None:
+ user.phone = input.phone.value
+
+# Bad - will raise AttributeError if phone is None
+user.phone = input.phone.value
+```
+
+### Helper Functions
+
+You can create helper functions to make Maybe handling cleaner:
+
+```python
+def update_if_provided(obj, field_name: str, maybe_value):
+ """Update object field only if Maybe value was provided."""
+ if maybe_value is not None:
+ setattr(obj, field_name, maybe_value.value)
+
+
+# Usage
+update_if_provided(user, "phone", input.phone)
+update_if_provided(user, "email", input.email)
+```
+
+## Related Types
+
+- [Input Types](./input-types.md) - Using Maybe in input type definitions
+- [Resolvers](./resolvers.md) - Using Maybe in resolver arguments
+- [Union Types](./union.md) - Combining Maybe with union types
+- [Scalars](./scalars.md) - Custom scalar types with Maybe
diff --git a/docs/types/resolvers.md b/docs/types/resolvers.md
index cd49fb5ec1..eb69e360c5 100644
--- a/docs/types/resolvers.md
+++ b/docs/types/resolvers.md
@@ -142,11 +142,7 @@ type Query {
### Optional arguments
-Optional or nullable arguments can be expressed using `Optional`. If you need to
-differentiate between `null` (maps to `None` in Python) and no arguments being
-passed, you can use `strawberry.Maybe`:
-
-
+Optional or nullable arguments can be expressed using `Optional`:
```python
from typing import Optional
@@ -160,50 +156,12 @@ class Query:
if name is None:
return "Hello world!"
return f"Hello {name}!"
-
- @strawberry.field
- def greet(self, name: strawberry.Maybe[str] = None) -> str:
- if name:
- if name.value:
- return f"Hello {name.value}!"
- else:
- return "Name was null!"
- else:
- return "Name was not set!"
-```
-
-```graphql
-type Query {
- hello(name: String = null): String!
- greet(name: String): String!
-}
```
-
-
-Like this you will get the following responses:
-
-
-
-```graphql
-{
- unset: greet
- null: greet(name: null)
- name: greet(name: "Dominique")
-}
-```
-
-```json
-{
- "data": {
- "unset": "Name was not set!",
- "null": "Name was null!",
- "name": "Hello Dominique!"
- }
-}
-```
-
-
+If you need to differentiate between `null` and no arguments being passed (for
+example, distinguishing between "set to null" vs "don't change"), you can use
+`strawberry.Maybe`. See the [Maybe documentation](./maybe.md) for comprehensive
+usage examples and patterns.
### Annotated Arguments
@@ -221,7 +179,7 @@ class Query:
@strawberry.field
def greet(
self,
- name: strawberry.Maybe[str] = None,
+ name: Optional[str] = None,
is_morning: Annotated[
Optional[bool],
strawberry.argument(
diff --git a/strawberry/cli/commands/upgrade/__init__.py b/strawberry/cli/commands/upgrade/__init__.py
index 4c18016f6a..29bffb1616 100644
--- a/strawberry/cli/commands/upgrade/__init__.py
+++ b/strawberry/cli/commands/upgrade/__init__.py
@@ -10,6 +10,7 @@
from strawberry.cli.app import app
from strawberry.codemods.annotated_unions import ConvertUnionToAnnotatedUnion
+from strawberry.codemods.maybe_optional import ConvertMaybeToOptional
from strawberry.codemods.update_imports import UpdateImportsCodemod
from ._run_codemod import run_codemod
@@ -17,6 +18,7 @@
codemods = {
"annotated-union": ConvertUnionToAnnotatedUnion,
"update-imports": UpdateImportsCodemod,
+ "maybe-optional": ConvertMaybeToOptional,
}
diff --git a/strawberry/codemods/__init__.py b/strawberry/codemods/__init__.py
index e69de29bb2..4e77d06ad9 100644
--- a/strawberry/codemods/__init__.py
+++ b/strawberry/codemods/__init__.py
@@ -0,0 +1,9 @@
+from .annotated_unions import ConvertUnionToAnnotatedUnion
+from .maybe_optional import ConvertMaybeToOptional
+from .update_imports import UpdateImportsCodemod
+
+__all__ = [
+ "ConvertMaybeToOptional",
+ "ConvertUnionToAnnotatedUnion",
+ "UpdateImportsCodemod",
+]
diff --git a/strawberry/codemods/maybe_optional.py b/strawberry/codemods/maybe_optional.py
new file mode 100644
index 0000000000..bc37a0956b
--- /dev/null
+++ b/strawberry/codemods/maybe_optional.py
@@ -0,0 +1,118 @@
+from __future__ import annotations
+
+import libcst as cst
+import libcst.matchers as m
+from libcst._nodes.expression import BaseExpression # noqa: TC002
+from libcst.codemod import CodemodContext, VisitorBasedCodemodCommand
+from libcst.codemod.visitors import AddImportsVisitor
+
+
+class ConvertMaybeToOptional(VisitorBasedCodemodCommand):
+ DESCRIPTION: str = (
+ "Converts strawberry.Maybe[T] to strawberry.Maybe[T | None] "
+ "to match the new Maybe type definition"
+ )
+
+ def __init__(
+ self,
+ context: CodemodContext,
+ use_pipe_syntax: bool = True, # Default to pipe syntax for modern Python
+ ) -> None:
+ self.use_pipe_syntax = use_pipe_syntax
+ super().__init__(context)
+
+ @m.leave(
+ m.Subscript(
+ value=m.Attribute(value=m.Name("strawberry"), attr=m.Name("Maybe"))
+ | m.Name("Maybe")
+ )
+ )
+ def leave_maybe_subscript(
+ self, original_node: cst.Subscript, updated_node: cst.Subscript
+ ) -> BaseExpression:
+ # Skip if it's not a strawberry.Maybe or imported Maybe
+ if isinstance(original_node.value, cst.Name):
+ # Check if this is an imported Maybe from strawberry
+ # For now, we'll assume any standalone "Maybe" is from strawberry
+ # In a more robust implementation, we'd track imports
+ pass
+
+ # Get the inner type
+ if isinstance(original_node.slice, (list, tuple)):
+ if len(original_node.slice) != 1:
+ return original_node
+ slice_element = original_node.slice[0]
+ else:
+ slice_element = original_node.slice
+
+ if not isinstance(slice_element, cst.SubscriptElement):
+ return original_node
+
+ if not isinstance(slice_element.slice, cst.Index):
+ return original_node
+
+ inner_type = slice_element.slice.value
+
+ # Check if the inner type already includes None
+ if self._already_includes_none(inner_type):
+ return original_node
+
+ # Create the new union type with None
+ new_type: BaseExpression
+ if self.use_pipe_syntax:
+ new_type = cst.BinaryOperation(
+ left=inner_type,
+ operator=cst.BitOr(
+ whitespace_before=cst.SimpleWhitespace(" "),
+ whitespace_after=cst.SimpleWhitespace(" "),
+ ),
+ right=cst.Name("None"),
+ )
+ else:
+ # Use Union[T, None] syntax
+ AddImportsVisitor.add_needed_import(self.context, "typing", "Union")
+ new_type = cst.Subscript(
+ value=cst.Name("Union"),
+ slice=[
+ cst.SubscriptElement(slice=cst.Index(value=inner_type)),
+ cst.SubscriptElement(slice=cst.Index(value=cst.Name("None"))),
+ ],
+ )
+
+ # Return the updated Maybe[T | None]
+ return updated_node.with_changes(
+ slice=[cst.SubscriptElement(slice=cst.Index(value=new_type))]
+ )
+
+ def _already_includes_none(self, node: BaseExpression) -> bool:
+ """Check if the type already includes None (e.g., T | None or Union[T, None])."""
+ # Check for T | None pattern
+ if isinstance(node, cst.BinaryOperation) and isinstance(
+ node.operator, cst.BitOr
+ ):
+ if isinstance(node.right, cst.Name) and node.right.value == "None":
+ return True
+ # Recursively check left side for chained unions
+ if self._already_includes_none(node.left):
+ return True
+
+ # Check for Union[..., None] pattern
+ if (
+ isinstance(node, cst.Subscript)
+ and isinstance(node.value, cst.Name)
+ and node.value.value == "Union"
+ ):
+ # Handle both list and tuple slice formats
+ slice_elements = (
+ node.slice if isinstance(node.slice, (list, tuple)) else [node.slice]
+ )
+ for element in slice_elements:
+ if (
+ isinstance(element, cst.SubscriptElement)
+ and isinstance(element.slice, cst.Index)
+ and isinstance(element.slice.value, cst.Name)
+ and element.slice.value.value == "None"
+ ):
+ return True
+
+ return False
diff --git a/strawberry/schema/schema.py b/strawberry/schema/schema.py
index 12753e1225..3a8874b4bb 100644
--- a/strawberry/schema/schema.py
+++ b/strawberry/schema/schema.py
@@ -50,6 +50,7 @@
from strawberry.extensions.runner import SchemaExtensionsRunner
from strawberry.printer import print_schema
from strawberry.schema.schema_converter import GraphQLCoreConverter
+from strawberry.schema.validation_rules.maybe_null import MaybeNullValidationRule
from strawberry.schema.validation_rules.one_of import OneOfInputValidationRule
from strawberry.types.base import (
StrawberryObjectDefinition,
@@ -124,6 +125,7 @@ def validate_document(
) -> list[GraphQLError]:
validation_rules = (
*validation_rules,
+ MaybeNullValidationRule,
OneOfInputValidationRule,
)
return validate(
diff --git a/strawberry/schema/schema_converter.py b/strawberry/schema/schema_converter.py
index 70865604d0..68d3864a9e 100644
--- a/strawberry/schema/schema_converter.py
+++ b/strawberry/schema/schema_converter.py
@@ -854,6 +854,13 @@ def from_maybe_optional(
NoneType = type(None)
if type_ is None or type_ is NoneType:
return self.from_type(type_)
+ if isinstance(type_, StrawberryMaybe):
+ # StrawberryMaybe should always generate optional types
+ # because Maybe[T] = Union[Some[T], None] (field can be absent)
+ # But we need to handle the case where of_type is itself optional
+ if isinstance(type_.of_type, StrawberryOptional):
+ return self.from_type(type_.of_type.of_type)
+ return self.from_type(type_.of_type)
if isinstance(type_, StrawberryOptional):
return self.from_type(type_.of_type)
return GraphQLNonNull(self.from_type(type_))
diff --git a/strawberry/schema/validation_rules/maybe_null.py b/strawberry/schema/validation_rules/maybe_null.py
new file mode 100644
index 0000000000..2c7d24fb17
--- /dev/null
+++ b/strawberry/schema/validation_rules/maybe_null.py
@@ -0,0 +1,136 @@
+from typing import Any
+
+from graphql import (
+ ArgumentNode,
+ GraphQLError,
+ GraphQLNamedType,
+ ObjectValueNode,
+ ValidationContext,
+ ValidationRule,
+ get_named_type,
+)
+
+from strawberry.types.base import StrawberryMaybe, StrawberryOptional
+from strawberry.utils.str_converters import to_camel_case
+
+
+class MaybeNullValidationRule(ValidationRule):
+ """Validates that Maybe[T] fields do not receive explicit null values.
+
+ This rule ensures that:
+ - Maybe[T] fields can only be omitted or have non-null values
+ - Maybe[T | None] fields can be omitted, null, or have non-null values
+
+ This provides clear semantics where Maybe[T] means "either present with value or absent"
+ and Maybe[T | None] means "present with value, present but null, or absent".
+ """
+
+ def __init__(self, validation_context: ValidationContext) -> None:
+ super().__init__(validation_context)
+
+ def enter_argument(self, node: ArgumentNode, *_args: Any) -> None:
+ # Check if this is a null value
+ if node.value.kind != "null_value":
+ return
+
+ # Get the argument definition from the schema
+ argument_def = self.context.get_argument()
+ if not argument_def:
+ return
+
+ # Check if this argument corresponds to a Maybe[T] (not Maybe[T | None])
+ # The argument type extensions should contain the Strawberry type info
+ strawberry_arg_info = argument_def.extensions.get("strawberry-definition")
+ if not strawberry_arg_info:
+ return
+
+ # Get the Strawberry type from the argument info
+ field_type = getattr(strawberry_arg_info, "type", None)
+ if not field_type:
+ return
+
+ if isinstance(field_type, StrawberryMaybe) and not isinstance(
+ field_type.of_type, StrawberryOptional
+ ):
+ # This is Maybe[T] - should not accept null values
+ type_name = self._get_type_name(field_type.of_type)
+
+ self.report_error(
+ GraphQLError(
+ f"Expected value of type '{type_name}', found null. "
+ f"Argument '{node.name.value}' of type 'Maybe[{type_name}]' cannot be explicitly set to null. "
+ f"Use 'Maybe[{type_name} | None]' if you need to allow null values.",
+ nodes=[node],
+ )
+ )
+
+ def enter_object_value(self, node: ObjectValueNode, *_args: Any) -> None:
+ # Get the input type for this object
+ input_type = get_named_type(self.context.get_input_type())
+ if not input_type:
+ return
+
+ # Get the Strawberry type definition from extensions
+ strawberry_type = input_type.extensions.get("strawberry-definition")
+ if not strawberry_type:
+ return
+
+ # Check each field in the object for null Maybe[T] violations
+ self.validate_maybe_fields(node, input_type, strawberry_type)
+
+ def validate_maybe_fields(
+ self, node: ObjectValueNode, input_type: GraphQLNamedType, strawberry_type: Any
+ ) -> None:
+ # Create a map of field names to field nodes for easy lookup
+ field_node_map = {field.name.value: field for field in node.fields}
+
+ # Check each field in the Strawberry type definition
+ if not hasattr(strawberry_type, "fields"):
+ return
+
+ for field_def in strawberry_type.fields:
+ # Resolve the actual GraphQL field name using the same logic as NameConverter
+ if field_def.graphql_name is not None:
+ field_name = field_def.graphql_name
+ else:
+ # Apply auto_camel_case conversion if enabled (default behavior)
+ field_name = to_camel_case(field_def.python_name)
+
+ # Check if this field is present in the input and has a null value
+ if field_name in field_node_map:
+ field_node = field_node_map[field_name]
+
+ # Check if this field has a null value
+ if field_node.value.kind == "null_value":
+ # Check if this is a Maybe[T] (not Maybe[T | None])
+ field_type = field_def.type
+ if isinstance(field_type, StrawberryMaybe) and not isinstance(
+ field_type.of_type, StrawberryOptional
+ ):
+ # This is Maybe[T] - should not accept null values
+ type_name = self._get_type_name(field_type.of_type)
+ self.report_error(
+ GraphQLError(
+ f"Expected value of type '{type_name}', found null. "
+ f"Field '{field_name}' of type 'Maybe[{type_name}]' cannot be explicitly set to null. "
+ f"Use 'Maybe[{type_name} | None]' if you need to allow null values.",
+ nodes=[field_node],
+ )
+ )
+
+ def _get_type_name(self, type_: Any) -> str:
+ """Get a readable type name for error messages."""
+ if hasattr(type_, "__name__"):
+ return type_.__name__
+ # Handle Strawberry types that don't have __name__
+ if hasattr(type_, "of_type") and hasattr(type_.of_type, "__name__"):
+ # For StrawberryList, StrawberryOptional, etc.
+ return (
+ f"list[{type_.of_type.__name__}]"
+ if "List" in str(type_.__class__)
+ else type_.of_type.__name__
+ )
+ return str(type_)
+
+
+__all__ = ["MaybeNullValidationRule"]
diff --git a/strawberry/types/arguments.py b/strawberry/types/arguments.py
index 1b4ff94166..496d3ce2d5 100644
--- a/strawberry/types/arguments.py
+++ b/strawberry/types/arguments.py
@@ -191,10 +191,24 @@ def convert_argument(
from strawberry.relay.types import GlobalID
# TODO: move this somewhere else and make it first class
- if isinstance(type_, StrawberryOptional):
+ # Handle StrawberryMaybe first, since it extends StrawberryOptional
+ if isinstance(type_, StrawberryMaybe):
+ # Check if this is Maybe[T | None] (has StrawberryOptional as of_type)
+ if isinstance(type_.of_type, StrawberryOptional):
+ # This is Maybe[T | None] - allows null values
+ res = convert_argument(value, type_.of_type, scalar_registry, config)
+
+ return Some(res)
+
+ # This is Maybe[T] - validation for null values is handled by MaybeNullValidationRule
+ # Convert the value and wrap in Some()
res = convert_argument(value, type_.of_type, scalar_registry, config)
- return Some(res) if isinstance(type_, StrawberryMaybe) else res
+ return Some(res)
+
+ # Handle regular StrawberryOptional (not Maybe)
+ if isinstance(type_, StrawberryOptional):
+ return convert_argument(value, type_.of_type, scalar_registry, config)
if value is None:
return None
diff --git a/strawberry/types/maybe.py b/strawberry/types/maybe.py
index 802c10fe65..a0021218e9 100644
--- a/strawberry/types/maybe.py
+++ b/strawberry/types/maybe.py
@@ -31,7 +31,7 @@ def __bool__(self) -> bool:
if TYPE_CHECKING:
- Maybe: TypeAlias = Union[Some[Union[T, None]], None]
+ Maybe: TypeAlias = Union[Some[T], None]
else:
# we do this trick so we can inspect that at runtime
class Maybe(Generic[T]): ...
diff --git a/tests/codemods/test_maybe_optional.py b/tests/codemods/test_maybe_optional.py
new file mode 100644
index 0000000000..95abb006fc
--- /dev/null
+++ b/tests/codemods/test_maybe_optional.py
@@ -0,0 +1,178 @@
+from libcst.codemod import CodemodTest
+
+from strawberry.codemods.maybe_optional import ConvertMaybeToOptional
+
+
+class TestConvertMaybeToOptional(CodemodTest):
+ TRANSFORM = ConvertMaybeToOptional
+
+ def test_simple_maybe(self) -> None:
+ before = """
+ from strawberry import Maybe
+
+ field: Maybe[str]
+ """
+
+ after = """
+ from strawberry import Maybe
+ from typing import Union
+
+ field: Maybe[Union[str, None]]
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
+
+ def test_simple_maybe_union_syntax(self) -> None:
+ before = """
+ from strawberry import Maybe
+
+ field: Maybe[str]
+ """
+
+ after = """
+ from strawberry import Maybe
+ from typing import Union
+
+ field: Maybe[Union[str, None]]
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
+
+ def test_strawberry_maybe(self) -> None:
+ before = """
+ import strawberry
+
+ field: strawberry.Maybe[int]
+ """
+
+ after = """
+ import strawberry
+ from typing import Union
+
+ field: strawberry.Maybe[Union[int, None]]
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
+
+ def test_nested_type(self) -> None:
+ before = """
+ from strawberry import Maybe
+
+ field: Maybe[List[str]]
+ """
+
+ after = """
+ from strawberry import Maybe
+ from typing import Union
+
+ field: Maybe[Union[List[str], None]]
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
+
+ def test_already_has_none_pipe(self) -> None:
+ before = """
+ from strawberry import Maybe
+
+ field: Maybe[Union[str, None]]
+ """
+
+ after = """
+ from strawberry import Maybe
+
+ field: Maybe[Union[str, None]]
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
+
+ def test_already_has_none_union(self) -> None:
+ before = """
+ from strawberry import Maybe
+ from typing import Union
+
+ field: Maybe[Union[str, None]]
+ """
+
+ after = """
+ from strawberry import Maybe
+ from typing import Union
+
+ field: Maybe[Union[str, None]]
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
+
+ def test_multiple_maybe_fields(self) -> None:
+ before = """
+ from strawberry import Maybe
+
+ @strawberry.type
+ class User:
+ name: Maybe[str]
+ age: Maybe[int]
+ email: Maybe[str]
+ """
+
+ after = """
+ from strawberry import Maybe
+ from typing import Union
+
+ @strawberry.type
+ class User:
+ name: Maybe[Union[str, None]]
+ age: Maybe[Union[int, None]]
+ email: Maybe[Union[str, None]]
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
+
+ def test_function_annotation(self) -> None:
+ before = """
+ from strawberry import Maybe
+
+ def get_user() -> Maybe[User]:
+ return None
+ """
+
+ after = """
+ from strawberry import Maybe
+ from typing import Union
+
+ def get_user() -> Maybe[Union[User, None]]:
+ return None
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
+
+ def test_generic_type(self) -> None:
+ before = """
+ from strawberry import Maybe
+
+ field: Maybe[Dict[str, Any]]
+ """
+
+ after = """
+ from strawberry import Maybe
+ from typing import Union
+
+ field: Maybe[Union[Dict[str, Any], None]]
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
+
+ def test_union_type_inside_maybe(self) -> None:
+ before = """
+ from strawberry import Maybe
+ from typing import Union
+
+ field: Maybe[Union[str, int]]
+ """
+
+ after = """
+ from strawberry import Maybe
+ from typing import Union
+
+ field: Maybe[Union[Union[str, int], None]]
+ """
+
+ self.assertCodemod(before, after, use_pipe_syntax=False)
diff --git a/tests/schema/test_maybe.py b/tests/schema/test_maybe.py
index 9e872414aa..2a82fd7f6d 100644
--- a/tests/schema/test_maybe.py
+++ b/tests/schema/test_maybe.py
@@ -1,5 +1,5 @@
from textwrap import dedent
-from typing import Optional
+from typing import Optional, Union
import pytest
@@ -23,7 +23,7 @@ def user(self) -> User:
@strawberry.input
class UpdateUserInput:
- phone: strawberry.Maybe[str]
+ phone: strawberry.Maybe[Union[str, None]]
@strawberry.type
class Mutation:
@@ -107,7 +107,7 @@ def test_optional_argument_maybe() -> None:
@strawberry.type
class Query:
@strawberry.field
- def hello(self, name: strawberry.Maybe[str] = None) -> str:
+ def hello(self, name: strawberry.Maybe[Union[str, None]] = None) -> str:
if name:
return "None" if name.value is None else name.value
@@ -154,7 +154,7 @@ def hello(self, name: strawberry.Maybe[str] = None) -> str:
def test_maybe_list():
@strawberry.input
class InputData:
- items: strawberry.Maybe[list[str]]
+ items: strawberry.Maybe[Union[list[str], None]]
@strawberry.type
class Query:
@@ -174,3 +174,828 @@ def test(self, data: InputData) -> str:
test(data: InputData!): String!
}"""
)
+
+
+def test_maybe_str_rejects_explicit_nulls():
+ """Test that Maybe[str] correctly rejects null values at Python validation level.
+
+ BEHAVIOR:
+ - Maybe[str] generates 'String' (optional) in GraphQL schema
+ - Rejects null values at Python validation level, not GraphQL level
+ - This allows the GraphQL parser to accept null, but Python validation rejects it
+ """
+
+ @strawberry.input
+ class UpdateInput:
+ name: strawberry.Maybe[str] # Should be optional in schema but reject null
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def update(self, input: UpdateInput) -> str:
+ if input.name is not None:
+ return f"Updated to: {input.name.value}"
+ return "No change"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Generates optional field
+ schema_str = str(schema)
+ assert "name: String" in schema_str
+ assert "name: String!" not in schema_str
+
+ # Test with explicit null fails at Python validation level
+ query = """
+ mutation {
+ update(input: { name: null })
+ }
+ """
+
+ result = schema.execute_sync(query)
+ assert result.errors
+ assert len(result.errors) == 1
+ # The error should mention Python-level validation, not GraphQL schema validation
+ error_message = str(result.errors[0])
+ assert "Expected value of type" in error_message
+ assert "found null" in error_message
+
+
+def test_maybe_str_accepts_valid_values():
+ """Test that Maybe[str] accepts valid string values."""
+
+ @strawberry.input
+ class UpdateInput:
+ name: strawberry.Maybe[str]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def update(self, input: UpdateInput) -> str:
+ if input.name is not None:
+ return f"Updated to: {input.name.value}"
+ return "No change"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test with valid string value should work
+ query = """
+ mutation {
+ update(input: { name: "John" })
+ }
+ """
+
+ result = schema.execute_sync(query)
+ assert not result.errors
+ assert result.data == {"update": "Updated to: John"}
+
+
+def test_maybe_str_handles_absent_fields():
+ """Test that Maybe[str] properly handles absent fields.
+
+ BEHAVIOR:
+ - Absent fields are allowed and result in None value
+ """
+
+ @strawberry.input
+ class UpdateInput:
+ name: strawberry.Maybe[str]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def update(self, input: UpdateInput) -> str:
+ if input.name is not None:
+ return f"Updated to: {input.name.value}"
+ return "No change"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test with absent field should work and return "No change"
+ query = """
+ mutation {
+ update(input: {})
+ }
+ """
+
+ result = schema.execute_sync(query)
+ assert not result.errors
+ assert result.data == {"update": "No change"}
+
+
+def test_maybe_str_error_messages():
+ """Test that Maybe[str] provides error messages when null is rejected."""
+
+ @strawberry.input
+ class UpdateInput:
+ name: strawberry.Maybe[str] # Rejects null at Python validation level
+ phone: strawberry.Maybe[Union[str, None]] # Can accept null
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def update(self, input: UpdateInput) -> str:
+ return "Updated"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test that null to name field produces a validation error
+ query = """
+ mutation {
+ update(input: { name: null, phone: null })
+ }
+ """
+
+ result = schema.execute_sync(query)
+ assert result.errors
+ assert len(result.errors) == 1
+
+ # The error should be related to the name field, not phone field
+ error_message = str(result.errors[0])
+ # Error message from Python validation
+ assert "Expected value of type" in error_message
+ assert "found null" in error_message
+
+
+def test_mixed_maybe_field_behavior():
+ """Test schema with both Maybe[T] and Maybe[T | None] behave differently."""
+
+ @strawberry.input
+ class UpdateUserInput:
+ # Should accept value or absent, reject null
+ username: strawberry.Maybe[str]
+ # Can accept null, value, or absent
+ bio: strawberry.Maybe[Union[str, None]]
+ # Can accept null, value, or absent
+ website: strawberry.Maybe[Union[str, None]]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def update_user(self, input: UpdateUserInput) -> str:
+ result = []
+
+ if input.username is not None:
+ result.append(f"username={input.username.value}")
+ else:
+ result.append("username=unchanged")
+
+ if input.bio is not None:
+ bio_value = (
+ input.bio.value if input.bio.value is not None else "cleared"
+ )
+ result.append(f"bio={bio_value}")
+ else:
+ result.append("bio=unchanged")
+
+ if input.website is not None:
+ website_value = (
+ input.website.value
+ if input.website.value is not None
+ else "cleared"
+ )
+ result.append(f"website={website_value}")
+ else:
+ result.append("website=unchanged")
+
+ return ", ".join(result)
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test 1: Valid values for all fields
+ query1 = """
+ mutation {
+ updateUser(input: {
+ username: "john",
+ bio: "Developer",
+ website: "example.com"
+ })
+ }
+ """
+ result1 = schema.execute_sync(query1)
+ assert not result1.errors
+ assert result1.data == {
+ "updateUser": "username=john, bio=Developer, website=example.com"
+ }
+
+ # Test 2: Null for bio and website should work, but not for username
+ query2 = """
+ mutation {
+ updateUser(input: {
+ username: null,
+ bio: null,
+ website: null
+ })
+ }
+ """
+ result2 = schema.execute_sync(query2)
+ assert result2.errors # Should fail due to username: null
+
+ # Test 3: Valid bio/website nulls without username
+ query3 = """
+ mutation {
+ updateUser(input: {
+ bio: null,
+ website: null
+ })
+ }
+ """
+ result3 = schema.execute_sync(query3)
+ assert not result3.errors
+ assert result3.data == {
+ "updateUser": "username=unchanged, bio=cleared, website=cleared"
+ }
+
+ # Test 4: Absent fields should work
+ query4 = """
+ mutation {
+ updateUser(input: {})
+ }
+ """
+ result4 = schema.execute_sync(query4)
+ assert not result4.errors
+ assert result4.data == {
+ "updateUser": "username=unchanged, bio=unchanged, website=unchanged"
+ }
+
+
+def test_maybe_nested_types():
+ """Test Maybe with nested types like lists."""
+
+ @strawberry.input
+ class UpdateItemsInput:
+ # Cannot accept null list - only valid list or absent
+ tags: strawberry.Maybe[list[str]]
+ # Can accept null, valid list, or absent
+ categories: strawberry.Maybe[Union[list[str], None]]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def update_items(self, input: UpdateItemsInput) -> str:
+ result = []
+
+ if input.tags is not None:
+ result.append(f"tags={input.tags.value}")
+ else:
+ result.append("tags=unchanged")
+
+ if input.categories is not None:
+ cat_value = (
+ input.categories.value
+ if input.categories.value is not None
+ else "cleared"
+ )
+ result.append(f"categories={cat_value}")
+ else:
+ result.append("categories=unchanged")
+
+ return ", ".join(result)
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test 1: Valid lists for both fields
+ query1 = """
+ mutation {
+ updateItems(input: {
+ tags: ["python", "graphql"],
+ categories: ["tech", "web"]
+ })
+ }
+ """
+ result1 = schema.execute_sync(query1)
+ assert not result1.errors
+ assert result1.data == {
+ "updateItems": "tags=['python', 'graphql'], categories=['tech', 'web']"
+ }
+
+ # Test 2: Null categories should work, but null tags should fail
+ query2 = """
+ mutation {
+ updateItems(input: {
+ tags: null,
+ categories: null
+ })
+ }
+ """
+ result2 = schema.execute_sync(query2)
+ assert result2.errors # Should fail due to tags: null
+
+ # Test 3: Valid categories null without tags
+ query3 = """
+ mutation {
+ updateItems(input: {
+ categories: null
+ })
+ }
+ """
+ result3 = schema.execute_sync(query3)
+ assert not result3.errors
+ assert result3.data == {"updateItems": "tags=unchanged, categories=cleared"}
+
+ # Test 4: Empty lists should work for both
+ query4 = """
+ mutation {
+ updateItems(input: {
+ tags: [],
+ categories: []
+ })
+ }
+ """
+ result4 = schema.execute_sync(query4)
+ assert not result4.errors
+ assert result4.data == {"updateItems": "tags=[], categories=[]"}
+
+
+def test_maybe_resolver_arguments():
+ """Test Maybe fields as resolver arguments."""
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def search(
+ self,
+ # Cannot accept null - only value or absent
+ query: strawberry.Maybe[str] = None,
+ # Can accept null, value, or absent
+ filter_by: strawberry.Maybe[Union[str, None]] = None,
+ ) -> str:
+ result = []
+
+ if query is not None:
+ result.append(f"query={query.value}")
+ else:
+ result.append("query=unset")
+
+ if filter_by is not None:
+ filter_value = (
+ filter_by.value if filter_by.value is not None else "cleared"
+ )
+ result.append(f"filter={filter_value}")
+ else:
+ result.append("filter=unset")
+
+ return ", ".join(result)
+
+ schema = strawberry.Schema(query=Query)
+
+ # Test 1: Valid values for both arguments
+ query1 = """
+ query {
+ search(query: "python", filterBy: "category")
+ }
+ """
+ result1 = schema.execute_sync(query1)
+ assert not result1.errors
+ assert result1.data == {"search": "query=python, filter=category"}
+
+ # Test 2: Null filter should work, but null query should fail
+ query2 = """
+ query {
+ search(query: null, filterBy: null)
+ }
+ """
+ result2 = schema.execute_sync(query2)
+ assert result2.errors # Should fail due to query: null
+
+ # Test 3: Valid filter null without query
+ query3 = """
+ query {
+ search(filterBy: null)
+ }
+ """
+ result3 = schema.execute_sync(query3)
+ assert not result3.errors
+ assert result3.data == {"search": "query=unset, filter=cleared"}
+
+ # Test 4: No arguments
+ query4 = """
+ query {
+ search
+ }
+ """
+ result4 = schema.execute_sync(query4)
+ assert not result4.errors
+ assert result4.data == {"search": "query=unset, filter=unset"}
+
+
+def test_maybe_graphql_schema_consistency():
+ """Test GraphQL schema generation for Maybe types.
+
+ BEHAVIOR:
+ - Maybe[str] generates String (optional) - allows absent but rejects null
+ - Maybe[str | None] generates String (optional) - allows absent and null
+ - Both generate the same GraphQL schema but have different validation
+ """
+
+ # Schema with Maybe[str]
+ @strawberry.input
+ class Input1:
+ field: strawberry.Maybe[str]
+
+ @strawberry.type
+ class Query1:
+ @strawberry.field
+ def test(self, input: Input1) -> str:
+ return "test"
+
+ schema1 = strawberry.Schema(query=Query1)
+
+ # Schema with Maybe[str | None]
+ @strawberry.input
+ class Input2:
+ field: strawberry.Maybe[Union[str, None]]
+
+ @strawberry.type
+ class Query2:
+ @strawberry.field
+ def test(self, input: Input2) -> str:
+ return "test"
+
+ schema2 = strawberry.Schema(query=Query2)
+
+ # Document new behavior
+ schema1_str = str(schema1)
+ schema2_str = str(schema2)
+
+ # Both Maybe[str] and Maybe[str | None] generate String (optional)
+ assert "field: String" in schema1_str
+ assert "field: String!" not in schema1_str
+ assert "field: String" in schema2_str
+ assert "field: String!" not in schema2_str
+
+
+def test_maybe_complex_types():
+ """Test Maybe with complex custom types."""
+
+ @strawberry.input
+ class AddressInput:
+ street: str
+ city: str
+ zip_code: Optional[str] = None
+
+ @strawberry.input
+ class UpdateProfileInput:
+ # Cannot accept null address - only valid address or absent
+ address: strawberry.Maybe[AddressInput]
+ # Can accept null, valid address, or absent
+ billing_address: strawberry.Maybe[Union[AddressInput, None]]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def update_profile(self, input: UpdateProfileInput) -> str:
+ result = []
+
+ if input.address is not None:
+ addr = input.address.value
+ result.append(f"address={addr.street}, {addr.city}")
+ else:
+ result.append("address=unchanged")
+
+ if input.billing_address is not None:
+ if input.billing_address.value is not None:
+ addr = input.billing_address.value
+ result.append(f"billing={addr.street}, {addr.city}")
+ else:
+ result.append("billing=cleared")
+ else:
+ result.append("billing=unchanged")
+
+ return ", ".join(result)
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test 1: Valid addresses for both fields
+ query1 = """
+ mutation {
+ updateProfile(input: {
+ address: { street: "123 Main", city: "NYC" },
+ billingAddress: { street: "456 Oak", city: "LA" }
+ })
+ }
+ """
+ result1 = schema.execute_sync(query1)
+ assert not result1.errors
+ assert result1.data == {
+ "updateProfile": "address=123 Main, NYC, billing=456 Oak, LA"
+ }
+
+ # Test 2: Null billing should work, but null address should fail
+ query2 = """
+ mutation {
+ updateProfile(input: {
+ address: null,
+ billingAddress: null
+ })
+ }
+ """
+ result2 = schema.execute_sync(query2)
+ assert result2.errors # Should fail due to address: null
+
+ # Test 3: Valid billing null without address
+ query3 = """
+ mutation {
+ updateProfile(input: {
+ billingAddress: null
+ })
+ }
+ """
+ result3 = schema.execute_sync(query3)
+ assert not result3.errors
+ assert result3.data == {"updateProfile": "address=unchanged, billing=cleared"}
+
+ # Test 4: Absent fields
+ query4 = """
+ mutation {
+ updateProfile(input: {})
+ }
+ """
+ result4 = schema.execute_sync(query4)
+ assert not result4.errors
+ assert result4.data == {"updateProfile": "address=unchanged, billing=unchanged"}
+
+
+def test_maybe_union_with_none_works():
+ """Test that Maybe[T | None] works correctly (this should pass)."""
+
+ @strawberry.input
+ class TestInput:
+ # This should work correctly - can accept value, null, or absent
+ field: strawberry.Maybe[Union[str, None]]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def test(self, input: TestInput) -> str:
+ if input.field is not None:
+ if input.field.value is not None:
+ return f"value={input.field.value}"
+ return "null"
+ return "absent"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Schema should show optional field
+ schema_str = str(schema)
+ assert "field: String" in schema_str
+ assert "field: String!" not in schema_str
+
+ # Test valid value
+ result1 = schema.execute_sync('mutation { test(input: { field: "hello" }) }')
+ assert not result1.errors
+ assert result1.data == {"test": "value=hello"}
+
+ # Test null value
+ result2 = schema.execute_sync("mutation { test(input: { field: null }) }")
+ assert not result2.errors
+ assert result2.data == {"test": "null"}
+
+ # Test absent field
+ result3 = schema.execute_sync("mutation { test(input: {}) }")
+ assert not result3.errors
+ assert result3.data == {"test": "absent"}
+
+
+def test_maybe_behavior_documented():
+ """Document the behavior of Maybe[str] vs Maybe[str | None]."""
+
+ @strawberry.input
+ class CompareInput:
+ # Generates String (optional) but rejects null at Python level
+ required_field: strawberry.Maybe[str]
+ # Generates String (optional) and accepts null
+ optional_field: strawberry.Maybe[Union[str, None]]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def compare(self, input: CompareInput) -> str:
+ return "test"
+
+ schema = strawberry.Schema(query=Query)
+ schema_str = str(schema)
+
+ # Document behavior - both generate optional fields
+ assert "requiredField: String" in schema_str
+ assert "requiredField: String!" not in schema_str
+ assert "optionalField: String" in schema_str
+ assert "optionalField: String!" not in schema_str
+
+
+def test_maybe_schema_generation():
+ """Test schema behavior - both Maybe[T] and Maybe[T | None] generate optional."""
+
+ @strawberry.input
+ class Input1:
+ field: strawberry.Maybe[str]
+
+ @strawberry.input
+ class Input2:
+ field: strawberry.Maybe[Union[str, None]]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def test1(self, input: Input1) -> str:
+ return "test"
+
+ @strawberry.field
+ def test2(self, input: Input2) -> str:
+ return "test"
+
+ schema = strawberry.Schema(query=Query)
+ schema_str = str(schema)
+
+ # Both Maybe[str] and Maybe[str | None] generate String (optional)
+ assert "field: String" in schema_str # Both Input1 and Input2 have optional fields
+ assert "field: String!" not in schema_str # No required fields
+
+
+def test_maybe_validation():
+ """Test that Maybe[T] rejects null at Python validation time, not GraphQL schema time."""
+
+ @strawberry.input
+ class TestInput:
+ field: strawberry.Maybe[
+ str
+ ] # Should be optional in schema but reject null in validation
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def test(self, input: TestInput) -> str:
+ return "test"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Schema should show optional field
+ schema_str = str(schema)
+ assert "field: String" in schema_str
+ assert "field: String!" not in schema_str
+
+ # Null should be rejected during Python validation, not GraphQL parsing
+ result = schema.execute_sync("mutation { test(input: { field: null }) }")
+ assert result.errors
+ # Error should be a validation error, not a GraphQL parsing error
+ error_message = str(result.errors[0])
+ assert "Expected value of type" in error_message
+ assert "found null" in error_message
+
+
+def test_maybe_comprehensive_behavior_comparison():
+ """Comprehensive test comparing Maybe[T] vs Maybe[T | None] behavior."""
+
+ @strawberry.input
+ class ComprehensiveInput:
+ # String (optional) - can be value or absent, but rejects null at Python level
+ strict_field: strawberry.Maybe[str]
+ # String (optional) - can be null, value, or absent
+ flexible_field: strawberry.Maybe[Union[str, None]]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def test_comprehensive(self, input: ComprehensiveInput) -> str:
+ result = []
+
+ # This logic works for both current and intended behavior
+ if input.strict_field is not None:
+ result.append(f"strict={input.strict_field.value}")
+ else:
+ result.append("strict=absent")
+
+ if input.flexible_field is not None:
+ if input.flexible_field.value is not None:
+ result.append(f"flexible={input.flexible_field.value}")
+ else:
+ result.append("flexible=null")
+ else:
+ result.append("flexible=absent")
+
+ return ", ".join(result)
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+ schema_str = str(schema)
+
+ # Document schema generation - both are optional
+ assert "strictField: String" in schema_str
+ assert "strictField: String!" not in schema_str
+ assert "flexibleField: String" in schema_str
+ assert "flexibleField: String!" not in schema_str
+
+ # Test 1: Valid values work for both
+ result1 = schema.execute_sync("""
+ mutation {
+ testComprehensive(input: {
+ strictField: "hello",
+ flexibleField: "world"
+ })
+ }
+ """)
+ assert not result1.errors
+ assert result1.data == {"testComprehensive": "strict=hello, flexible=world"}
+
+ # Test 2: Only flexible field can be null
+ result2 = schema.execute_sync("""
+ mutation {
+ testComprehensive(input: {
+ strictField: "hello",
+ flexibleField: null
+ })
+ }
+ """)
+ assert not result2.errors
+ assert result2.data == {"testComprehensive": "strict=hello, flexible=null"}
+
+ # Test 3: Strict field null causes Python validation error
+ result3 = schema.execute_sync("""
+ mutation {
+ testComprehensive(input: {
+ strictField: null,
+ flexibleField: "world"
+ })
+ }
+ """)
+ assert result3.errors # Python validation error - cannot pass null to Maybe[str]
+
+ # Test 4: Both fields can be omitted now
+ result4 = schema.execute_sync("""
+ mutation {
+ testComprehensive(input: {
+ strictField: "hello"
+ })
+ }
+ """)
+ assert not result4.errors
+ assert result4.data == {"testComprehensive": "strict=hello, flexible=absent"}
+
+ # Test 5: Strict field can now be omitted (both fields optional in schema)
+ result5 = schema.execute_sync("""
+ mutation {
+ testComprehensive(input: {
+ flexibleField: "world"
+ })
+ }
+ """)
+ assert not result5.errors # No error - both fields are optional in schema
+ assert result5.data == {"testComprehensive": "strict=absent, flexible=world"}
diff --git a/tests/schema/test_one_of.py b/tests/schema/test_one_of.py
index 57bee860f6..50e2a860cf 100644
--- a/tests/schema/test_one_of.py
+++ b/tests/schema/test_one_of.py
@@ -8,8 +8,8 @@
@strawberry.input(one_of=True)
class ExampleInputTagged:
- a: strawberry.Maybe[str]
- b: strawberry.Maybe[int]
+ a: strawberry.Maybe[Union[str, None]]
+ b: strawberry.Maybe[Union[int, None]]
@strawberry.type
@@ -210,8 +210,8 @@ def test_works_with_camelcasing():
@strawberry.input(directives=[OneOf()])
class ExampleWithLongerNames:
- a_field: strawberry.Maybe[str]
- b_field: strawberry.Maybe[int]
+ a_field: strawberry.Maybe[Union[str, None]]
+ b_field: strawberry.Maybe[Union[int, None]]
@strawberry.type
class Result:
diff --git a/tests/schema/validation_rules/__init__.py b/tests/schema/validation_rules/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/schema/validation_rules/test_maybe_null.py b/tests/schema/validation_rules/test_maybe_null.py
new file mode 100644
index 0000000000..5a335ced48
--- /dev/null
+++ b/tests/schema/validation_rules/test_maybe_null.py
@@ -0,0 +1,264 @@
+from typing import Union
+
+import strawberry
+
+
+def test_maybe_null_validation_rule_input_fields():
+ """Test MaybeNullValidationRule validates input object fields correctly."""
+
+ @strawberry.input
+ class TestInput:
+ strict_field: strawberry.Maybe[str] # Should reject null
+ flexible_field: strawberry.Maybe[Union[str, None]] # Should allow null
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def test(self, input: TestInput) -> str:
+ return "success"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test 1: Valid values should work
+ result = schema.execute_sync("""
+ mutation {
+ test(input: { strictField: "hello", flexibleField: "world" })
+ }
+ """)
+ assert not result.errors
+ assert result.data == {"test": "success"}
+
+ # Test 2: Flexible field can be null
+ result = schema.execute_sync("""
+ mutation {
+ test(input: { strictField: "hello", flexibleField: null })
+ }
+ """)
+ assert not result.errors
+ assert result.data == {"test": "success"}
+
+ # Test 3: Strict field cannot be null
+ result = schema.execute_sync("""
+ mutation {
+ test(input: { strictField: null, flexibleField: "world" })
+ }
+ """)
+ assert result.errors
+ assert len(result.errors) == 1
+ error = result.errors[0]
+ assert "Expected value of type 'str', found null" in str(error)
+ assert "strictField" in str(error)
+ assert "cannot be explicitly set to null" in str(error)
+ assert "Use 'Maybe[str | None]'" in str(error)
+
+
+def test_maybe_null_validation_rule_resolver_arguments():
+ """Test MaybeNullValidationRule validates resolver arguments correctly."""
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def search(
+ self,
+ query: strawberry.Maybe[str] = None, # Should reject null
+ filter_by: strawberry.Maybe[Union[str, None]] = None, # Should allow null
+ ) -> str:
+ return "success"
+
+ schema = strawberry.Schema(query=Query)
+
+ # Test 1: Valid values should work
+ result = schema.execute_sync("""
+ query {
+ search(query: "hello", filterBy: "world")
+ }
+ """)
+ assert not result.errors
+ assert result.data == {"search": "success"}
+
+ # Test 2: Flexible argument can be null
+ result = schema.execute_sync("""
+ query {
+ search(query: "hello", filterBy: null)
+ }
+ """)
+ assert not result.errors
+ assert result.data == {"search": "success"}
+
+ # Test 3: Strict argument cannot be null
+ result = schema.execute_sync("""
+ query {
+ search(query: null, filterBy: "world")
+ }
+ """)
+ assert result.errors
+ assert len(result.errors) == 1
+ error = result.errors[0]
+ assert "Expected value of type 'str', found null" in str(error)
+ assert "query" in str(error)
+ assert "cannot be explicitly set to null" in str(error)
+ assert "Use 'Maybe[str | None]'" in str(error)
+
+
+def test_maybe_null_validation_rule_multiple_errors():
+ """Test that multiple null violations are all reported."""
+
+ @strawberry.input
+ class TestInput:
+ field1: strawberry.Maybe[str]
+ field2: strawberry.Maybe[int]
+ field3: strawberry.Maybe[Union[str, None]] # This one allows null
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def test(self, input: TestInput) -> str:
+ return "success"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test with multiple nulls - should get multiple errors
+ result = schema.execute_sync("""
+ mutation {
+ test(input: { field1: null, field2: null, field3: null })
+ }
+ """)
+ assert result.errors
+ assert len(result.errors) == 2 # field1 and field2 should fail, field3 should pass
+
+ error_messages = [str(error) for error in result.errors]
+ assert any("field1" in msg for msg in error_messages)
+ assert any("field2" in msg for msg in error_messages)
+ # field3 should NOT generate an error because it allows null
+
+
+def test_maybe_null_validation_rule_nested_input():
+ """Test validation works with nested input objects."""
+
+ @strawberry.input
+ class NestedInput:
+ value: strawberry.Maybe[str]
+
+ @strawberry.input
+ class TestInput:
+ nested: NestedInput
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def test(self, input: TestInput) -> str:
+ return "success"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test with null in nested input
+ result = schema.execute_sync("""
+ mutation {
+ test(input: { nested: { value: null } })
+ }
+ """)
+ assert result.errors
+ assert len(result.errors) == 1
+ error = result.errors[0]
+ assert "Expected value of type 'str', found null" in str(error)
+ assert "value" in str(error)
+
+
+def test_maybe_null_validation_rule_different_types():
+ """Test validation works with different field types."""
+
+ @strawberry.input
+ class TestInput:
+ string_field: strawberry.Maybe[str]
+ int_field: strawberry.Maybe[int]
+ bool_field: strawberry.Maybe[bool]
+ list_field: strawberry.Maybe[list[str]]
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def test(self, input: TestInput) -> str:
+ return "success"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test each field type with null
+ test_cases = [
+ ("stringField", "str"),
+ ("intField", "int"),
+ ("boolField", "bool"),
+ ("listField", "list[str]"),
+ ]
+
+ for field_name, type_name in test_cases:
+ result = schema.execute_sync(f"""
+ mutation {{
+ test(input: {{ {field_name}: null }})
+ }}
+ """)
+ assert result.errors
+ assert len(result.errors) == 1
+ error = result.errors[0]
+ assert f"Expected value of type '{type_name}', found null" in str(error)
+
+
+def test_maybe_null_validation_rule_custom_graphql_names():
+ """Test validation works with custom GraphQL field names."""
+
+ @strawberry.input
+ class TestInput:
+ internal_name: strawberry.Maybe[str] = strawberry.field(name="customName")
+
+ @strawberry.type
+ class Query:
+ @strawberry.field
+ def hello(self) -> str:
+ return "world"
+
+ @strawberry.type
+ class Mutation:
+ @strawberry.mutation
+ def test(self, input: TestInput) -> str:
+ return "success"
+
+ schema = strawberry.Schema(query=Query, mutation=Mutation)
+
+ # Test with custom GraphQL name
+ result = schema.execute_sync("""
+ mutation {
+ test(input: { customName: null })
+ }
+ """)
+ assert result.errors
+ assert len(result.errors) == 1
+ error = result.errors[0]
+ assert "customName" in str(error)
+
+
+# TODO: Add test for auto_camel_case=False configuration
+# This requires accessing the schema configuration from the validation rule context,
+# which needs further investigation of the GraphQL validation context API.
diff --git a/tests/typecheckers/test_maybe.py b/tests/typecheckers/test_maybe.py
index b36195abe8..ee336254ca 100644
--- a/tests/typecheckers/test_maybe.py
+++ b/tests/typecheckers/test_maybe.py
@@ -31,13 +31,13 @@ def test_maybe() -> None:
[
Result(
type="information",
- message='Type of "obj.foobar" is "Some[str | None] | None"',
+ message='Type of "obj.foobar" is "Some[str] | None"',
line=12,
column=13,
),
Result(
type="information",
- message='Type of "obj.foobar" is "Some[str | None]"',
+ message='Type of "obj.foobar" is "Some[str]"',
line=15,
column=17,
),
@@ -48,13 +48,13 @@ def test_maybe() -> None:
[
Result(
type="note",
- message='Revealed type is "Union[strawberry.types.maybe.Some[Union[builtins.str, None]], None]"',
+ message='Revealed type is "Union[strawberry.types.maybe.Some[builtins.str], None]"',
line=12,
column=13,
),
Result(
type="note",
- message='Revealed type is "strawberry.types.maybe.Some[Union[builtins.str, None]]"',
+ message='Revealed type is "strawberry.types.maybe.Some[builtins.str]"',
line=15,
column=17,
),