Proposal: Mapped Types #2147
RichardDRJ
started this conversation in
General
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
This proposal introduces three primitives -
KeyOf[T],ValueOf[T, K], andTypeFrom[Base, Fields]- which enable deriving new types from existing ones and introducing alterations using familiar comprehension syntax. Combined with helpers likeExtendsandAnnotatedWith, these allow expressing patterns like those found in TypeScript in a way that's both runtime-inspectable and statically analysable.Motivation
TypeScript's mapped types allow developers to derive new types from existing ones: making fields optional, filtering to a subset of keys, transforming value types, etc. Python supports doing this at runtime, but not in a way which is inspectable by static type checkers. This leads to a choice between the use of dynamic types (e.g. supporting "partial update" variants of models) and the power of type checkers.
This post introduces primitives that compose with Python's existing syntax to enable building dynamic types in a manner compatible with type checkers. It aims to respect Python's dynamic nature and runtime type inspectability while enabling powerful type transformations through familiar comprehension syntax.
I've called out some open questions and areas I've left out for now at the end - I'm wary of turning this into too much of a beast, but some of them (especially thinking about method mapping and ParamSpec mapping) may be desirable from the start.
Prior Art
TypeScript
TypeScript provides several primitive operations on types which can be composed powerfully in type mapping:
keyof Source"foo" | "bar"Source["foo"]numberExclude<"a" | "b" | "c", "b">"a" | "c"{ [K in keyof Source]: Source[K] }{ foo: number, bar: string }{ [K in keyof Source]: Source[K][] }{ foo: number[], bar: string[] }{ [K in keyof Source]?: Source[K] }{ foo?: number, bar?: string }{ [K in keyof Source as `get${Capitalize<string & Property>}`]: () => Source[K] }{ getFoo: () => number, getBar: () => string }Key Differences in Python
Two fundamental differences shape this proposal:
Runtime existence
TypeScript types are erased at compile time. Python types exist at runtime and drive behaviour in various libraries (e.g. dataclasses, Pydantic, attrs).
Implication: Any mapped type solution must produce types that are introspectable and usable by these tools. This may include being able to introspect the source type annotations, not just the resulting constructed type.
No universal "optional field" semantics
In TypeScript,
foo?: Tmeans the property may be absent, and accessing it yieldsundefined. Python has no equivalent:Nonesometimes indicates absence, but can also be a meaningful valueMISSING) are commonImplication: This proposal does not attempt to map TypeScript's optionality modifiers directly. Instead, it provides the ability to express optionality in type annotations. Expressing optionality by defining field descriptors is considered out of scope, on the basis that customising generated types based on parameters to
Annotatedbecomes more feasible with this approach and therefore could serve as a viable alternative to@dataclass_transform.TypeScript has multiple different meanings for a literal string/boolean/number/etc.: depending on the context it can mean a runtime literal or a type representing a literal. The latter is analagous to a Python
Literal[...].Implication: Any translation from Typescript to Python must take into account whether TypeScript is representing a runtime literal or a type; Python currently only supports representing literals with the
Literal[...]special form, so this proposal does not attempt to change that behaviour to align more with TypeScript.Python Discussions
I've found two main relevant discussion threads on similar topics to this:
Both converge on the concept of creating mapped types, specifically between
TypedDicts. In Eric's comment on the discuss.python.org thread (October 2024), he suggests investigating the primitives offered by TypeScript, and in his comment on the github.com discussion (June 2023) he outlines some possible approaches to implementing these primitives in Python.A lot of this proposal builds upon Eric's prior work.
Proposal
Given the prior art mentioned above, including the key differences called out between TypeScript and Python, I would suggest the addition of the following operations:
Literal["foo", "bar"]Literal[*tp.__annotations__.keys()]Literal["a", "b"]is equivalent toLiteral["a"] | Literal["b"](PEP link)Literal["foo"]->int,Literal["foo", "bar"]->int | strUnion[*[tp.__annotations__.get(it) for it in keytp.__args__]]__iter__methods; for a Union, this returnsiter(self.__args__), and for aLiteralthis returnsiter(Literal[it] for it in self.__args__)Unionand iterating over aLiteral; since aLiteral's__args__are literal values, I'm of the view that when returned by the iterator they should still be wrapped inLiteral, whereas aUnion's__argsare all types alreadyUnion[it for it in Literal["a", "b"]]->Union[Literal["a"], Literal["b"]],Union[it for it in Union[str, int]]->Union[str, int]Unionbut this could cause issues, especially withAnnotatedwhere a list comprehension might actually be a list. Instead, this proposal suggests allowingUnionto take an iterator parameter to its__class_getitem__; this is more in line with the use ofLiteralto explicitly denote string literal types, and leaves the door open for additional behaviour to be added if e.g.Intersectionis added.Literals in tuplestuple[Literal["foo"], int] | tuple[Literal["bar"], str]-> class withnamespaceset to{"__annotations__": {"foo": int}, "bar": str}Key Differences in Python, it would be good to be able to define the class hierarchy. This would allow for defining type mappedTypedDicts and dataclass-like classes.Out of Scope
I'm treating the following as out of scope:
Literalstrings, which introduces additional complexity and I don't believe it's a hard requirement for being able to map types.I've taken a stab below at how these primitives could look. This is primarily to enable exploring how they'd work together and what could be expressed with them, rather than trying to nail down specifics immediately.
KeyOf[T]- Extract field namesReturns a
Literalcontaining all field annotation names for a type.At runtime this can be constructed with
tp.__annotations__.keys().ValueOf[T, K]- Extract field value typesTypeFrom[Base, Fs]- Construct a type from field specificationsAccepts a base class and a
Unionof field type specifications in the form:tuple[Literal["name", type]].TypeFromperforms a direct mechanical translation with no interpretation of the value. The resulting class is equivalent to one written by hand, with the exception that its name is a verbatim transformation of the type name.Composition via Comprehensions
The power comes from combining these primitives with Python's existing comprehension syntax:
Valid Comprehension Syntax
Valid
Unioncomprehension syntax is very similar to standardlistcomprehension syntax (incl. nestedfors). The primary addition consists of helper utility types which are defined as both runtime and static-analysis-time constructs. This allows for the composition of helpers and the definition of new ones. For example, theExtendshelper could be implemented in a manner similar to:Certain helpers cannot currently be expressed in the type system without using themselves (even with this proposal), so their type-time behaviour will need to be hardcoded in type checkers, but their runtime behaviour can be vended as standard Python code.
Open Questions
Generic Mapped Types
In order to be able to reuse type forms, mapped types should support generic parameters that remain inspectable at runtime:
The proposed usage of iterable comprehensions would not easily enable that as Python stands.
Question: How can generic parameters be inspectable in Union comprehensions?
Union Iteration Complexities
In Python, there are some equivalence rules in
Unions andLiterals:Literal["a", "b"]is equivalent toUnion[Literal["a"], Literal["b"]](source)Union[Union[str, int], Union[float, bool]]is equivalent toUnion[str, int, float, bool](source)Union[int]is equivalent toint(source)This means that:
Union[list[K] for K in Union[int, Literal["a", "b"]]]should be equivalent toUnion[list[K] for K in Union[int, Literal["a"], Literal["b"]]]:Union[list[int], list[Literal["a"]], list[Literal["b"]]]. So rules would need to be defined for union flattening.Union[list[K] for K in Union[int]]should evaluate tolist[int], but under rule (3) aboveUnion[int]could be flattened before the comprehension is evaluated, leading to an invalid attempt to iterate overint.Question: How can
Unioniteration be safely implemented to match developer intuition?Question: Instead of reusing
Union, should a new construct (e.g.TypeList) be introduced?Method Mapping
Method preservation not currently addressed in this proposal; mapped types transform data shape only. However, this introduces issues with representing things like dataclass transforms where, for example, it is necessary to introduce new methods (
__init__) and preserve existing methods from the transformed class.Question: Is mapping methods between types worthwhile including?
Question: Should methods be treated the same as annotations (i.e. use the same
KeyOf/ValueOfconstructs to access them)?Question: If method mapping is included, what would the set of methods resolve to at runtime? (e.g. all entries in
dir(T)which have acallablevalue and are not inT.__annotations__?)Out Of Scope/Futures
Method Mapping
Method preservation is explicitly out of scope for the time being; mapped types transform data shape only. This introduces issues with representing a dataclass transform; a potential extension would be to include method names in
KeyOfand support getting their annotations inValueOf. However, it's unclear how that could work at runtime.Field Descriptors
This proposal focuses on defining field annotations; however, libraries such as
dataclasses,Pydantic, andattrshandle customisation of field behaviour based on parameters passed into those fields' descriptors. A potential extension would be to support both getting and setting the class-level value of fields. However, this would cause this type-level construct to include constructs which are primarily useful at runtime.Method mapping would potentially unblock using dataclass-esque functionality while specifying things like defaults in
Annotatedparameters, avoiding the need to worry about field descriptors at static analysis time.ParamSpec Transformation
ParamSpecs could potentially follow a similar pattern to
Unions, in that they could be iterated over and constructed from iterators. That would then allow for type-safe function wrapping with additional parameters, or transformation fromParamSpecs totuples andTypedDicts (e.g. as called out in #1009).Examples
Deriving views over types
Filtering based on annotations
Limitations
Dataclass
API Models
Alternatives Considered
Introduce Lisp-style type-level operators such as
Map/Filterinstead of comprehension syntax.Pros:
Cons:
Potential compromise: Introduce something along these lines first, then add comprehension syntax as sugar once the semantics are proven and open questions are ironed out.
Static-only types (no runtime representation), similar to TypeScript's approach.
Pros:
TypeFrom[...]produces at runtime.Cons:
Suggestion: Not worth doing. Runtime inspectability is a core requirement for Python's ecosystem.
Extend
TypedDictonly, rather than supporting arbitrary base classes.Pros:
TypedDicthas already been raised in the community;Cons:
dataclass,Pydantic, orattrsuse cases;Suggestion: Not worth doing. The primitives proposed here (
KeyOf,ValueOf,TypeFrom) are general enough to supportTypedDictas a special case ofTypeFrom[TypedDict, ...]. Starting narrow would delay the more general solution without significantly reducing complexity.Use a string-based DSL for type transformations.
Pros:
Cons:
Suggestion: Not worth doing. The goal is to work with Python's syntax, not around it.
Implicit union for comprehensions in type contexts rather than explicit
Union[... for ...].Pros:
Cons:
Annotatedand other contexts;Intersection);Suggestion: Explicit
Union[...]wrapper is preferred for clarity and extensibility.Use strings directly for field names rather than wrapping them in
Literal.Pros:
Cons:
Unionto support takingstrliterals which are implicitly wrapped inLiteral.Suggestion: Wrapping field names in
Literal[...]is preferred for clarity and compatibility.Support setting a generated type's name via its annotation rather than allowing a user to provide it.
Pros:
Cons:
Suggestion: Implicit name generation for mapped types is preferred for now for debuggability, but adding explicit names can be added in future.
Beta Was this translation helpful? Give feedback.
All reactions