Building and using new data types in Python

Question:

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.

Asked By: besi

||

Answers:

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
    
Answered By: ShadowRanger
from dataclasses import dataclass
from typing import Optional


@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')

First issue, is you have your __post_init__ written as __post_init. If it is not written correctly, then it won’t run after the class is instantiated.

Secondly, I am not sure the purpose of the def func, so I have ignored it for my examples.

Thirdly, the way the dataclass works is when instantiated, the first parameter is mapped to the first property in the class, unless you override it with explicit parameter names. Therefore in order to instantiate a class with just an int, you should do Interface(None, 3) or Interface(b=3).

Therefore this is what will happen:

Interface("abc") # works with just a string

Interface("abc", 3) # works with both

Interface(b=3) # works with just an int

Interface() # raises an error as expected

Interface(None, None) # raises an error as expected

Interface(a=None) # raises an error as expected

Interface(b=None) # raises an error as expected

Its worth mentioning that you will not have a warning that either of these parameters are required

However, in case you need this code to work, I have made a basic version of what you were trying to use:

class Interface:
    def __init__(self, *args):

        if len(args) == 0:
            raise ValueError("a string or integer is required")

        self.a = None
        self.b = None

        for value in args:
            if type(value) == str:
                self.a = value
            elif type(value) == int:
                self.b = value

This code takes an infinite amount of arguments, and creates a list of all of them. It then loops through the list, assigning the data to the corresponding variable. Although if multiple values of the same data type are used as arguments, the last argument of that type will be used.

e.g. If we use x = Interface("a", "b"), x.a will equal "b"

Answered By: Elijah Deal