Search code examples
pythonpython-3.xpython-typingpython-dataclasses

Building and using new data types in Python


How do you build custom data types in Python (similar to how you define interface in TypeScript)?

Let's say for example I have a class which accepts two input arguments. Both are optional, but one of them is required to initialize the class.

I can do something like:

@dataclass
class Interface:
  a: Optional[str] = None
  b: Optional[int] = None

  def __post_init(self):
    if self.a is None and self.b is None:
      raise ValueError('one of a or b is required')

Now I want to define a function which accepts an argument of the type Interface:

def func(i: Interface) -> Interface: 
  return i

Of course, this works if I invoke:

func(Interface('abc')) # works
func(Interface(2))     # this does not work! 

Is it possible to call the function similar to how you would initialize the Interface class? E.g.:

func('abc')   # should work
func(2)       # should also work
func([1, 2])  # should report a type issue

The reason for doing this is that I have to make extensive use of the Interface "type", and would like to avoid writing the types everywhere.


Solution

  • Your current definition still requires the arguments to match properly with the generated __init__, which has a form like:

    def __init__(self, a: Optional[str] = None, b: Optional[int] = None):
        ...
    

    When you do func('abc') it works because it's like invoking __init__ with 'abc' for a and None for b, so the types match. When you do func(2), it's still trying to pass 2 to a, not b, so the types don't match and you get a (correct) error.

    As currently, you must replace func(2) with func(b=2) or func(None, 2).

    If you want to do it with a single positional argument that can be either, you either:

    1. Have to store both in the same field, e.g.:

      @dataclass
      class Interface:
          a: Optional[Union[str, int]] = None  # On modern Python, a: str | int | None = None would work
      
      # No postinit needed
      

      with the downside being that now you don't know which is stored there, and have to check each time, or...

    2. Manually write __init__ instead of relying on dataclasses to generate it, so it accepts a single value and stores it in the correct field:

      @dataclass
      class Interface:
          a: Optional[str] = None
          b: Optional[int] = None
      
          def __init__(self, ab: Optional[Union[str, int]] = None):
          # Or cleaner 3.10+ syntax:
          def __init__(self, ab: str | int | None = None):
              self.a = self.b = None
              if isinstance(ab, str):
                  self.a = ab
              elif isinstance(ab, int):
                  self.b = ab
              else:
                  raise TypeError(f"Only str or int accepted, received {type(ab).__name__}")
      
      # No postinit needed