Skip to content

Commit b9c9033

Browse files
authored
BREAKING CHANGE: make optional dict values non-nullable by default (#12)
* add extra option for non-nullable optional keys * make non-nullable optionals the default behavor * fix type error
1 parent f969f02 commit b9c9033

File tree

11 files changed

+76
-21
lines changed

11 files changed

+76
-21
lines changed

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ from typing_extensions import Any, Dict, List, Literal, NotRequired, Optional, T
4545
class Foo(TypedDict):
4646
type: Literal["foo"]
4747
foo: List[float]
48-
optional: NotRequired[Optional[str]]
48+
optional: NotRequired[str]
4949

5050
class Ts2PyHelperType1(TypedDict):
5151
foo: Foo
@@ -89,9 +89,16 @@ TypeScript2Python supports many of TypeScripts type constructs, including:
8989
- Unions, `string | number`
9090
- Arrays, `boolean[]`
9191
- Nested objects `{ bar: { foo: string } }`, that will get transpiled into helper dictionaries
92-
- Optional properties `{ optional?: number }`, that get transpiled to `NotRequired[Optional[...]]` attributes
92+
- Optional properties `{ optional?: number }`, that get transpiled to `NotRequired[...]` attributes
9393
- Docstrings `/** this is very useful */`
9494
95+
## Transpilation options
96+
97+
### Nullable optionals
98+
99+
In TypeScript objects, optional values can also be set to `undefined`. By default we assume the according Python
100+
type to be non-nullable, but a more closely matching behavior can be achieved using the flag `--nullable-optionals`.
101+
95102
## Limitations
96103
97104
We currently do not support the following features:

src/ParserState.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import ts from "typescript";
2+
import { Ts2PyConfig } from "./config";
23

34
export type ParserState = {
45
helperCount: number;
56
statements: string[];
67
typechecker: ts.TypeChecker;
78
knownTypes: Map<ts.Type | string, string>;
89
imports: Set<string>;
10+
config: Ts2PyConfig;
911
};
1012

11-
export const createNewParserState = (typechecker: ts.TypeChecker): ParserState => {
13+
export const createNewParserState = (typechecker: ts.TypeChecker, config: Ts2PyConfig): ParserState => {
1214
const knownTypes = new Map<ts.Type, string>();
1315
knownTypes.set(typechecker.getVoidType(), "None");
1416
knownTypes.set(typechecker.getUndefinedType(), "None");
@@ -22,5 +24,6 @@ export const createNewParserState = (typechecker: ts.TypeChecker): ParserState =
2224
typechecker,
2325
knownTypes,
2426
imports: new Set(),
27+
config,
2528
};
2629
}

src/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
2+
export type Ts2PyConfig = {
3+
nullableOptionals?: boolean;
4+
}

src/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import ts from "typescript";
44
import path from "path";
55
import { program } from "@commander-js/extra-typings";
66
import { typeScriptToPython } from "./typeScriptToPython";
7+
import { Ts2PyConfig } from "./config";
78

8-
const compile = (fileNames: string[]) => {
9+
const compile = (fileNames: string[], config: Ts2PyConfig) => {
910
const program = ts.createProgram(fileNames, {
1011
noEmit: true,
1112
allowJs: true,
@@ -21,16 +22,17 @@ const compile = (fileNames: string[]) => {
2122
.reduce((a, b) => a || b),
2223
);
2324

24-
const transpiled = typeScriptToPython(program.getTypeChecker(), relevantSourceFiles)
25+
const transpiled = typeScriptToPython(program.getTypeChecker(), relevantSourceFiles, config)
2526
console.log(transpiled);
2627
}
2728

2829
program
2930
.name("typescript2python")
3031
.description("A program that converts TypeScript type definitions to Python")
32+
.option("--nullable-optionals", "if set, optional entries in dictionaries will be nullable, e.g. `NotRequired[Optional[T]]`")
3133
.arguments("<input...>")
32-
.action(args => {
33-
compile(args)
34+
.action((args, options) => {
35+
compile(args, options)
3436
})
3537
.parse(process.argv)
3638

src/parseExports.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import ts from "typescript";
22
import { ParserState } from "./ParserState";
33
import { parseTypeDefinition } from "./parseTypeDefinition";
4+
import { Ts2PyConfig } from "./config";
45

56
export function parseExports(state: ParserState, sourceFile: ts.SourceFile) {
67
for (const statement of sourceFile.statements) {

src/parseInlineType.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const parseInlineType = (state: ParserState, type: ts.Type) => {
1919
* strings match.
2020
**/
2121
const getCanonicalTypeName = (state: ParserState, type: ts.Type) => {
22-
const tmpState = createNewParserState(state.typechecker);
22+
const tmpState = createNewParserState(state.typechecker, state.config);
2323
parseTypeDefinition(tmpState, "TS2PyTmpType", type);
2424
return tmpState.statements.join("\n")
2525
}

src/parseProperty.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ export const parseProperty = (state: ParserState, symbol: ts.Symbol) => {
1818

1919
if (symbol.flags & ts.SymbolFlags.Optional) {
2020
state.imports.add("NotRequired");
21-
state.imports.add("Optional");
22-
return `${name}: NotRequired[Optional[${definition}]]${documentationSuffix}`;
21+
if (state.config.nullableOptionals) {
22+
state.imports.add("Optional");
23+
return `${name}: NotRequired[Optional[${definition}]]${documentationSuffix}`;
24+
} else {
25+
return `${name}: NotRequired[${definition}]${documentationSuffix}`;
26+
}
2327
} else {
2428
return `${name}: ${definition}${documentationSuffix}`;
2529
}

src/testing/dicts.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,18 @@ class A(TypedDict):
5858
);
5959
expect(result).toContain(`class A(TypedDict):\n foo: str\n bar: float`);
6060
});
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 with non-null optionals as NotRequired[T]", async () => {
68+
const result = await transpileString(`export type A = { foo?: string }`, {
69+
nullableOptionals: true,
70+
});
71+
expect(result).toContain(
72+
`class A(TypedDict):\n foo: NotRequired[Optional[str]]`,
73+
);
74+
});
6175
});

src/testing/imports.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ describe("transpiling referenced types", () => {
2222
);
2323
}
2424

25-
const transpiled = typeScriptToPython(program.getTypeChecker(), [
26-
barSource,
27-
]);
25+
const transpiled = typeScriptToPython(
26+
program.getTypeChecker(),
27+
[barSource],
28+
{},
29+
);
2830
expect(transpiled).toEqual(
2931
`from typing_extensions import TypedDict
3032

src/testing/utils.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
1+
import { Ts2PyConfig } from "../config";
12
import { typeScriptToPython } from "../typeScriptToPython";
23
import { createProject, ts } from "@ts-morph/bootstrap";
34

4-
export const transpileString = async (code: string) => {
5-
const project = await createProject();
6-
const fileName = "test.ts";
5+
/**
6+
* We create only a single global project to improve performance when sequentially
7+
* transpiling multiple files.
8+
**/
9+
let globalProject: ReturnType<typeof createProject> | undefined;
710

8-
const sourceFile = project.createSourceFile(fileName, code);
11+
export const transpileString = async (
12+
code: string,
13+
config: Ts2PyConfig = {},
14+
) => {
15+
if (globalProject === undefined) {
16+
globalProject = createProject({
17+
useInMemoryFileSystem: true,
18+
});
19+
}
20+
21+
const project = await globalProject;
22+
const fileName = `source.ts`;
23+
24+
// instead of adding a new source file for each program, we update the existing one.
25+
const sourceFile = project.updateSourceFile(fileName, code);
926
const program = project.createProgram();
1027
const diagnostics = ts.getPreEmitDiagnostics(program);
1128

@@ -17,5 +34,5 @@ export const transpileString = async (code: string) => {
1734
);
1835
}
1936

20-
return typeScriptToPython(program.getTypeChecker(), [sourceFile]);
37+
return typeScriptToPython(program.getTypeChecker(), [sourceFile], config);
2138
};

0 commit comments

Comments
 (0)