├── .eslintrc.js ├── .github └── workflows │ ├── publish.yaml │ └── tests.yaml ├── .gitignore ├── .npmignore ├── .nvmrc ├── LICENSE ├── README.md ├── babel.config.cjs ├── package.json ├── src ├── ParserState.ts ├── canonicalTypeName.ts ├── config.ts ├── getDocumentationStringForType.ts ├── index.ts ├── isValidPythonIdentifier.ts ├── newHelperTypeName.ts ├── parseExports.ts ├── parseInlineType.ts ├── parseProperty.ts ├── parseTypeDefinition.ts ├── testing │ ├── basic.test.ts │ ├── dicts.test.ts │ ├── generics.test.ts │ ├── helperTypes.test.ts │ ├── imports.test.ts │ ├── readme.test.ts │ ├── reference.test.ts │ └── utils.ts └── typeScriptToPython.ts ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 6 | 'prettier', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | EXPERIMENTAL_useSourceOfProjectReferenceRedirect: true, 11 | project: ['./tsconfig.json'], 12 | }, 13 | plugins: [ 14 | '@typescript-eslint', 15 | 'prettier', 16 | ], 17 | rules: { 18 | "@typescript-eslint/no-non-null-assertion": "off", 19 | "@typescript-eslint/no-unused-vars": ["error", { ignoreRestSiblings: true }], 20 | "prettier/prettier": "error", 21 | 'no-console': ['error', { allow: ['warn', 'error', 'assert'] }], 22 | }, 23 | ignorePatterns: "dist/**", 24 | }; 25 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | 2 | on: 3 | push: 4 | branches: [ main ] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-node@v3 12 | with: 13 | node-version-file: '.nvmrc' 14 | cache: 'yarn' 15 | 16 | - name: install 17 | run: yarn install 18 | 19 | - name: Publish 20 | if: github.ref == 'refs/heads/main' 21 | uses: Github-Actions-Community/merge-release@v6.0.7 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version-file: '.nvmrc' 19 | cache: 'yarn' 20 | 21 | # install dependencies 22 | - run: yarn install 23 | # build app 24 | - run: yarn build 25 | # check if lockfile is up to date 26 | - run: git diff --exit-code yarn.lock 27 | # run the typechecker 28 | - run: yarn typecheck 29 | # run unit tests 30 | - run: yarn test 31 | # run linter 32 | - run: yarn lint --max-warnings=0 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /yarn-error.log 4 | /dist 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | /.github 3 | /.vscode 4 | /node_modules 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.15.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Lars Melchior 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeScript2Python 2 | 3 | ## About 4 | 5 | This project implements a transpiler for creating [pyright](https://github.com/microsoft/pyright) compatible type declarations automatically from TypeScript code! 6 | This is useful in a number of scenarios. 7 | For example: 8 | 9 | - Safely use JSON objects created by TypeScript projects in Python 10 | - Automatic generation of type-safe APIs between Node.js and Python applications 11 | - An easy way to write complex Python typings using TypeScript 12 | 13 | ## Example 14 | 15 | ### TypeScript 16 | 17 | ```ts 18 | export type Foo = { 19 | type: "foo" 20 | foo: number[] 21 | optional?: string 22 | } 23 | 24 | /** DocStrings are supported! */ 25 | export type Bar = { 26 | type: "bar" 27 | bar: string 28 | /** nested objects need extra declarations in Python */ 29 | nested: { 30 | foo: Foo 31 | } 32 | } 33 | 34 | export type FooBarMap = { 35 | [key: string]: Foo | Bar 36 | } 37 | 38 | export type TupleType = [string, Foo | Bar, any[]] 39 | ``` 40 | 41 | ### TypeScript2Python 42 | 43 | ```python 44 | from typing_extensions import Any, Dict, List, Literal, NotRequired, Tuple, TypedDict, Union 45 | 46 | class Foo(TypedDict): 47 | type: Literal["foo"] 48 | foo: List[float] 49 | optional: NotRequired[str] 50 | 51 | class Ts2Py_tliGTOBrDv(TypedDict): 52 | foo: Foo 53 | 54 | class Bar(TypedDict): 55 | """ 56 | DocStrings are supported! 57 | """ 58 | type: Literal["bar"] 59 | bar: str 60 | nested: Ts2Py_tliGTOBrDv 61 | """ 62 | nested objects need extra declarations in Python 63 | """ 64 | 65 | FooBarMap = Dict[str,Union[Foo,Bar]] 66 | 67 | TupleType = Tuple[str,Union[Foo,Bar],List[Any]] 68 | ``` 69 | 70 | 71 | ## Usage 72 | 73 | The easiest way to use TypeScript2Python, is to invoke it directly using `npx` and pointing it to one (or multiple) source files that export type declarations. 74 | 75 | ```bash 76 | npx typescript2python 77 | ``` 78 | 79 | It will then output the transpiled code to the terminal. 80 | To save the output as a python file, simply pipe the result to the desired destination. 81 | For example `npx typescript2python types.ts > types.py`. 82 | 83 | ## Features 84 | 85 | TypeScript2Python supports many of TypeScripts type constructs, including: 86 | 87 | - Basic types, like `boolean`, `number`, `string`, `undefined` 88 | - Literal types, e.g. `type X = 42`, `type Y = 'test'` 89 | - Object types, `{ foo: string }` 90 | - Unions, `string | number` 91 | - Arrays, `boolean[]` 92 | - Nested objects `{ bar: { foo: string } }`, that will get transpiled into helper dictionaries 93 | - Optional properties `{ optional?: number }`, that get transpiled to `NotRequired[...]` attributes 94 | - Docstrings `/** this is very useful */` 95 | 96 | ## Transpiler options 97 | 98 | ### Strict 99 | 100 | Use the `--strict` flag to enable all strict type-checking options to ensure `undefined` and `null` properties are not ignored during transpilation. 101 | 102 | ### Nullable optionals 103 | 104 | In TypeScript objects, optional values can also be set to `undefined`. By default we assume the according Python 105 | type to be non-nullable, but a more closely matching behavior can be achieved using the flag `--nullable-optionals`. 106 | This will result in optional entries beeing transpiled as `NotRequired[Optional[T]]` instead of `NotRequired[T]`. 107 | 108 | ## Limitations 109 | 110 | The main focus of this project is transpiling type definitions for serializable data (e.g. JSON objects), and the following is not planned to be supported: 111 | 112 | - Generics, as TypeScript's type system is much more powerful than Python's 113 | - Function signatures, as we restrict ourselves to serializable data 114 | - Anything that isn't a type definition 115 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { 4 | targets: { node: 'current' }, exclude: ["proposal-dynamic-import"] 5 | }], 6 | '@babel/preset-typescript', 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript2python", 3 | "version": "1.0.0", 4 | "main": "dist/index.js", 5 | "license": "MIT", 6 | "repository": "https://github.com/TheLartians/TypeScript2Python", 7 | "homepage": "https://github.com/TheLartians/TypeScript2Python#readme", 8 | "keywords": [ 9 | "python", 10 | "typescript", 11 | "transpiler", 12 | "types", 13 | "type-safety", 14 | "api", 15 | "json", 16 | "compiler", 17 | "docstring", 18 | "typings" 19 | ], 20 | "dependencies": { 21 | "@commander-js/extra-typings": "^12.1.0", 22 | "commander": "^12.1.0", 23 | "typescript": "^5.3.3" 24 | }, 25 | "devDependencies": { 26 | "@babel/cli": "^7.23.4", 27 | "@babel/core": "^7.23.7", 28 | "@babel/node": "^7.22.19", 29 | "@babel/preset-env": "^7.23.7", 30 | "@babel/preset-typescript": "^7.23.3", 31 | "@ts-morph/bootstrap": "^0.22.0", 32 | "@types/jest": "^29.5.11", 33 | "@types/node": "^20.10.6", 34 | "@typescript-eslint/eslint-plugin": "^6.17.0", 35 | "@typescript-eslint/parser": "^6.17.0", 36 | "eslint": "^8.56.0", 37 | "eslint-config-prettier": "^9.1.0", 38 | "eslint-plugin-prettier": "^5.1.2", 39 | "jest": "^29.7.0", 40 | "prettier": "^3.1.1" 41 | }, 42 | "scripts": { 43 | "watch": "babel-node --watch -x .ts --", 44 | "single": "babel-node -x .ts", 45 | "develop": "yarn watch src/index.ts", 46 | "lint": "eslint src/**/*.ts", 47 | "build": "babel --extensions .ts --ignore '**/*.test.ts' ./src -d dist --source-maps", 48 | "test": "jest", 49 | "typecheck": "tsc -p . --noEmit", 50 | "prepack": "yarn build" 51 | }, 52 | "bin": { 53 | "typescript2python": "./dist/index.js" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ParserState.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { Ts2PyConfig } from "./config"; 3 | 4 | export type ParserState = { 5 | statements: string[]; 6 | typechecker: ts.TypeChecker; 7 | knownTypes: Map; 8 | helperTypeNames: Map; 9 | canonicalTypeNames: Map; 10 | imports: Set; 11 | config: Ts2PyConfig; 12 | }; 13 | 14 | export const createNewParserState = (typechecker: ts.TypeChecker, config: Ts2PyConfig): ParserState => { 15 | const knownTypes = new Map(); 16 | knownTypes.set(typechecker.getVoidType(), "None"); 17 | knownTypes.set(typechecker.getNullType(), "None"); 18 | knownTypes.set(typechecker.getUndefinedType(), "None"); 19 | knownTypes.set(typechecker.getStringType(), "str"); 20 | knownTypes.set(typechecker.getBooleanType(), "bool"); 21 | knownTypes.set(typechecker.getNumberType(), "float"); 22 | 23 | return { 24 | statements: [], 25 | typechecker, 26 | knownTypes, 27 | imports: new Set(), 28 | helperTypeNames: new Map(), 29 | canonicalTypeNames: new Map(), 30 | config, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/canonicalTypeName.ts: -------------------------------------------------------------------------------- 1 | import { createNewParserState, ParserState } from "./ParserState"; 2 | import ts from "typescript"; 3 | import { parseTypeDefinition } from "./parseTypeDefinition"; 4 | 5 | /** 6 | * A function that creates a unique string for a given interface or object type, 7 | * from a fresh parser state. This should return the same string for two semantically 8 | * identically types, allowing us to re-use existing helper types if the generated 9 | * strings match. 10 | **/ 11 | export const getCanonicalTypeName = (state: ParserState, type: ts.Type) => { 12 | const cachedName = state.canonicalTypeNames.get(type); 13 | if (cachedName) { 14 | return cachedName; 15 | } else { 16 | const tmpState = createNewParserState(state.typechecker, state.config); 17 | parseTypeDefinition(tmpState, "TS2PyTmpType", type); 18 | const result = tmpState.statements.join("\n"); 19 | state.canonicalTypeNames.set(type, result); 20 | return result; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | 2 | export type Ts2PyConfig = { 3 | nullableOptionals?: boolean; 4 | } 5 | -------------------------------------------------------------------------------- /src/getDocumentationStringForType.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | export const getDocumentationStringForType = ( 4 | typechecker: ts.TypeChecker, 5 | type: ts.Type, 6 | ) => { 7 | const jsDocStrings = type.aliasSymbol 8 | ?.getDocumentationComment(typechecker) 9 | .map((v) => v.text); 10 | if (jsDocStrings !== undefined && jsDocStrings?.length > 0) { 11 | return `${jsDocStrings.join("\n")}`; 12 | } else { 13 | return undefined; 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import ts from "typescript"; 4 | import path from "path"; 5 | import { program } from "@commander-js/extra-typings"; 6 | import { typeScriptToPython } from "./typeScriptToPython"; 7 | import { Ts2PyConfig } from "./config"; 8 | import { readFileSync } from "fs"; 9 | 10 | const compile = (fileNames: string[], config: Ts2PyConfig & {strict?: boolean}) => { 11 | const program = ts.createProgram(fileNames, { 12 | noEmit: true, 13 | allowJs: true, 14 | resolveJsonModule: true, 15 | skipLibCheck: true, 16 | strict: config.strict, 17 | }); 18 | 19 | const relevantSourceFiles = program 20 | .getSourceFiles() 21 | .filter((f) => 22 | fileNames 23 | .map((fn) => path.relative(fn, f.fileName) === "") 24 | .reduce((a, b) => a || b), 25 | ); 26 | const transpiled = typeScriptToPython(program.getTypeChecker(), relevantSourceFiles, config) 27 | console.log(transpiled); 28 | } 29 | 30 | program 31 | .name("typescript2python") 32 | .description("A program that converts TypeScript type definitions to Python") 33 | .option("--nullable-optionals", "if set, optional entries in dictionaries will be nullable, e.g. `NotRequired[Optional[T]]`") 34 | .option("--strict", "Enable all strict type-checking options.") 35 | .arguments("") 36 | .action((args, options) => { 37 | compile(args, options) 38 | }) 39 | .parse(process.argv) 40 | 41 | -------------------------------------------------------------------------------- /src/isValidPythonIdentifier.ts: -------------------------------------------------------------------------------- 1 | const validNameRegex = /^[a-zA-Z_][\w]*$/; 2 | 3 | export const isValidPythonIdentifier = (name: string) => { 4 | return !!name.match(validNameRegex); 5 | }; 6 | -------------------------------------------------------------------------------- /src/newHelperTypeName.ts: -------------------------------------------------------------------------------- 1 | import { ParserState } from "./ParserState"; 2 | import ts from "typescript"; 3 | import { createHash } from "node:crypto" 4 | import { getCanonicalTypeName } from "./canonicalTypeName"; 5 | 6 | export const newHelperTypeName = (state: ParserState, type: ts.Type) => { 7 | // to keep helper type names predictable and not dependent on the order of definition, 8 | // we use the first 10 characters of a sha256 hash of the type. If there is an unexpected 9 | // collision, we fallback to using an incrementing counter. 10 | const fullHash = createHash("sha256").update(getCanonicalTypeName(state, type)); 11 | // for the short hash, we remove all non-alphanumeric characters from the hash and take the 12 | // first 10 characters. 13 | let shortHash = fullHash.digest("base64").replace(/\W/g, '').substring(0, 10); 14 | if (state.helperTypeNames.has(shortHash) && state.helperTypeNames.get(shortHash) !== type) { 15 | shortHash = "HelperType" + state.helperTypeNames.size.toString(); 16 | } else { 17 | state.helperTypeNames.set(shortHash, type); 18 | } 19 | return `Ts2Py_${shortHash}`; 20 | }; 21 | -------------------------------------------------------------------------------- /src/parseExports.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { ParserState } from "./ParserState"; 3 | import { parseTypeDefinition } from "./parseTypeDefinition"; 4 | import { Ts2PyConfig } from "./config"; 5 | 6 | export function parseExports(state: ParserState, sourceFile: ts.SourceFile) { 7 | for (const statement of sourceFile.statements) { 8 | if ( 9 | ts.isTypeAliasDeclaration(statement) || 10 | ts.isInterfaceDeclaration(statement) 11 | ) { 12 | const isExported = !!( 13 | ts.getCombinedModifierFlags(statement) & ts.ModifierFlags.Export 14 | ); 15 | if (isExported) { 16 | const name = statement.name.getText(); 17 | const type = state.typechecker.getTypeAtLocation(statement); 18 | parseTypeDefinition(state, name, type); 19 | } 20 | } 21 | } 22 | 23 | return state.statements; 24 | } 25 | -------------------------------------------------------------------------------- /src/parseInlineType.ts: -------------------------------------------------------------------------------- 1 | import ts, { TypeFlags } from "typescript"; 2 | import { ParserState } from "./ParserState"; 3 | import { newHelperTypeName } from "./newHelperTypeName"; 4 | import { parseTypeDefinition } from "./parseTypeDefinition"; 5 | import { getCanonicalTypeName } from "./canonicalTypeName"; 6 | 7 | export const parseInlineType = (state: ParserState, type: ts.Type) => { 8 | const result = tryToParseInlineType(state, type); 9 | if (result !== undefined) { 10 | return result; 11 | } else { 12 | throw new Error(`could not parse type`); 13 | } 14 | }; 15 | 16 | export const tryToParseInlineType = ( 17 | state: ParserState, 18 | type: ts.Type, 19 | globalScope?: boolean, 20 | ): string | undefined => { 21 | const known = state.knownTypes.get(type); 22 | 23 | if (known !== undefined) { 24 | return known; 25 | } else if (type === state.typechecker.getTrueType()) { 26 | state.imports.add("Literal"); 27 | return "Literal[True]" 28 | } else if (type === state.typechecker.getFalseType()) { 29 | state.imports.add("Literal"); 30 | return "Literal[False]" 31 | } else if (type === state.typechecker.getAnyType() || ((type.flags & TypeFlags.Unknown) !== 0)) { 32 | state.imports.add("Any"); 33 | return "Any"; 34 | } else if (type.getFlags() & (ts.TypeFlags.TypeParameter | ts.TypeFlags.TypeVariable)) { 35 | // we don't support types with generic type parameters 36 | return `object`; 37 | } else if (type.isLiteral()) { 38 | state.imports.add("Literal"); 39 | return `Literal[${JSON.stringify(type.value)}]`; 40 | } else if (type.isUnion()) { 41 | state.imports.add("Union"); 42 | return `Union[${type.types 43 | .map((v) => parseInlineType(state, v)) 44 | .join(",")}]`; 45 | } else if (state.typechecker.isTupleType(type)) { 46 | state.imports.add("Tuple"); 47 | return `Tuple[${state.typechecker 48 | .getTypeArguments(type as ts.TypeReference) 49 | .map((v) => parseInlineType(state, v)) 50 | .join(",")}]`; 51 | } else if (type.getStringIndexType() !== undefined) { 52 | state.imports.add("Dict"); 53 | return `Dict[str,${parseInlineType(state, type.getStringIndexType()!)}]`; 54 | } else if (state.typechecker.isArrayLikeType(type)) { 55 | const typeArguments = state.typechecker.getTypeArguments( 56 | type as ts.TypeReference, 57 | ); 58 | if (typeArguments.length === 1) { 59 | state.imports.add("List"); 60 | return `List[${parseInlineType(state, typeArguments[0]!)}]`; 61 | } else { 62 | // TODO: figure out why we reach this and replace with correct type definition 63 | return `object`; 64 | } 65 | } else { 66 | // assume interface or object, we need to create a helper type 67 | if (!globalScope) { 68 | const canonicalName = getCanonicalTypeName(state, type); 69 | const semanticallyIdenticalType = state.knownTypes.get(canonicalName); 70 | if (semanticallyIdenticalType !== undefined) { 71 | // we can re-use an existing helper type 72 | return semanticallyIdenticalType; 73 | } else { 74 | // we must create a new type 75 | const helperName = newHelperTypeName(state, type); 76 | parseTypeDefinition(state, helperName, type); 77 | state.knownTypes.set(canonicalName, helperName); 78 | return helperName; 79 | } 80 | } else { 81 | // type cannot be defined inline 82 | return undefined; 83 | } 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /src/parseProperty.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { ParserState } from "./ParserState"; 3 | import { parseInlineType } from "./parseInlineType"; 4 | 5 | export const parseProperty = (state: ParserState, symbol: ts.Symbol) => { 6 | const name = symbol.getName(); 7 | const documentation = symbol 8 | .getDocumentationComment(state.typechecker) 9 | .map((v) => v.text) 10 | .join("\n"); 11 | const documentationSuffix = documentation 12 | ? `\n """\n ${documentation.replaceAll("\n", "\n ")}\n """` 13 | : ""; 14 | 15 | if (symbol.flags & ts.SymbolFlags.Optional) { 16 | state.imports.add("NotRequired"); 17 | const definition = parseInlineType( 18 | state, 19 | // since the entry is already options, the inner type can be non-nullable 20 | state.typechecker.getNonNullableType(state.typechecker.getTypeOfSymbol(symbol)), 21 | ); 22 | if (state.config.nullableOptionals) { 23 | state.imports.add("Optional"); 24 | return `${name}: NotRequired[Optional[${definition}]]${documentationSuffix}`; 25 | } else { 26 | return `${name}: NotRequired[${definition}]${documentationSuffix}`; 27 | } 28 | } else { 29 | const definition = parseInlineType( 30 | state, 31 | // since the entry is already options, the inner type can be non-nullable 32 | state.typechecker.getTypeOfSymbol(symbol), 33 | ); 34 | return `${name}: ${definition}${documentationSuffix}`; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/parseTypeDefinition.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { ParserState } from "./ParserState"; 3 | import { parseProperty } from "./parseProperty"; 4 | import { getDocumentationStringForType } from "./getDocumentationStringForType"; 5 | import { tryToParseInlineType } from "./parseInlineType"; 6 | import { isValidPythonIdentifier } from "./isValidPythonIdentifier"; 7 | 8 | export const parseTypeDefinition = ( 9 | state: ParserState, 10 | name: string, 11 | type: ts.Type, 12 | ) => { 13 | const inlineType = tryToParseInlineType(state, type, true); 14 | const documentation = getDocumentationStringForType(state.typechecker, type); 15 | 16 | if (!state.knownTypes.has(type)) { 17 | // we set the currently parsed type here to prevent recursions 18 | state.knownTypes.set(type, name); 19 | } 20 | 21 | if (inlineType) { 22 | const definition = `${name} = ${inlineType}${ 23 | documentation ? `\n"""\n${documentation}\n"""` : "" 24 | }`; 25 | state.statements.push(definition); 26 | } else { 27 | state.imports.add("TypedDict"); 28 | 29 | const properties = type 30 | .getProperties() 31 | .filter((v) => isValidPythonIdentifier(v.getName())) 32 | .map((v) => parseProperty(state, v)); 33 | 34 | const definition = `class ${name}(TypedDict):${ 35 | documentation 36 | ? `\n """\n ${documentation.replaceAll("\n", " \n")}\n """` 37 | : "" 38 | }\n ${properties.length > 0 ? properties.join(`\n `) : "pass"}`; 39 | 40 | state.statements.push(definition); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/testing/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { transpileString } from "./utils"; 2 | 3 | describe("transpiling basic types", () => { 4 | it.each([ 5 | ["export type T = boolean;", "T = bool"], 6 | ["export type T = number;", "T = float"], 7 | ["export type T = string;", "T = str"], 8 | ["export type T = undefined;", "T = None"], 9 | ["export type T = void;", "T = None"], 10 | ["export type T = null;", "T = None"], 11 | [ 12 | "export type T = true;", 13 | "from typing_extensions import Literal\n\nT = Literal[True]", 14 | ], 15 | [ 16 | "export type T = false;", 17 | "from typing_extensions import Literal\n\nT = Literal[False]", 18 | ], 19 | [ 20 | "export type T = 42;", 21 | "from typing_extensions import Literal\n\nT = Literal[42]", 22 | ], 23 | [ 24 | "export type T = 'foo';", 25 | 'from typing_extensions import Literal\n\nT = Literal["foo"]', 26 | ], 27 | [ 28 | "export type T = {[key: string]: boolean};", 29 | "from typing_extensions import Dict\n\nT = Dict[str,bool]", 30 | ], 31 | [ 32 | "export type T = {[key: string]: number};", 33 | "from typing_extensions import Dict\n\nT = Dict[str,float]", 34 | ], 35 | [ 36 | "export type T = [string, number]", 37 | "from typing_extensions import Tuple\n\nT = Tuple[str,float]", 38 | ], 39 | [ 40 | "export type T = number[]", 41 | "from typing_extensions import List\n\nT = List[float]", 42 | ], 43 | ["export type T = any;", "from typing_extensions import Any\n\nT = Any"], 44 | [ 45 | "export type T = unknown;", 46 | "from typing_extensions import Any\n\nT = Any", 47 | ], 48 | [ 49 | "export type T = {[key: string]: {[key: string]: number}};", 50 | "from typing_extensions import Dict\n\nT = Dict[str,Dict[str,float]]", 51 | ], 52 | [ 53 | "export type T = Record;", 54 | "from typing_extensions import Dict\n\nT = Dict[str,float]", 55 | ], 56 | [ 57 | "export type T = number | string | Record", 58 | "from typing_extensions import Dict, Union\n\nT = Union[str,float,Dict[str,bool]]", 59 | ], 60 | [ 61 | "export type T = number | undefined", 62 | // without strict mode the `undefined` gets lost here 63 | "T = float", 64 | ], 65 | ])("transpiles %p to %p", async (input, expected) => { 66 | const result = await transpileString(input); 67 | expect(result).toEqual(expected); 68 | }); 69 | 70 | it.each([ 71 | [ 72 | "export type T = number | undefined", 73 | "from typing_extensions import Union\n\nT = Union[None,float]", 74 | ], 75 | [ 76 | "export type T = number | null", 77 | "from typing_extensions import Union\n\nT = Union[None,float]", 78 | ], 79 | ])("transpiles %p to %p when strict", async (input, expected) => { 80 | const result = await transpileString(input, {}, { strict: true }); 81 | expect(result).toEqual(expected); 82 | }); 83 | 84 | it("only transpiles exported types", async () => { 85 | const result = await transpileString(` 86 | type NotExported = number; 87 | const notExported: NotExported = 42; 88 | export type Exported = number; 89 | export const exported: Exported = 42; 90 | `); 91 | expect(result).not.toContain("NotExported"); 92 | expect(result).not.toContain("exported"); 93 | expect(result).toContain("Exported = float"); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/testing/dicts.test.ts: -------------------------------------------------------------------------------- 1 | import { transpileString } from "./utils"; 2 | 3 | describe("transpiling dictionaries types", () => { 4 | it("can transpile dicts", async () => { 5 | const result = await transpileString(`export type A = { 6 | foo: string, 7 | bar: number, 8 | }`); 9 | expect(result).toContain(`class A(TypedDict):\n foo: str\n bar: float`); 10 | }); 11 | 12 | it("keeps docstrings", async () => { 13 | const result = await transpileString(` 14 | /** This is A */ 15 | export type A = { 16 | /** this is foo */ 17 | foo: string, 18 | /** this is bar */ 19 | bar: number, 20 | } 21 | `); 22 | expect(result).toContain( 23 | `class A(TypedDict): 24 | """ 25 | This is A 26 | """ 27 | foo: str 28 | """ 29 | this is foo 30 | """ 31 | bar: float 32 | """ 33 | this is bar 34 | """`, 35 | ); 36 | }); 37 | 38 | it("can transpile nested dicts", async () => { 39 | const result = await transpileString(`export type A = { 40 | outer: { 41 | inner: string 42 | }, 43 | extra: number, 44 | }`); 45 | expect(result).toContain( 46 | `class Ts2Py_FOZhdT9ykh(TypedDict): 47 | inner: str 48 | 49 | class A(TypedDict): 50 | outer: Ts2Py_FOZhdT9ykh 51 | extra: float`, 52 | ); 53 | }); 54 | 55 | it("can transpile intersections", async () => { 56 | const result = await transpileString( 57 | `export type A = { foo: string } & { bar: number }`, 58 | ); 59 | expect(result).toContain(`class A(TypedDict):\n foo: str\n bar: float`); 60 | }); 61 | 62 | it("transpiles optional values as NotRequired[Optional[T]]", async () => { 63 | const result = await transpileString(`export type A = { foo?: string }`); 64 | expect(result).toContain(`class A(TypedDict):\n foo: NotRequired[str]`); 65 | }); 66 | 67 | it("transpiles optional values as NotRequired[Optional[T]] in strict mode", async () => { 68 | const result = await transpileString( 69 | `export type A = { foo?: string }`, 70 | {}, 71 | { strict: true }, 72 | ); 73 | expect(result).toContain(`class A(TypedDict):\n foo: NotRequired[str]`); 74 | }); 75 | 76 | it("transpiles optional values with non-null optionals as NotRequired[T]", async () => { 77 | const result = await transpileString(`export type A = { foo?: string }`, { 78 | nullableOptionals: true, 79 | }); 80 | expect(result).toContain( 81 | `class A(TypedDict):\n foo: NotRequired[Optional[str]]`, 82 | ); 83 | }); 84 | 85 | it("transpiles records as dicts", async () => { 86 | const result = await transpileString( 87 | `export type A = Record<"foo" | "bar", number>`, 88 | ); 89 | expect(result).toContain(`class A(TypedDict):\n foo: float\n bar: float`); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/testing/generics.test.ts: -------------------------------------------------------------------------------- 1 | import { transpileString } from "./utils"; 2 | 3 | describe("transpiling generic types", () => { 4 | it("does not transpile generic types", async () => { 5 | const transpiled = await transpileString( 6 | `export type Generic = T;`, 7 | ); 8 | expect(transpiled).toContain("Generic = object"); 9 | }); 10 | 11 | it("does transpile fully narrowed generic types", async () => { 12 | const transpiled = await transpileString(` 13 | export type Generic = T; 14 | export type Narrowed = Generic; 15 | `); 16 | expect(transpiled).toContain("Generic = object"); 17 | expect(transpiled).toContain("Narrowed = str"); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/testing/helperTypes.test.ts: -------------------------------------------------------------------------------- 1 | import { transpileString } from "./utils"; 2 | 3 | describe("creating helper types", () => { 4 | it("converts nested types to helper types", async () => { 5 | const result = await transpileString(` 6 | export type T = { inner: { foo: { bar: string } } } 7 | `); 8 | 9 | expect(result).toEqual( 10 | `from typing_extensions import TypedDict 11 | 12 | class Ts2Py_rTIa1O0osy(TypedDict): 13 | bar: str 14 | 15 | class Ts2Py_v6EwABEDVq(TypedDict): 16 | foo: Ts2Py_rTIa1O0osy 17 | 18 | class T(TypedDict): 19 | inner: Ts2Py_v6EwABEDVq`, 20 | ); 21 | }); 22 | 23 | it("reuses identical helper types", async () => { 24 | const result = await transpileString(` 25 | export type A = { a: { foo: { bar: string } } } 26 | export type B = { b: { foo: { bar: string } } } 27 | export type C = { foo: { bar: string } } 28 | `); 29 | 30 | expect(result).toEqual(`from typing_extensions import TypedDict 31 | 32 | class Ts2Py_rTIa1O0osy(TypedDict): 33 | bar: str 34 | 35 | class Ts2Py_v6EwABEDVq(TypedDict): 36 | foo: Ts2Py_rTIa1O0osy 37 | 38 | class A(TypedDict): 39 | a: Ts2Py_v6EwABEDVq 40 | 41 | class B(TypedDict): 42 | b: Ts2Py_v6EwABEDVq 43 | 44 | class C(TypedDict): 45 | foo: Ts2Py_rTIa1O0osy`); 46 | }); 47 | 48 | it("always uses the same helper type names", async () => { 49 | const result1 = await transpileString(` 50 | export type A = { a: { foo: { bar: string } } } 51 | `); 52 | const result2 = await transpileString(` 53 | export type B = { b: { bar: { foo: string } } } 54 | export type A = { a: { foo: { bar: string } } } 55 | `); 56 | 57 | // the type hashes will be the same, no matter if we define other helper types before 58 | const expectedAType = `class Ts2Py_rTIa1O0osy(TypedDict): 59 | bar: str 60 | 61 | class Ts2Py_v6EwABEDVq(TypedDict): 62 | foo: Ts2Py_rTIa1O0osy 63 | 64 | class A(TypedDict): 65 | a: Ts2Py_v6EwABEDVq`; 66 | 67 | expect(result1).toEqual(`from typing_extensions import TypedDict 68 | 69 | ${expectedAType}`); 70 | 71 | expect(result2).toEqual(`from typing_extensions import TypedDict 72 | 73 | class Ts2Py_9ZFaik8GRM(TypedDict): 74 | foo: str 75 | 76 | class Ts2Py_g2bOy1R1LY(TypedDict): 77 | bar: Ts2Py_9ZFaik8GRM 78 | 79 | class B(TypedDict): 80 | b: Ts2Py_g2bOy1R1LY 81 | 82 | ${expectedAType}`); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/testing/imports.test.ts: -------------------------------------------------------------------------------- 1 | import { typeScriptToPython } from "../typeScriptToPython"; 2 | import { createProject, ts } from "@ts-morph/bootstrap"; 3 | 4 | describe("transpiling referenced types", () => { 5 | it("can refer to types defined in other files", async () => { 6 | const project = await createProject(); 7 | 8 | project.createSourceFile("foo.ts", `export type Foo = { foo: number }`); 9 | const barSource = project.createSourceFile( 10 | "bar.ts", 11 | `import {Foo} from './foo';\nexport type Bar = {foo: Foo}`, 12 | ); 13 | 14 | const program = project.createProgram(); 15 | const diagnostics = ts.getPreEmitDiagnostics(program); 16 | 17 | if (diagnostics.length > 0) { 18 | throw new Error( 19 | `code compiled with errors: ${project.formatDiagnosticsWithColorAndContext( 20 | diagnostics, 21 | )}`, 22 | ); 23 | } 24 | 25 | const transpiled = typeScriptToPython( 26 | program.getTypeChecker(), 27 | [barSource], 28 | {}, 29 | ); 30 | expect(transpiled).toEqual( 31 | `from typing_extensions import TypedDict 32 | 33 | class Ts2Py_vxK3pg8Yk2(TypedDict): 34 | foo: float 35 | 36 | class Bar(TypedDict): 37 | foo: Ts2Py_vxK3pg8Yk2`, 38 | ); 39 | }); 40 | 41 | it("doesn't get confused if imported types have the same name", async () => { 42 | const project = await createProject(); 43 | 44 | project.createSourceFile( 45 | "foo.ts", 46 | ` 47 | type Content = { publicFoo: "foo" }; 48 | export type Foo = { foo: Content } 49 | `, 50 | ); 51 | project.createSourceFile( 52 | "bar.ts", 53 | ` 54 | type Content = { publicBar: "bar" }; 55 | export type Bar = { bar: Content } 56 | `, 57 | ); 58 | const commonSource = project.createSourceFile( 59 | "common.ts", 60 | ` 61 | import {Foo} from './foo'; 62 | import {Bar} from './bar'; 63 | export type FooBar = { foo: Foo, bar: Bar } 64 | `, 65 | ); 66 | const program = project.createProgram(); 67 | const diagnostics = ts.getPreEmitDiagnostics(program); 68 | 69 | if (diagnostics.length > 0) { 70 | throw new Error( 71 | `code compiled with errors: ${project.formatDiagnosticsWithColorAndContext( 72 | diagnostics, 73 | )}`, 74 | ); 75 | } 76 | 77 | const transpiled = typeScriptToPython( 78 | program.getTypeChecker(), 79 | [commonSource], 80 | {}, 81 | ); 82 | expect(transpiled).toContain(`publicFoo: Literal["foo"]`); 83 | expect(transpiled).toContain(`publicBar: Literal["bar"]`); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/testing/readme.test.ts: -------------------------------------------------------------------------------- 1 | import { transpileString } from "./utils"; 2 | 3 | describe("readme", () => { 4 | it("transpiles the readme example", async () => { 5 | const result = await transpileString(` 6 | export type Foo = { 7 | type: "foo" 8 | foo: number[] 9 | optional?: string 10 | } 11 | 12 | /** DocStrings are supported! */ 13 | export type Bar = { 14 | type: "bar" 15 | bar: string 16 | /** nested objects need extra declarations in Python */ 17 | nested: { 18 | foo: Foo 19 | } 20 | } 21 | 22 | export type FooBarMap = { 23 | [key: string]: Foo | Bar 24 | } 25 | 26 | export type TupleType = [string, Foo | Bar, any[]] 27 | `); 28 | 29 | // note: if this needs to be updated, be sure to update the readme as well 30 | expect(result) 31 | .toEqual(`from typing_extensions import Any, Dict, List, Literal, NotRequired, Tuple, TypedDict, Union 32 | 33 | class Foo(TypedDict): 34 | type: Literal["foo"] 35 | foo: List[float] 36 | optional: NotRequired[str] 37 | 38 | class Ts2Py_tliGTOBrDv(TypedDict): 39 | foo: Foo 40 | 41 | class Bar(TypedDict): 42 | """ 43 | DocStrings are supported! 44 | """ 45 | type: Literal["bar"] 46 | bar: str 47 | nested: Ts2Py_tliGTOBrDv 48 | """ 49 | nested objects need extra declarations in Python 50 | """ 51 | 52 | FooBarMap = Dict[str,Union[Foo,Bar]] 53 | 54 | TupleType = Tuple[str,Union[Foo,Bar],List[Any]]`); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/testing/reference.test.ts: -------------------------------------------------------------------------------- 1 | import { transpileString } from "./utils"; 2 | 3 | describe("transpiling referenced types", () => { 4 | it("can refer to previously defined types", async () => { 5 | const result = await transpileString(` 6 | type A = { foo: number } 7 | type B = A | { [key: string]: boolean } | { bar: string } 8 | export type C = { flat: number, outer: B } 9 | `); 10 | expect(result).toEqual( 11 | `from typing_extensions import Dict, TypedDict, Union 12 | 13 | class Ts2Py_vxK3pg8Yk2(TypedDict): 14 | foo: float 15 | 16 | class Ts2Py_rTIa1O0osy(TypedDict): 17 | bar: str 18 | 19 | class C(TypedDict): 20 | flat: float 21 | outer: Union[Ts2Py_vxK3pg8Yk2,Dict[str,bool],Ts2Py_rTIa1O0osy]`, 22 | ); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/testing/utils.ts: -------------------------------------------------------------------------------- 1 | import { Ts2PyConfig } from "../config"; 2 | import { typeScriptToPython } from "../typeScriptToPython"; 3 | import { createProject, ts } from "@ts-morph/bootstrap"; 4 | 5 | /** 6 | * We create only a single global project to improve performance when sequentially 7 | * transpiling multiple files. 8 | **/ 9 | let globalProject: ReturnType | undefined; 10 | 11 | /** Each file should get a unique name to avoid issues. */ 12 | let i = 0; 13 | 14 | export const transpileString = async ( 15 | code: string, 16 | config: Ts2PyConfig = {}, 17 | compilerOptions: ts.CompilerOptions = {}, 18 | ) => { 19 | if (globalProject === undefined) { 20 | globalProject = createProject({ 21 | useInMemoryFileSystem: true, 22 | }); 23 | } 24 | 25 | const project = await globalProject; 26 | const fileName = `source_${i++}.ts`; 27 | 28 | // instead of adding a new source file for each program, we update the existing one. 29 | const sourceFile = project.updateSourceFile(fileName, code); 30 | const program = project.createProgram({ 31 | rootNames: [fileName], 32 | options: { ...project.compilerOptions, ...compilerOptions }, 33 | }); 34 | const diagnostics = ts.getPreEmitDiagnostics(program); 35 | 36 | if (diagnostics.length > 0) { 37 | throw new Error( 38 | `code compiled with errors: ${project.formatDiagnosticsWithColorAndContext( 39 | diagnostics, 40 | )}`, 41 | ); 42 | } 43 | 44 | return typeScriptToPython(program.getTypeChecker(), [sourceFile], config); 45 | }; 46 | -------------------------------------------------------------------------------- /src/typeScriptToPython.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { createNewParserState } from "./ParserState"; 3 | import { parseExports } from "./parseExports"; 4 | import { Ts2PyConfig } from "./config"; 5 | 6 | /** 7 | * Transpiles the types exported by the source files into Python. 8 | */ 9 | export const typeScriptToPython = (typechecker: ts.TypeChecker, sourceFiles: ts.SourceFile[], config: Ts2PyConfig) => { 10 | const state = createNewParserState(typechecker, config); 11 | 12 | sourceFiles.forEach((f) => { 13 | parseExports(state, f); 14 | }); 15 | 16 | let importStatement = "" 17 | if (state.imports.size > 0) { 18 | importStatement = `from typing_extensions import ${Array.from(state.imports).sort().join(", ")}\n\n` 19 | } 20 | 21 | return importStatement + state.statements.join("\n\n"); 22 | }; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "noEmit": true, 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "downlevelIteration": true, 15 | "noUncheckedIndexedAccess": true, 16 | "noImplicitAny": true, 17 | "outDir": "dist" 18 | }, 19 | "include": ["src"], 20 | "exclude": ["./node_modules"] 21 | } 22 | --------------------------------------------------------------------------------