Type Mapping Explained

The following explains, how ts2python maps Typescript-types and in particular Typescript-interfaces, to Python types, in particular, TypedDicts. The mapping is, so far, rich enough to cover all interfaces from the Language Server Protocol.

Mapping of Interfaces

Basically, Typescript-Interfaces are mapped to Python-classes and the fields of an interface are mapped to a class attribute. Thus,:

interface Message {
    jsonrpc: string;
}

becomes:

class Message(TypedDict):
    jsonrpc: str

ts2python uses TypedDict as base class per default. Optional fields of a TypeScript-Interface are mapped to NonRequired-types in Python. Since the NotRequired-type-qualifier has only been introduced in with Python version 3.11, NotRequired will be defined as an alias to Optional if an older Python version is used. Also, if there are any optional fields, the total-parameter of the TypedDict-class will be set to to False in the compatibility-mode. Since before Python version 3.11 static validation relies on the total-parameter it will not capture missing required attributes in TypedDicts that contain both required and optional fields. Runtime-Validation by ts2python will still catch such errors (see Runtime Validation).

Thus,:

interface RequestMessage extends Message {
    id: integer | string;
    method: string;
    params?: array | object;
}

becomes:

NotRequired = Optional

class RequestMessage(Message, TypedDict, total=False):
    id: Union[int, str]
    method: str
    params: NotRequired[Union[List, Dict]]

In the compatibility-mode (for Python versions smaller than 3.11) Optional-types are understood as attributes that need not be present in the dictionary. This runs contrary the standard semantics of Optional-types in Python, which requires attributes annotated with Optional to always be present although they may contain the value None. In fact, this non-standard interpretation of Optional implements one of the rejected ways of marking individual TypedDict items as not required in PEP 655.

However, since up to and including Python version 3.10 PEP 655 had not been implemented, abusing Optional for this purpose appeared to be a pragmatic solution that in connection with setting the parameter total=False plays well-enough with static type-checkers. Unless your code using ts2python-transpiled TypedDicts does not assume attributes with Optional type to be present, there won’t be a problem.

For the case that the transpiled code does not need to run with Python-versions below 3.11, it is possible, to enforce PEP 655 by calling ts2python with the parameter -p 655 or with -c 3.11, in which case NotRequired will be not be redefined as Optional. Also, the total-parameter of the TypedDict-class will not be set to “False” (and thus keep its default value “True), which means that all not required fields are required. The above Message-interface will then read as:

class RequestMessage(Message, TypedDict):
    id: Union[int, str]
    method: str
    params: NotRequired[List | Dict]

Mapping of Field Types

For most field types the mapping is fairly straight forward:

Typscript type

Python type

number

float

integer

int

boolean

bool

string

str

null

None

unknown

Any

any

any

array

list

object

dict

Mapping of Literals

For some field types, the Python-counterpart is less obvious. Literal types are naturally converted using Literal. For Python- Versions below 3.8, the typing_extensions-module must be present to provide the Literal-type:

export type ResourceOperationKind = 'create' | 'rename' | 'delete';

will be converted to:

ResourceOperationKind = Literal['create', 'rename', 'delete']

Mapping of Enumerations

Enumerations can also more or less directly be transpiled to Python-Enums. Thus,:

export enum FoldingRangeKind {
    Comment = 'comment',
    Imports = 'imports',
    Region = 'region'
}

becomes:

class FoldingRangeKind(Enum):
    Comment = 'comment'
    Imports = 'imports'
    Region = 'region'

There exist some restrictions regarding enums, though. Other than Typescript, Python does not really allow strings as keys in enumerations. Thus, the Typescript enum:

export enum MilkyWay {
    'from the earth',
    'past the moon',
    'to the stars'
}

will not be converted to a Python Enum. (Rather, ts2python will complain about an expected closing quote.) However, in those cases, where the string-content happens to be a valid identifier, ts2python will consider those strings as identifiers. The Typescript enum enum MilkyWay { 'earth', 'moon', 'stars' } will be converted to:

class MilkyWay(IntEnum):
    earth = enum.auto()
    moon = enum.auto()
    stars = enum.auto()

The same Python Enum would be produced by enum MilkyWay { earth, moon, stars } without quotation marks.

Caution

Observe, that ts2python converts enums without explicit values to Python IntEnums, and that, furthermore, Python enums start counting with 1 rather than 0. (See the documentation of Python’s [enum-module](https://docs.python.org/3/library/enum.html#functional-api) for the reasons for this.) If this leads to problems, the Typescript enum-definitions must be disambiguated by adding explicit values before the conversion!

Mapping of Index Signatures

Index signatures are simply transpiled to dictionaries, dropping the identifier of the index signature. Thus,:

export interface WorkspaceEdit {
    changes?: { [uri: DocumentUri]: TextEdit[]; };

    documentChanges?: (
        TextDocumentEdit[] |
        (TextDocumentEdit | CreateFile | RenameFile | DeleteFile)[]
    );

    changeAnnotations?: {
        [id: string /* ChangeAnnotationIdentifier */]: ChangeAnnotation;
    };
}

becomes:

class WorkspaceEdit(TypedDict, total=False):
    changes: NotRequired[Dict['DocumentUri', List['TextEdit']]]
    documentChanges: Union[
        List['TextDocumentEdit'],
        List[Union['TextDocumentEdit', 'CreateFile', 'RenameFile', 'DeleteFile']],
        None]
    changeAnnotations: NotRequired[Dict[str, 'ChangeAnnotation']]

Mapping of Tuple Types

Likewise, Typescript-tuple-types are transpiled to Python-tuple-types.

Typescript:

export interface ParameterInformation {
    label: string | [uinteger, uinteger];
    documentation?: string | MarkupContent;
}

Python:

class ParameterInformation(TypedDict):
    label: Union[str, Tuple[int, int]]
    documentation: NotRequired[Union[str, 'MarkupContent']]

Mapping of Records

Typescript Records are simply mapped to parameterized dictionaries.

Typescript:

export interface Test {
  t: Record<string, number>
}

Python:

class Test(TypedDict):
    t: Dict[str, float]

Mapping of Anonymous Interfaces

A bit more complicated is the case of anonymous interfaces in TypeScript:

interface InitializeParams extends WorkDoneProgressParams {
    processId: integer | null;
    clientInfo?: {
        name: string;
        version?: string;
    };
    locale?: string;
    rootPath?: string | null;
    rootUri: DocumentUri | null;
    initializationOptions?: any;
    capabilities: ClientCapabilities;
    trace?: TraceValue;
    workspaceFolders?: WorkspaceFolder[] | null;
}

In order to transfer this to Python, a local class is defined and the fields’ name with a capitalized first letter and appended underscore is used as name for the local class. Although, this use of local-classes within TypedDict-classes is not in “legal” conformance with the specification of TypedDict-classes (see PEP 589), it is technically sound and works perfectly well in practice (see toplevel_switch for how to enforce “legal” conformance, if needed)

class InitializeParams(WorkDoneProgressParams, TypedDict):
    class ClientInfo_(TypedDict):
        name: str
        version: NotRequired[str]
    processId: Union[int, None]
    clientInfo: NotRequired[ClientInfo_]
    locale: NotRequired[str]
    rootPath: NotRequired[Union[str, None]]
    rootUri: Union['DocumentUri', None]
    initializationOptions: NotRequired[Any]
    capabilities: 'ClientCapabilities'
    trace: NotRequired['TraceValue']
    workspaceFolders: NoRequired[Union[List['WorkspaceFolder'], None]]

This works also for nested local interfaces:

interface SemanticTokensClientCapabilities {
    dynamicRegistration?: boolean;
    requests: {
        range?: boolean | {
        };
        full?: boolean | {
            delta?: boolean;
        };
    };
    tokenTypes: string[];
    tokenModifiers: string[];
    formats: TokenFormat[];
    overlappingTokenSupport?: boolean;
    multilineTokenSupport?: boolean;
}

becomes:

class SemanticTokensClientCapabilities(TypedDict):
    class Requests_(TypedDict):
        class Range_1(TypedDict):
            pass
        class Full_1(TypedDict):
            delta: NotRequired[bool]
        range: NotRequired[Union[bool, Range_1]]
        full: NotRequired[Union[bool, Full_1]]
    dynamicRegistration: NotRequired[bool]
    requests: Requests_
    tokenTypes: List[str]
    tokenModifiers: List[str]
    formats: List['TokenFormat']
    overlappingTokenSupport: NotRequired[bool]
    multilineTokenSupport: NotRequired[bool]

In case of type unions, the local classes will be numbered, because there could be more than one local interface for the same field:

export type TextDocumentContentChangeEvent = {
    range: Range;
    rangeLength?: uinteger;
    text: string;
} | {
    text: string;
};

becomes:

class TextDocumentContentChangeEvent_0(TypedDict, total=False):
    range: Range
    rangeLength: NotRequired[int]
    text: str
class TextDocumentContentChangeEvent_1(TypedDict):
    text: str
TextDocumentContentChangeEvent = Union[
    TextDocumentContentChangeEvent_0, TextDocumentContentChangeEvent_1]

Alternative Representations for Anonymous Interfaces

Starting with version 0.6.9, anonymous interfaces can also be mapped with functional syntax:

interface InitializeResult {
    capabilities: ServerCapabilities;
    serverInfo?: {
        name: string;
        version?: string;
    };
}

becomes:

class InitializeResult(TypedDict):
    capabilities: 'ServerCapabilities'
    serverInfo: NotRequired[TypedDict("ServerInfo_0",
                            {"name": str, "version": NotRequired[str]})]

The “functional” representation can be selected by assigning the value “functional” to the configuration key “ts2python.RenderAnonymous”. Alternatively, it can be selected with the command line option --anonymous functional or -a functional.

There is also an experimental “type”-syntax, which renders the anonymous interface in the above example as:

TypedDict[{"name": str, "version": NotRequired[str]}]

However, this is not (yet) in conformance with the Python-Standard. (See this post on inline TypedDict definitions). Still, it can be turned on with -a type.

Finally, with --anonymous toplevel or -a toplevel, the definition of classes inside classes can be avoided completely. This helps to avoid complaints by type-checkers like mypy or pylance. The result looks like this:

class InitializeResult_ServerInfo_0(TypedDict):
    name: str
    version: NotRequired[str]

class InitializeResult(TypedDict):
    capabilities: 'ServerCapabilities'
    serverInfo: NotRequired[InitializeResult_ServerInfo_0]

Namespaces and Generics

Typescript namespaces are not supported, except for the special case where they consist entirely of constant definitions. In this case, namespaces will be transpiled to Enums.

Typescript Namespace:

export namespace DiagnosticSeverity {
    export const Error: 1 = 1;
    export const Warning: 2 = 2;
    export const Information: 3 = 3;
    export const Hint: 4 = 4;
}

Resulting Python Enum:

class DiagnosticSeverity(IntEnum):
    Error = 1
    Warning = 2
    Information = 3
    Hint = 4

Thus, generic interfaces containing type-parameters will be transpiled to generic typed dicts, which in the most backward-compatible form (back to Python 3.7) look like this:

interface ProgressParams<T> {
    token: ProgressToken;
    value: T;
}

becomes:

T = TypeVar('T')

class ProgressParams(Generic[T], GenericTypedDict, total=True):
    token: 'ProgressToken'
    value: T

If the compatibility-level is set to 3.11 or above, TypeVars suffice. There is no need to derive from Generic or GenericTypedDict, any more:

T = TypeVar('T')

class ProgressParams(TypedDict):
    token: 'ProgressToken'
    value: T

For Python versions higher than 3.12 only the result will be a generic TypedDict-class:

class ProgressParams[T](TypedDict):
    token: 'ProgressToken'
    value: T

TypeAliases

The mapping of type aliases depends very much on the compatibility-level. If the default compatibility all they way down to Python version 3.7 is selected, Typescript type aliases will be mapped to plain type assignments. For example,:

type ProviderResult<T> = T | undefined | null | Thenable<T | undefined | null>;

will become:

T = TypeVar('T')
ProviderResult = Union[T, None, Coroutine[Union[T, None]]]

Observe that undefined and null are both mapped to the Python None-value and that redundancies like None | None are automatically resolved to None. If the compatibility-level is set to at least Python version 3.10 with the “-c 3.10” switch which autoselects PEPs 586, 604, 613, the type assignment will furthermore be annotated with the TypeAlias-type:

T = TypeVar('T')
ProviderResult: TypeAlias = T | None | Coroutine[T | None]

Compatibility levels of 3.12 and above will also include support for PEP 695 and ultimately yield the arguably most elegant syntax using the type-statement introduced with Python 3.12:

type ProviderResult[T] = T | None | Coroutine[T | None]

Imports

Starting from version 0.6.9 TypeScript imports, e.g. import {ChangeInfo, CommentRange} from './rest-api'; will be parsed and ignored so that they don’t cause any parser errors.

Types derived from other Types

ts2Python has only rudimentary support for types that are derived from other types (see Creating Types from Types in the Typescript-manual). While some of these derived types are accepted by ts2python’s parser, they are practically never properly matched to similar Python-types. In many cases types derived from other types will - for the lack of a deeper semantic analysis of Typescript-input by ts2python - simply be represented as type Any on the Python-side.

Because Python’s type system isn’t as elaborated as that of Typescript, a translation that keeps all information will often not be feasible, anyway. The main reason, however, why this is not done is that it would require ts2python to actually reason about the types it parses, which is something which ts2python has not been designed for. However, more purely syntactic support for these constructs can be added in the future, if desired.