Skip to content

Allow unions of unions#311

Open
dsnam wants to merge 2 commits intodapper91:masterfrom
dsnam:mixed-sametype-unions
Open

Allow unions of unions#311
dsnam wants to merge 2 commits intodapper91:masterfrom
dsnam:mixed-sametype-unions

Conversation

@dsnam
Copy link
Copy Markdown

@dsnam dsnam commented Apr 10, 2026

The checks on union choices do not allow for unions that contain other unions, even if those unions when flattened would still be all-primitive or all-model. For example in our project we have a set of response codes in an XML schema we are modeling using this library:

type PermissionErrors = Literal[-4, -5]
type ServerErrors = Literal[-10, -11]
type CommonCodes = PermissionErrors | ServerErrors
type Request1ErrorCodes = Literal[-1, -2]
type Request1SuccessCodes = Literal[10, 11]
type Request1Codes = CommonCodes | Request1ErrorCodes | Request1SuccessCodes

Base Pydantic handles a field of type Request1Codes fine, but pydantic-xml does not:

================================================= short test summary info =================================================
FAILED tests/test_unions.py::test_nested_primitive_union - pydantic_xml.errors.ModelFieldError: Model.element1 field type error: union must be of primitive or model type
FAILED tests/test_unions.py::test_nested_model_union - pydantic_xml.errors.ModelFieldError: TopLevelModel.top_element1 field type error: union must be of primitive or model ...
============================================== 2 failed, 18 passed in 0.41s ===============================================

I modifed union.py to flatten union choices down when they consist of other unions so that the check on whether the union is not a mix of primitives and models still happens, but it no longer rejects a union that contains another union.

For anyone else running into this, a workaround is just to manually flatten your unions, ie the example above becomes:

type PermissionErrors = Literal[-4, -5]
type ServerErrors = Literal[-10, -11]
type Request1ErrorCodes = Literal[-1, -2]
type Request1SuccessCodes = Literal[10, 11]
type Request1Codes = PermissionErrors | ServerErrors | Request1ErrorCodes | Request1SuccessCodes

but this has the downside of requiring duplication (for us CommonCodes is quite a bit larger and reused extensively) and not exactly matching your schema, plus not everyone has control over the types they are modeling.

…ther unions, as long as everything flattens down to all primitives or all models
computed = ctx.field_computed
inner_serializers: List[Serializer] = []
for choice_schema in schema['choices']:
choice_schemas = _flatten_choice_schemas(schema['choices'])
Copy link
Copy Markdown
Author

@dsnam dsnam Apr 10, 2026

Choose a reason for hiding this comment

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

I was not the biggest fan of having to call this both in the module level from_core_schema and in both of the classmethods here, but I did not want to mutate schema. If it's ok to do that I could make this a bit less redundant. Alternatively I could pass in the flattened schemas as an extra argument.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

I think it is ok to mutate the schema in the outer from_core_schema as far as we keep it correct. But it is better to deepcopy it before since we don't know how it could affect pydantic itself.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Sounds good, I have reworked it to mutate a deepcopy of the schema and pass that flattened schema along to the serializers

raise AssertionError("unreachable")


def _flatten_choice_schemas(choice_schemas):
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

it seems like this procedure must be recursive since the union nesting level can be not only 2, but 3, 4 ...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I believe this is covered with no recursion because when a union choice is found its choices are added to the same list the while loop is using, so it continues until everything is flattened, but I did discover I had missed a case where a choice is a definition-ref, is that what you meant? I updated a test case to include an additional level and added looking up definition-refs. Let me know if you think I'm missing something else here.

…alizers rather than call _flatten_choice_schemas multiple times. cover the case where a choice is a 'definition-ref'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants