Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Proposal: Mapped Types #2147

RichardDRJ started this conversation in General
Discussion options

This proposal introduces three primitives - KeyOf[T], ValueOf[T, K], and TypeFrom[Base, Fields] - which enable deriving new types from existing ones and introducing alterations using familiar comprehension syntax. Combined with helpers like Extends and AnnotatedWith, 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:

Operation TypeScript Syntax Result Docs
Get keys of a type keyof Source "foo" | "bar" https://www.typescriptlang.org/docs/handbook/2/keyof-types.html
Get value type for key Source["foo"] number https://www.typescriptlang.org/docs/handbook/2/indexed-access-types.html
Exclude elements from union Exclude<"a" | "b" | "c", "b"> "a" | "c" https://www.typescriptlang.org/docs/handbook/utility-types.html#excludeuniontype-excludedmembers
New type { [K in keyof Source]: Source[K] } { foo: number, bar: string } https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
Transform value types { [K in keyof Source]: Source[K][] } { foo: number[], bar: string[] } https://www.typescriptlang.org/docs/handbook/2/mapped-types.html
Modify optionality { [K in keyof Source]?: Source[K] } { foo?: number, bar?: string } https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#mapping-modifiers
Change property names { [K in keyof Source as `get${Capitalize<string & Property>}`]: () => Source[K] } { getFoo: () => number, getBar: () => string } https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as

Key Differences in Python

Two fundamental differences shape this proposal:

  1. 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.

  2. No universal "optional field" semantics

    In TypeScript, foo?: T means the property may be absent, and accessing it yields undefined. Python has no equivalent:

    • None sometimes indicates absence, but can also be a meaningful value
    • Sentinel defaults (e.g., MISSING) are common
    • Different class systems handle "optional" differently

    Implication: 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 Annotated becomes more feasible with this approach and therefore could serve as a viable alternative to @dataclass_transform.

  3. 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:

Operation Example Resolved Type(s) Runtime Behaviour Notes
Support getting the keys of a type as a union Literal["foo", "bar"] Return Literal[*tp.__annotations__.keys()] Literal["a", "b"] is equivalent to Literal["a"] | Literal["b"] (PEP link)
Support getting the value type for a union of keys Literal["foo"] -> int, Literal["foo", "bar"] -> int | str Return Union[*[tp.__annotations__.get(it) for it in keytp.__args__]] The type of a union of keys is the union of the resolved types; since unions of a single type vanish, getting the type of one key gives a single value type
Support union/literal iteration Union/Literals expose __iter__ methods; for a Union, this returns iter(self.__args__), and for a Literal this returns iter(Literal[it] for it in self.__args__) This is where some confusion could come in, especially in the difference between iterating over a Union and iterating over a Literal; since a Literal's __args__ are literal values, I'm of the view that when returned by the iterator they should still be wrapped in Literal, whereas a Union's __args are all types already
Support Union comprehensions in types Union[it for it in Literal["a", "b"]] -> Union[Literal["a"], Literal["b"]], Union[it for it in Union[str, int]] -> Union[str, int] We could make it implicit that a list comprehension within a type alias is inherently a Union but this could cause issues, especially with Annotated where a list comprehension might actually be a list. Instead, this proposal suggests allowing Union to take an iterator parameter to its __class_getitem__; this is more in line with the use of Literal to explicitly denote string literal types, and leaves the door open for additional behaviour to be added if e.g. Intersection is added.
Support creating a new type from Literals in tuples tuple[Literal["foo"], int] | tuple[Literal["bar"], str] -> class with namespace set to {"__annotations__": {"foo": int}, "bar": str} Because of the runtime point mentioned in Key Differences in Python, it would be good to be able to define the class hierarchy. This would allow for defining type mapped TypedDicts and dataclass-like classes.
Define standard supported operations in Union comprehensions Limiting the supported grammar within Union comprehensions to a subset of supported operations means that type checkers don't need to support executing arbitrary code

Out of Scope
I'm treating the following as out of scope:

  • The Change Property Names operation: this would require the ability to alter Literal strings, 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 names

Returns a Literal containing all field annotation names for a type.

class Foo:
 x: int
 y: str
KeyOf[Foo] # Literal["x", "y"]

At runtime this can be constructed with tp.__annotations__.keys().

ValueOf[T, K] - Extract field value types

class Foo:
 x: int
 y: str
ValueOf[Foo, Literal["x"]] # int
ValueOf[Foo, Literal["x", "y"]] # int | str

TypeFrom[Base, Fs] - Construct a type from field specifications

Accepts a base class and a Union of field type specifications in the form: tuple[Literal["name", type]].

TypeFrom performs 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.

Result = TypeFrom[object, Union[tuple[Literal["x"], int], tuple[Literal["y"], str]]]
# Equivalent to:
class Result:
 x: int
 y: str
Result.__name__ = 'TypeFrom[object, Union[tuple[Literal["x"], int], tuple[Literal["y"], str]]]'
# Also equivalent to:
Result = type('TypeFrom[object, Union[tuple[Literal["x"], int], tuple[Literal["y"], str]]]', object, {"__annotations__": {"x": int, "y": str}})

Composition via Comprehensions

The power comes from combining these primitives with Python's existing comprehension syntax:

class Source:
 foo: int
 bar: str
 baz: bool
 bip: Annotated[str, Marker]
# Filter to specific fields (Pick)
Picked = TypeFrom[object,
 Union[tuple[K, ValueOf[Source, K]] for K in KeyOf[Source] if Extends[K, Literal["foo", "bar"]]]
]
# Result: {foo: int, bar: str}
# Exclude specific fields (Omit)
Omitted = TypeFrom[object,
 Union[tuple[K, ValueOf[Source, K]] for K in KeyOf[Source] if not Extends[K, Literal["baz"]]]
]
# Result: {foo: int, bar: str, bip: Annotated[str, Marker]}
# Filter fields which are `Annotated` and have an instance of `Marker` in their list of annotations
Tagged = TypeFrom[object,
 Union[tuple[K, ValueOf[Source, K]] for K in KeyOf[Source] if AnnotatedWith[ValueOf[Source, K], Marker]]
]
# Result: {bip: Annotated[str, Marker]}
# Transform value types
Listified = TypeFrom[object,
 Union[tuple[K, list[ValueOf[Source, K]]] for K in KeyOf[Source]]
]
# Result: {foo: list[int], bar: list[str], baz: list[bool], bip: list[Annotated[str, Marker]]}
# Make all fields None-able (NOTE: This doesn't make them truly optional, see Open Questions for more about providing defaults)
Partial = TypeFrom[object,
 Union[tuple[K, ValueOf[Source, K] | None] for K in KeyOf[Source]]
]
# Result: {foo: int | None, bar: str | None, ...}
# Filter fields which:
# - are `Annotated` and have an instance of `Marker` in their list of annotations; OR
# - are called "foo" or "bar"
Tagged = TypeFrom[object,
 Union[tuple[K, ValueOf[Source, K]] for K in KeyOf[Source] if AnnotatedWith[ValueOf[Source, K], Marker]] | Union[tuple[K, ValueOf[Source, K]] for K in KeyOf[Source] if Extends[K, Literal["foo", "bar"]]]
]
# Result: {foo: int, bar: str, bip: Annotated[str, Marker]}
Valid Comprehension Syntax

Valid Union comprehension syntax is very similar to standard list comprehension syntax (incl. nested fors). 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, the Extends helper could be implemented in a manner similar to:

class Extends:
 @overload
 def __class_getitem__[B, C: B](cls, args: tuple[C, B]) -> Literal[True]: ...
 # Note: this doesn't actually indicate that C is _not_ a subclass of B - this would require that negation
 # be added to the type system to be representable without a special case in type checkers.
 @overload
 def __class_getitem__[B, C](cls, args: tuple[C, B]) -> Literal[False]: ...
 def __class_getitem__[B, C](cls, args: tuple[C, B]):
 child, base = args
 return isinstance(child, type) and isinstance(base, type) and issubclass(child, base)

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:

type Pick[T, Ks: KeyOf[T]] = Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T] if Extends[K, Ks]]
# Use it
PickedKeys = Pick[User, Literal["name", "email"]] # Should resolve to `Literal["name", "email"]`

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 and Literals:

  1. Literal["a", "b"] is equivalent to Union[Literal["a"], Literal["b"]] (source)
  2. Union[Union[str, int], Union[float, bool]] is equivalent to Union[str, int, float, bool] (source)
  3. Union[int] is equivalent to int (source)

This means that:

  • Intuitively, Union[list[K] for K in Union[int, Literal["a", "b"]]] should be equivalent to Union[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.
  • Intuitively, Union[list[K] for K in Union[int]] should evaluate to list[int], but under rule (3) above Union[int] could be flattened before the comprehension is evaluated, leading to an invalid attempt to iterate over int.

Question: How can Union iteration 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/ValueOf constructs 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 a callable value and are not in T.__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 KeyOf and support getting their annotations in ValueOf. 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, and attrs handle 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 Annotated parameters, 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 from ParamSpecs to tuples and TypedDicts (e.g. as called out in #1009).

Examples

Deriving views over types

# Pick: Select specific fields from a type
type Pick[T, Ks: Literal] = TypeFrom[object,
 Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T] if Extends[K, Ks]]
]
class User:
 id: int
 name: str
 email: str
 password_hash: str
 created_at: datetime
# Public-safe user representation
UserPublic = Pick[User, Literal["id", "name"]]
# Result: {id: int, name: str}
# Omit: Exclude specific fields from a type
type Omit[T, Ks: Literal] = TypeFrom[object,
 Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T] if not Extends[K, Ks]]
]
# User without sensitive fields
UserSafe = Omit[User, Literal["password_hash"]]
# Result: {id: int, name: str, email: str, created_at: datetime}

Filtering based on annotations

from typing import Annotated
class Sensitive: pass
class PublicField: pass
class UserRecord:
 id: Annotated[int, PublicField()]
 name: Annotated[str, PublicField()]
 email: Annotated[str, PublicField()]
 password_hash: Annotated[str, Sensitive()]
 api_key: Annotated[str, Sensitive()]
# Extract only fields marked as public
type PublicFields[T] = TypeFrom[object,
 Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T] if AnnotatedWith[ValueOf[T, K], PublicField]]
]
UserPublicRecord = PublicFields[UserRecord]
# Result: {id: int, name: str, email: str}
# Extract only sensitive fields (e.g., for audit logging)
type SensitiveFields[T] = TypeFrom[object,
 Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T] if AnnotatedWith[ValueOf[T, K], Sensitive]]
]
UserSensitiveRecord = SensitiveFields[UserRecord]
# Result: {password_hash: str, api_key: str}

Limitations

Dataclass

# What we CAN express: the field annotations
type DataclassFields[C] = TypeFrom[object,
 Union[tuple[K, ValueOf[C, K]] for K in KeyOf[C]]
]
# What we CANNOT express without method mapping: the __init__ signature
# The following is illustrative of what would be needed (would also rely on being able to unpack a
# TypedDict from a TypeVar - see https://github.com/python/typing/issues/1395):
class WithInit[ArgsTD](Protocol):
 def __init__(self, **kwargs: Unpack[ArgsTD]) -> None: ...
type DataclassConstructorArgs[C] = TypeFrom[TypedDict,
 Union[tuple[K, ValueOf[C, K]] for K in KeyOf[C]]
]
# This conflates field annotations with method annotations,
# which the current proposal explicitly separates
type MyDataclassTransform[C] = TypeFrom[object,
 Union[tuple[K, ValueOf[C, K]] for K in KeyOf[C]]
 | tuple[Literal["__init__"], WithInit[DataclassConstructorArgs[C]]] # Not valid under current proposal
]
def my_dataclass[C](cls: C) -> MyDataclassTransform[C]: ...
@my_dataclass
class Point:
 x: int
 y: int
# Desired result:
# class Point:
# x: int
# y: int
# def __init__(self, *, x: int, y: int) -> None: ...
#
# Current proposal can express the fields, but not the __init__ method.
# This motivates the "Method Mapping" section in Future Work.

API Models

class Article:
 id: int
 title: str
 body: str
 author_id: int
 created_at: datetime
 updated_at: datetime
# For creation: exclude auto-generated fields
type CreateRequest[T] = Omit[T, Literal["id", "created_at", "updated_at"]]
ArticleCreate = CreateRequest[Article]
# Result: {title: str, body: str, author_id: int}
# For updates: all fields nullable except id
type UpdateRequest[T] = TypeFrom[object,
 Union[tuple[Literal["id"], int]]
 | Union[tuple[K, ValueOf[T, K] | None] for K in KeyOf[T] if not Extends[K, Literal["id", "created_at", "updated_at"]]]
]
ArticleUpdate = UpdateRequest[Article]
# Result: {id: int, title: str | None, body: str | None, author_id: int | None}
# NOTE: These fields all still need to be explicitly passed; adding support for setting field values in the class' namespace would help with that.
# For responses: all fields present
type Response[T] = T # Identity, but could add metadata wrapper
ArticleResponse = Response[Article]

Alternatives Considered

Introduce Lisp-style type-level operators such as Map/Filter instead of comprehension syntax.

# Instead of:
Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T] if Extends[K, Literal["foo", "bar"]]]
# Something like:
Map[
 lambda K: tuple[K, ValueOf[T, K]],
 Filter[lambda K: Extends[K, Literal["foo", "bar"]], KeyOf[T]]
]

Pros:

  • Fully backwards compatible with existing Python syntax;
  • Runtime-inspectable, including ability to expand generics;
  • Potentially easier for type checkers to implement (explicit structure, no comprehension parsing).

Cons:

  • Unfamiliar syntax - doesn't feel like idiomatic Python;
  • Lambdas in type positions have no precedent and would require new semantics;
  • Deeply nested for complex transformations.

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:

  • Simpler implementation - no need to generate actual classes;
  • Avoids questions about what TypeFrom[...] produces at runtime.

Cons:

  • Breaks compatibility with runtime-dependent libraries (dataclasses, Pydantic, attrs, etc.);
  • Inconsistent with Python's philosophy that types are real objects;
  • Limits usefulness to purely static analysis contexts.

Suggestion: Not worth doing. Runtime inspectability is a core requirement for Python's ecosystem.


Extend TypedDict only, rather than supporting arbitrary base classes.

Pros:

  • Narrower scope - easier to specify and implement;
  • Mapped types for TypedDict has already been raised in the community;
  • Avoids complexity around class hierarchies and method resolution.

Cons:

  • Doesn't address dataclass, Pydantic, or attrs use cases;
  • Would likely need to be generalised later anyway;
  • Misses opportunity to provide unified primitives.

Suggestion: Not worth doing. The primitives proposed here (KeyOf, ValueOf, TypeFrom) are general enough to support TypedDict as a special case of TypeFrom[TypedDict, ...]. Starting narrow would delay the more general solution without significantly reducing complexity.


Use a string-based DSL for type transformations.

Mapped["{ [K in keyof T]: T[K] | None }"]

Pros:

  • Could directly mirror TypeScript syntax;
  • Avoids needing to fit transformations into Python's grammar.

Cons:

  • Strings are opaque to type checkers without special handling;
  • No IDE support (autocomplete, refactoring, go-to-definition);
  • Feels foreign in Python code;
  • Runtime parsing adds complexity and performance cost.

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 ...].

# Implicit:
type Foo = [tuple[K, ValueOf[T, K]] for K in KeyOf[T]]
# Explicit (proposed):
type Foo = Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T]]

Pros:

  • Slightly less verbose.

Cons:

  • Ambiguous - list comprehensions already have meaning in Annotated and other contexts;
  • Harder to extend if other collection-like type constructs are added (e.g., Intersection);
  • Implicit behaviour is harder to reason about.

Suggestion: Explicit Union[...] wrapper is preferred for clarity and extensibility.


Use strings directly for field names rather than wrapping them in Literal.

# Unwrapped field names:
KeyOf[Foo]
# Result: Union["foo", "bar"]
# Wrapped field names (proposed):
KeyOf[Foo]
# Result: Literal["foo", "bar"]

Pros:

  • Less nesting.

Cons:

  • Ambiguous - strings already have a meaning within type annotations (often as forward references);
  • Requires modification to Union to support taking str literals which are implicitly wrapped in Literal.

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.

# Explicit type name
TypeFrom[object, Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T] if Extends[K, Literal["foo", "bar"]]], Literal["MyType"]]
# Result: class called `MyType`
# Inferred type name (proposed)
TypeFrom[object, Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T] if Extends[K, Literal["foo", "bar"]]]]
# Result: class called `TypeFrom[object, Union[tuple[K, ValueOf[T, K]] for K in KeyOf[T] if Extends[K, Literal["foo", "bar"]]]]`

Pros:

  • More explicit.

Cons:

  • Harder to identify the provenance of a given mapped type;
  • Harder to identify why a given field might be missing from a mapped type.

Suggestion: Implicit name generation for mapped types is preferred for now for debuggability, but adding explicit names can be added in future.

You must be logged in to vote

Replies: 0 comments

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
1 participant

AltStyle によって変換されたページ (->オリジナル) /