Skip to content

Type-safe "optional-nullable" fields #3779

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
1 of 3 tasks
nrbnlulu opened this issue Feb 12, 2025 · 18 comments · Fixed by #3791
Open
1 of 3 tasks

Type-safe "optional-nullable" fields #3779

nrbnlulu opened this issue Feb 12, 2025 · 18 comments · Fixed by #3791

Comments

@nrbnlulu
Copy link
Member

nrbnlulu commented Feb 12, 2025

Preface

it is a common practice in strawberry that when your data layer have
an optional field i.e

class Person:
    name: str
    phone: str | None = None

and you want to update it you would use UNSET in the mutation input
in order to check whether this field provided by the client or not like so:

@strawberry.input
class UpdatePersonInput:
    id: strawberry.ID
    name: str| None
    phone: str | None = UNSET

@strawberry.mutation
def update_person(input: UpdatePersonInput) -> Person:
    inst = service.get_person(input.id)
    if name := input.name:
         inst.name = name
    if input.phone is not UNSET:
        inst.phone = input.phone  # ❌ not type safe
    
    service.save(inst)

Note that this is not an optimization rather a business requirement.
if the user wants to nullify the phone it won't be possible other wise
OTOH you might nullify the phone unintentionally.

This approach can cause lots of bugs since you need to remember that you have
used UNSET and to handle this correspondingly.

Since strawberry claims to

Strawberry leverages Python type hints to provide a great developer experience while creating GraphQL Libraries.

it is only natural for us to provide a typesafe way to mitigate this.

Proposal

The Option type.which will require only this minimal implementation

import dataclasses


@dataclasses.dataclass
class Some[T]:
    value: T
    
    def some(self) -> Some[T | None] | None:
      return self
    
@dataclasses.dataclass
class Nothing[T]:
    def some(self) -> Some[T | None] | None:
      return None 

Maybe[T] = Some[T] | Nothing[T]

and this is how you'd use it

@strawberry.input
class UpdatePersonInput:
    id: strawberry.ID
    name: str| None
    phone: Maybe[str | None]

@strawberry.mutation
def update_person(input: UpdatePersonInput) -> Person:
    inst = service.get_person(input.id)
    if name := input.name:
         inst.name = name
    if phone := input.phone.some(): 
        inst.phone = phone.value  # ✅  type safe
    
    service.save(inst)

Currently if you want to know if a field was provided

Backward compat

UNSET can remain as is for existing codebases.
Option would be handled separately.

which Option library should we use?

  1. Don't use any library craft something minimal our own as suggested above.
  2. ** use something existing**

The sad truth is that there are no well-maintained libs in the ecosystem.

Never the less it is not hard to maintain something just for strawberry since the implementation
is rather straight forward and not much features are needed. we can fork either

and just forget about it.

  1. allow users to decide
# before anything

strawberry.register_option_type((MyOptionType, NOTHING))

then strawberry could use that and you could use whatever you want.

  • Core functionality
  • Alteration (enhancement/optimization) of existing feature(s)
  • New behavior
@fruitymedley
Copy link

To clarify, is the following the use case you're interested in?

mutation Update {
    updatePerson(  ## this operation sets phone to null
        input: {
            id: ..., 
            name: ...,
            phone: null
        }
    ) {
        ...
    }
}

vs

mutation Update {
    updatePerson(  ## this operation does not modify phone number
        input: {
            id: ..., 
            name: ...
        }
    ) {
        ...
    }
}

@nrbnlulu
Copy link
Member Author

yep

@Speedy1991
Copy link
Contributor

I think this is a solid proposal. Both Option and Maybe are valid choices, though Maybe seems to be slightly better maintained. However, I’m not entirely sure if this justifies adding a third-party library as a dependency.

For an opt-in approach, I would prefer your solution:

strawberry.register_option_type((MyOptionType, NOTHING))

If this Type is meant to be included directly in Strawberry, I would avoid forking the repository. Instead, I’d suggest copying the relevant MIT-licensed code while ensuring proper attribution, such as adding credits in the form of code comments or including a reference to the original license.

@nrbnlulu
Copy link
Member Author

@patrick91 👀 ?

@patrick91
Copy link
Member

Code sample in pyright playground

from __future__ import annotations
import dataclasses
from typing import Self, reveal_type


@dataclasses.dataclass
class Some[T]:
    value: T
    
    def some(self) -> Self:
      return self
    
@dataclasses.dataclass
class Nothing[T]:
    def some(self) -> None:
      return None 

type Maybe[T] = Some[T | None] | Nothing[T]

def some_func() -> Maybe[str]:
    return Some("a")

a = some_func()

reveal_type(a)

if phone := a.some():
    reveal_type(phone)
    reveal_type(phone.value)
else:
    reveal_type(phone)

@Corentin-Bravo
Copy link

I have met the exact same problem, but I don't understand why you cannot solve it.

@strawberry.input
class UpdatePersonInput:
    id: strawberry.ID
    name: str| None
    phone: str | None | UnsetType = strawberry.field(
        graphql_type=str | None,
        default=UNSET
    )

@strawberry.mutation
def update_person(input: UpdatePersonInput) -> Person:
    inst = service.get_person(input.id)
    if name := input.name:
         inst.name = name
    if not instance(input.phone, UnsetType):
        inst.phone = phone.value
    
    service.save(inst)

The case where you do not want None but either a string or nothing cannot be handled cleanly due to the GraphQL specification (an optional value is a nullable value**), but if your API is used only internally, you can do the same thing:

@strawberry.input
class UpdatePersonInput:
    id: strawberry.ID
    name: str| None
    phone: str  | UnsetType = strawberry.field(
        graphql_type=str | None,
        default=UNSET
      description="This value is optional but not nullable, do not send a null!"
    )

(Arguably you could write custom code to ensure that sending a None would return an appropriate error instead of randomly breaking inside your code)

I find the proposition slightly contrived, as it turns scalar values into objects.

@nrbnlulu
Copy link
Member Author

nrbnlulu commented Feb 23, 2025

@Corentin-Bravo

phone: str | None | UnsetType

can actually solve this, I haven't thought about this really...

Though:

  1. nothing stops you from using UNSET on anything.
    UNSET is UNSET: Any = UnsetType()
    because how we advertised the use of unset up until now which is
phone: str | None = strawberry.UNSET

You could say that we can change the signature to the real type but that would type-checkers would lint
for existing code and would require modifications.

  1. using isinstance is not very pleasant to use IMO. (prob could avoid that with a typegaurd)

We could also think re-introducing the Unset usage which could look like this

type Maybe[T] = UnsetType | None | T

def is_unset[T](val: T | UnsetType) -> TypeGuard[UnsetType]:
    return isinstance(val, UnsetType)


@strawberry.input
class UpdatePersonInput:
    phone: Maybe[str]

def update_person(input_: UpdatePersonInput) -> ...:
       if not strawberry.is_unset(input_.phone)
           update_phone(input_.phone)  

I actually have no oppositions to this.

@ctorrington
Copy link
Contributor

ctorrington commented Mar 16, 2025

Could we achieve the same result with the Maybe class & changing the type of the Unset singleton? Then direct identity checks can continue to be supported & there is no need for helper methods?

from typing import Final

class UnsetType:
    __instance: Optional["UnsetType"] = None

    def __new__(cls: type["UnsetType"]) -> "UnsetType":
        if cls.__instance is None:
            ret = super().__new__(cls)
            cls.__instance = ret
            return ret
        return cls.__instance

    def __str__(self) -> str:
        return ""

    def __repr__(self) -> str:
        return "UNSET"

    def __bool__(self) -> bool:
        return False


~~UNSET: Any = UnsetType()~~
UNSET: Final[UnsetType] = UnsetType()

Would we need to remove the falsy behaviour as well? 🤔 Just thinking if 0 is passed & Unset is falsy, then we would miss that 0 value in some contexts?

This would return 42 instead of 0?

def foo(val: Maybe[int]) -> int:
         return val or 42

Existing code should only break if it was already relying on Any incorrectly?

@nrbnlulu
Copy link
Member Author

nrbnlulu commented Mar 16, 2025

Im not sure what is your suggestion. the approach I took is similar to what @Corentin-Bravo suggested.

  1. AFAICT helper function is unavoidable.
  2. you can still use is not strawberry.UNSET
  3. no existing code will break.

see the Release notes in #3791

import strawberry


@strawberry.type
class User:
    name: str
    phone: str | None


@strawberry.input
class UpdateUserInput:
    name: str
    phone: strawberry.Maybe[str]


@strawberry.type
class Mutation:
    def update_user(self, info, input: UpdateUserInput) -> User:
        reveal_type(input.phone)  # str | None | UnsetType
        if strawberry.exists(input.phone):
            reveal_type(input.phone)  # str | None
            update_user_phone(input.phone)

        return User(name=input.name, phone=input.phone)

You can also use strawberry.Maybe as a field argument like so

import strawberry


@strawberry.field
def filter_users(self, phone: strawberry.Maybe[str] = strawberry.UNSET) -> list[User]:
    if strawberry.exists(phone):
        return filter_users_by_phone(phone)
    return get_all_users()

@ctorrington
Copy link
Contributor

I do not understand why helper functions are required if it is possible to use direct identity checks with Python's is operator. The direct identity checks are already implemented & documented in the documentation.

@strawberry.field
def greet(self, name: Optional[str] = strawberry.UNSET) -> str:
    if name is strawberry.UNSET:
        return "Name was not set!"
    if name is None:
        return "Name was null!"
    return f"Hello {name}!"

Code will need to be changed to allow for type safety with the helper functions. I understand that changing strawberry's Unset singleton from Any to Final[UnsetType] will cause some existing code to be linted with errors. However, that will only occur if developers have been using Unset in a non-type-safe way. I see that as beneficial.

Including type None in type Maybe with generic type T & type UnsetType conflates the meaning of type Maybe. Type Maybe usually represents the absence or presence of a value. Adding type None creates an unnecessary third state. The field can be made optional if type None is required.

For optional nullable fields I would suggest:

type Maybe[T] = T | UnsetType

field: Maybe[str] | None

This allows type Maybe to handle whether a field was provided or not, while allowing the field to be nullable independently.

In another issue, we should look at the Falsy behaviour of type UnsetType, which could cause silent erros with Falsy values like 0.

@nrbnlulu
Copy link
Member Author

nrbnlulu commented Mar 16, 2025

Code will need to be changed to allow for type safety with the helper functions. I understand that changing strawberry's Unset singleton from Any to Final[UnsetType] will cause some existing code to be linted with errors. However, that will only occur if developers have been using Unset in a non-type-safe way. I see that as beneficial.

hmm are you certain about that? @patrick91 WDYT?

Including type None in type Maybe with generic type T & type UnsetType conflates the meaning of type Maybe. Type Maybe usually represents the absence or presence of a value. Adding type None creates an unnecessary third state. The field can be made optional if type None is required.

AFAICT there is no usecase for Maybe[T] without a None. It was added merely for convenience.

@Corentin-Bravo
Copy link

Corentin-Bravo commented Mar 17, 2025

I think I have expressed all possible cases below

In python:

@strawberry.input()
class TestStringInput:
    string: str
    optional_string: str = "abc"
    optional_string_with_none_default: str | None = None
    nullable_string: str | None
    optional_string_nullable_with_default: str | None = "abc"
    optional_string_nullable_without_default: str | None | UnsetType = UNSET

In GraphQL:

input TestStringInput {
    string: String!
    optionalString: String! = "abc"
    optionalStringWithNoneDefault: String
    nullableString: String
    optionalStringNullableWithDefault: String = "abc"
    optionalStringNullableWithoutDefault: String
}

The graphqQL schema does not know None nor UNSET, so effectively:

  • An optional nullable
  • Nullable
  • Optional (defaulting to None)

Are all equal for the schema.

Therefore, I believe we should not try to be smarter than the underlying GraphQL schema.

Maybe[T] should include None, because the schema will allow it.

While I agree that it would be better to be able to mark things as optional and nullable separately, GraphQL itself does not allow it.

The proposed PR is just syntaxic sugar, and I think it cannot be more than that.

@ctorrington
Copy link
Contributor

ctorrington commented Mar 17, 2025

I understand that GraphQL's schema cannot distinguish between the 3 types above, however, I do not believe that to be a reason to limit Strawberry's type safety when Python can distinguish between the 3. The above examples show the problem & the need for better type safety. I do not believe that limiting a capable type system is the correct approach.

A reason for Maybe[T] = UNSET | T:

@strawberry.input
class UpdateUserInput:
    id: strawberry.ID
    name: Maybe[str | None]        # Can be UNSET, str or None
    bio: Maybe[str]                # Can be UNSET, str, but CANNOT be None

@strawberry.mutation
def update_user(self, input: UpdateUserInput) -> User:
    user = get_user(input.id)

    if input.name is not UNSET:
        user.name = input.name     # Type checker knows type is str or None

    if input.bio is not UNSET:
        user.bio = input.bio       # Type checker knows type is str & cannot be None

    return user

A problem with Maybe[T] = UNSET | T | None:

@strawberry.input
class UpdateUserInput:
    id: strawberry.ID
    name: Maybe[str]                # Can be UNSET, str or None
    bio: Maybe[str]                 # Can be UNSET, str or None

@strawberry.mutation
def update_user(self, input: UpdateUserInput) -> User:
    user = get_user(input.id)

    if input.name is not UNSET:
        user.name = input.name      # Could be str or None

    if input.bio is not UNSET:
        user.bio = input.bio        # Could be str or None

    return user

Maybe[T] = UNSET | T allows for explicitly defining which fields can accept null values and which cannot. In the example above, the field Bio is optional (can be omitted), but cannot be set to null. This helps prevent accidentally nulling none-nullabe fields, which is not enforced by GraphQL at the schema level.

We can gradually adopt the type annotation for UNSET: Final[UnsetType] instead of [UNSET: Any]to allow for existing code to adapt, but I believe thatMaybe` should allow for separating presence/absence from nullability from the beginning.

@Corentin-Bravo
Copy link

A reason for Maybe[T] = UNSET | T:

@strawberry.input
class UpdateUserInput:
id: strawberry.ID
name: Maybe[str | None] # Can be UNSET, str or None
bio: Maybe[str] # Can be UNSET, str, but CANNOT be None

The graphql representation of bio will have to be String. Which allows explicit null. Except you will have logic on the server that actually prevents None (either that or you will have bugs).

I believe it's better to lose a bit of consistency in the code (using None as "unset" when it cannot be a value, but use UNSET when None is a valid value) rather than having a graphql schema that lies.

@Speedy1991
Copy link
Contributor

lint for existing code and would require modifications.

I think this is absolutly valid. This is the reason why type-checkers exist.

my2c

@ctorrington
Copy link
Contributor

I believe we're seeing this issue appear in issue #3785 where the 'nullability' of OneOf input fields is being misunderstood.

From GraphQL:

all the fields of a OneOf input type must be declared as if they were nullable.

Strawberry's OneOf implementation is below:

To define a one of input type you can use the one_of flag on the @strawberry.input decorator:

import strawberry

@strawberry.input(one_of=True)
class SearchBy:
    name: str | None = strawberry.UNSET
    email: str | None = strawberry.UNSET

Which creates the correct GraphQL schema for OneOf inputs:

input SearchBy @oneOf {
  name: String
  email: String
}

However, null is not typed in GraphQL. Which means that type None cannot be passed to those SearchBy fields. Those fields either need a none-null type or they must be omitted. This is where the confusion arises in issue #3785 as null is passed to a OneOf field & strawberry correctly returns an error.

In this case, type None means may be omitted but not nullable.

I believe this supports type Maybe[T] = UNSET | T as the correct approach. It makes the distinction between "field not provided" from "field is null" explicit, while type Maybe[T] = UNSET | T | None conflates the ideas. Rejecting it because of schema accuracy while accepting GraphQL's OneOf directive seems inconsistent. It would help prevent the type of confusion seen in issue #3785 where field constraints are misunderstood because the difference between nullability & omitability is not clear.

patrick91 added a commit that referenced this issue May 6, 2025
* feat: Type-safe "optional-nullable" fields #3779

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Rename `isnt_unset` to `not_unset`; inffer use of Maybe and inject UNSET automatically.

* add docs

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* feat: enhance Maybe type handling and add typecheck tests

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* support maybe in field arguments. update docs.

* minor doc update

* refactor: rename `not_unset` to `exists`

* refactor: update type annotations to use Union for optional fields

* py 3.9 compat

* typing lints

* migrate to Maybe & Some

* wip: migrate to Maybe and Some

* handle maybe arguments on field resolvers properly. (don't use UNSET)

* fix mypy

* nit

* Update release notes, add tests for match

* resolve nits

* Address review

* Remove test for 3.10+

* Add tweet file

* Fix typecheckers

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Patrick Arminio <[email protected]>
@Mapiarz
Copy link

Mapiarz commented May 12, 2025

Hey! I stumbled upon this due to the recent release. I was a bit confused at first as to why the new Maybe boils down to Maybe[T] = UNSET | T | None. The docs don't give any rationale for this so I had to dig a bit deeper and found this and related conversations.

I think @ctorrington makes a strong argument.

@Corentin-Bravo said that:

I believe it's better to lose a bit of consistency in the code (using None as "unset" when it cannot be a value, but use UNSET when None is a valid value) rather than having a graphql schema that lies.

But in practice, with what tools we have now, I have confusing inconsistency in python code AND I have a schema that lies anyway.

I can't speak for everyone, but I come from https://github.com/strawberry-graphql/strawberry-django. For many, it will actually be a very common scenario to require T | UNSET in their update mutations. That's the case for vast majority of fields on my update mutations. But of course, due to the graphql spec limitations which we all agree suck, the schema accepts None. For most django users, myself included, that will lead to some validation exception. Either django validation will kick in or DB constraints. None will never become a valid input to this mutation. And that is happening everywhere.

@ctorrington found other examples of lies already built into strawberry. We will not escape from this.

In fact, I'd much rather have a built in mechanism and fail early during schema validation or something than have to rely on Django/DB to validate (or having to write custom code myself).

So why not give us, python devs, tools to have explicit clarity and consistency? What @ctorrington proposed looks solid.

@patrick91 is there any chance to revisit this?

@patrick91 patrick91 reopened this May 12, 2025
@patrick91
Copy link
Member

I'll review this with @bellini666 tomorrow 😊

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 a pull request may close this issue.

7 participants