Skip to content

Commit 183b426

Browse files
feat: Improved Promise handling to support packages like Prisma (#1924)
1 parent 48363ef commit 183b426

File tree

13 files changed

+260
-17
lines changed

13 files changed

+260
-17
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ node_modules/
88
# local config for auto
99
.env
1010

11+
# Other package managers
12+
pnpm-lock.yaml
13+
package-lock.json

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ fs.writeFile(outputPath, schemaString, (err) => {
231231
- `keyof`
232232
- conditional types
233233
- functions
234+
- `Promise<T>` unwraps to `T`
234235

235236
## Run locally
236237

factory/parser.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import ts from "typescript";
1+
import type ts from "typescript";
22
import { BasicAnnotationsReader } from "../src/AnnotationsReader/BasicAnnotationsReader.js";
33
import { ExtendedAnnotationsReader } from "../src/AnnotationsReader/ExtendedAnnotationsReader.js";
44
import { ChainNodeParser } from "../src/ChainNodeParser.js";
55
import { CircularReferenceNodeParser } from "../src/CircularReferenceNodeParser.js";
6-
import { CompletedConfig } from "../src/Config.js";
6+
import type { CompletedConfig } from "../src/Config.js";
77
import { ExposeNodeParser } from "../src/ExposeNodeParser.js";
8-
import { MutableParser } from "../src/MutableParser.js";
9-
import { NodeParser } from "../src/NodeParser.js";
8+
import type { MutableParser } from "../src/MutableParser.js";
9+
import type { NodeParser } from "../src/NodeParser.js";
1010
import { AnnotatedNodeParser } from "../src/NodeParser/AnnotatedNodeParser.js";
1111
import { AnyTypeNodeParser } from "../src/NodeParser/AnyTypeNodeParser.js";
1212
import { ArrayLiteralExpressionNodeParser } from "../src/NodeParser/ArrayLiteralExpressionNodeParser.js";
@@ -55,9 +55,10 @@ import { UndefinedTypeNodeParser } from "../src/NodeParser/UndefinedTypeNodePars
5555
import { UnionNodeParser } from "../src/NodeParser/UnionNodeParser.js";
5656
import { UnknownTypeNodeParser } from "../src/NodeParser/UnknownTypeNodeParser.js";
5757
import { VoidTypeNodeParser } from "../src/NodeParser/VoidTypeNodeParser.js";
58-
import { SubNodeParser } from "../src/SubNodeParser.js";
58+
import type { SubNodeParser } from "../src/SubNodeParser.js";
5959
import { TopRefNodeParser } from "../src/TopRefNodeParser.js";
6060
import { SatisfiesNodeParser } from "../src/NodeParser/SatisfiesNodeParser.js";
61+
import { PromiseNodeParser } from "../src/NodeParser/PromiseNodeParser.js";
6162

6263
export type ParserAugmentor = (parser: MutableParser) => void;
6364

@@ -121,6 +122,7 @@ export function createParser(program: ts.Program, config: CompletedConfig, augme
121122
.addNodeParser(new LiteralNodeParser(chainNodeParser))
122123
.addNodeParser(new ParenthesizedNodeParser(chainNodeParser))
123124

125+
.addNodeParser(new PromiseNodeParser(typeChecker, chainNodeParser))
124126
.addNodeParser(new TypeReferenceNodeParser(typeChecker, chainNodeParser))
125127
.addNodeParser(new ExpressionWithTypeArgumentsNodeParser(typeChecker, chainNodeParser))
126128
.addNodeParser(new IndexedAccessTypeNodeParser(typeChecker, chainNodeParser))

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"@types/jest": "^29.5.12",
6666
"@types/node": "^20.12.7",
6767
"@types/normalize-path": "^3.0.2",
68+
"@types/ts-expose-internals": "npm:ts-expose-internals@^5.4.5",
6869
"ajv": "^8.12.0",
6970
"ajv-formats": "^3.0.1",
7071
"auto": "^11.1.6",
@@ -94,6 +95,5 @@
9495
"debug": "tsx --inspect-brk ts-json-schema-generator.ts",
9596
"run": "tsx ts-json-schema-generator.ts",
9697
"release": "yarn build && auto shipit"
97-
},
98-
"packageManager": "[email protected]"
98+
}
9999
}

src/NodeParser/FunctionNodeParser.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ export class FunctionNodeParser implements SubNodeParser {
1818
public supportsNode(node: ts.TypeNode): boolean {
1919
return (
2020
node.kind === ts.SyntaxKind.FunctionType ||
21+
// @ts-expect-error internals type bug
2122
node.kind === ts.SyntaxKind.FunctionExpression ||
23+
// @ts-expect-error internals type bug
2224
node.kind === ts.SyntaxKind.ArrowFunction ||
25+
// @ts-expect-error internals type bug
2326
node.kind === ts.SyntaxKind.FunctionDeclaration
2427
);
2528
}

src/NodeParser/PromiseNodeParser.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import ts from "typescript";
2+
import { Context, type NodeParser } from "../NodeParser.js";
3+
import type { SubNodeParser } from "../SubNodeParser.js";
4+
import { AliasType } from "../Type/AliasType.js";
5+
import type { BaseType } from "../Type/BaseType.js";
6+
import { DefinitionType } from "../Type/DefinitionType.js";
7+
import { getKey } from "../Utils/nodeKey.js";
8+
9+
/**
10+
* Needs to be registered before 261, 260, 230, 262 node kinds
11+
*/
12+
export class PromiseNodeParser implements SubNodeParser {
13+
public constructor(
14+
protected typeChecker: ts.TypeChecker,
15+
protected childNodeParser: NodeParser,
16+
) {}
17+
18+
public supportsNode(node: ts.Node): boolean {
19+
if (
20+
// 261 interface PromiseInterface extends Promise<T>
21+
!ts.isInterfaceDeclaration(node) &&
22+
// 260 class PromiseClass implements Promise<T>
23+
!ts.isClassDeclaration(node) &&
24+
// 230 Promise<T>
25+
!ts.isExpressionWithTypeArguments(node) &&
26+
// 262 type PromiseAlias = Promise<T>;
27+
!ts.isTypeAliasDeclaration(node)
28+
) {
29+
return false;
30+
}
31+
32+
const type = this.typeChecker.getTypeAtLocation(node);
33+
34+
const awaitedType = this.typeChecker.getAwaitedType(type);
35+
36+
// ignores non awaitable types
37+
if (!awaitedType) {
38+
return false;
39+
}
40+
41+
// If the awaited type differs from the original type, the type extends promise
42+
// Awaited<Promise<T>> -> T (Promise<T> !== T)
43+
// Awaited<Y> -> Y (Y === Y)
44+
if (awaitedType === type) {
45+
return false;
46+
}
47+
48+
// In types like: A<T> = T, type C = A<1>, C has the same type as A<1> and 1,
49+
// the awaitedType is NOT the same reference as the type, so a assignability
50+
// check is needed
51+
return (
52+
!this.typeChecker.isTypeAssignableTo(type, awaitedType) &&
53+
!this.typeChecker.isTypeAssignableTo(awaitedType, type)
54+
);
55+
}
56+
57+
public createType(
58+
node: ts.InterfaceDeclaration | ts.ClassDeclaration | ts.ExpressionWithTypeArguments | ts.TypeAliasDeclaration,
59+
context: Context,
60+
): BaseType {
61+
const type = this.typeChecker.getTypeAtLocation(node);
62+
const awaitedType = this.typeChecker.getAwaitedType(type)!; // supportsNode ensures this
63+
const awaitedNode = this.typeChecker.typeToTypeNode(awaitedType, undefined, ts.NodeBuilderFlags.IgnoreErrors);
64+
65+
if (!awaitedNode) {
66+
throw new Error(
67+
`Could not find awaited node for type ${node.pos === -1 ? "<unresolved>" : node.getText()}`,
68+
);
69+
}
70+
71+
const baseNode = this.childNodeParser.createType(awaitedNode, new Context(node));
72+
73+
const name = this.getNodeName(node);
74+
75+
// Nodes without name should just be their awaited type
76+
// export class extends Promise<T> {} -> T
77+
// export class A extends Promise<T> {} -> A (ref to T)
78+
if (!name) {
79+
return baseNode;
80+
}
81+
82+
return new DefinitionType(name, new AliasType(`promise-${getKey(node, context)}`, baseNode));
83+
}
84+
85+
private getNodeName(
86+
node: ts.InterfaceDeclaration | ts.ClassDeclaration | ts.ExpressionWithTypeArguments | ts.TypeAliasDeclaration,
87+
) {
88+
if (ts.isExpressionWithTypeArguments(node)) {
89+
if (!ts.isHeritageClause(node.parent)) {
90+
throw new Error("Expected ExpressionWithTypeArguments to have a HeritageClause parent");
91+
}
92+
93+
return node.parent.parent.name?.getText();
94+
}
95+
96+
return node.name?.getText();
97+
}
98+
}

src/NodeParser/TypeReferenceNodeParser.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import ts from "typescript";
2-
3-
import { Context, NodeParser } from "../NodeParser.js";
2+
import { Context, type NodeParser } from "../NodeParser.js";
43
import type { SubNodeParser } from "../SubNodeParser.js";
54
import { AnnotatedType } from "../Type/AnnotatedType.js";
65
import { AnyType } from "../Type/AnyType.js";
@@ -31,11 +30,6 @@ export class TypeReferenceNodeParser implements SubNodeParser {
3130
// property on the node itself.
3231
(node.typeName as unknown as ts.Type).symbol;
3332

34-
// Wraps promise type to avoid resolving to a empty Object type.
35-
if (typeSymbol.name === "Promise") {
36-
return this.childNodeParser.createType(node.typeArguments![0], this.createSubContext(node, context));
37-
}
38-
3933
if (typeSymbol.flags & ts.SymbolFlags.Alias) {
4034
const aliasedSymbol = this.typeChecker.getAliasedSymbol(typeSymbol);
4135

@@ -53,6 +47,16 @@ export class TypeReferenceNodeParser implements SubNodeParser {
5347
return context.getArgument(typeSymbol.name);
5448
}
5549

50+
// Wraps promise type to avoid resolving to a empty Object type.
51+
if (typeSymbol.name === "Promise" || typeSymbol.name === "PromiseLike") {
52+
// Promise without type resolves to Promise<any>
53+
if (!node.typeArguments || node.typeArguments.length === 0) {
54+
return new AnyType();
55+
}
56+
57+
return this.childNodeParser.createType(node.typeArguments[0], this.createSubContext(node, context));
58+
}
59+
5660
if (typeSymbol.name === "Array" || typeSymbol.name === "ReadonlyArray") {
5761
const type = this.createSubContext(node, context).getArguments()[0];
5862

test/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function assertValidSchema(
6969
const actual: any = JSON.parse(JSON.stringify(schema));
7070

7171
expect(typeof actual).toBe("object");
72-
expect(actual).toEqual(expected);
72+
expect(actual).toStrictEqual(expected);
7373

7474
let localValidator = validator;
7575
if (config.extraTags) {

test/valid-data-type.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,6 @@ describe("valid-data-type", () => {
143143
it("ignore-export", assertValidSchema("ignore-export", "*"));
144144

145145
it("lowercase", assertValidSchema("lowercase", "MyType"));
146+
147+
it("promise-extensions", assertValidSchema("promise-extensions", "*"));
146148
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
export type A = { a: string; b: number[] };
2+
3+
export type PromiseAlias = Promise<A>;
4+
5+
export class PromiseClass extends Promise<A> {}
6+
7+
export interface PromiseInterface extends Promise<A> {}
8+
9+
export type LikeType = PromiseLike<A>;
10+
11+
export type PromiseOrAlias = Promise<A> | A;
12+
13+
export type LikeOrType = PromiseLike<A> | A;
14+
15+
export type AndPromise = Promise<A> & { a: string };
16+
17+
export type AndLikePromise = PromiseLike<A> & { a: string };
18+
19+
// Should not be present
20+
export default class extends Promise<A> {}
21+
22+
export class LikeClass implements PromiseLike<A> {
23+
then<TResult1 = A, TResult2 = never>(
24+
onfulfilled?: ((value: A) => TResult1 | PromiseLike<TResult1>) | null | undefined,
25+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined
26+
): PromiseLike<TResult1 | TResult2> {
27+
return new Promise(() => {});
28+
}
29+
}
30+
31+
export abstract class LikeAbstractClass implements PromiseLike<A> {
32+
abstract then<TResult1 = A, TResult2 = never>(
33+
onfulfilled?: ((value: A) => TResult1 | PromiseLike<TResult1>) | null | undefined,
34+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined
35+
);
36+
}
37+
38+
export interface LikeInterface extends PromiseLike<A> {}
39+
40+
// Prisma has a base promise type just like this
41+
export interface WithProperty extends Promise<A> {
42+
[Symbol.toStringTag]: "WithProperty";
43+
}
44+
45+
export interface ThenableInterface {
46+
then<TResult1 = A, TResult2 = never>(
47+
onfulfilled?: ((value: A) => TResult1 | PromiseLike<TResult1>) | null | undefined,
48+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined
49+
): PromiseLike<TResult1 | TResult2>;
50+
}
51+
52+
export class ThenableClass {
53+
then<TResult1 = A, TResult2 = never>(
54+
onfulfilled?: ((value: A) => TResult1 | PromiseLike<TResult1>) | null | undefined,
55+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined
56+
): PromiseLike<TResult1 | TResult2> {
57+
return new Promise(() => {});
58+
}
59+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"definitions": {
4+
"A": {
5+
"additionalProperties": false,
6+
"properties": {
7+
"a": {
8+
"type": "string"
9+
},
10+
"b": {
11+
"items": {
12+
"type": "number"
13+
},
14+
"type": "array"
15+
}
16+
},
17+
"required": [
18+
"a",
19+
"b"
20+
],
21+
"type": "object"
22+
},
23+
"AndLikePromise": {
24+
"$ref": "#/definitions/A"
25+
},
26+
"AndPromise": {
27+
"$ref": "#/definitions/A"
28+
},
29+
"LikeAbstractClass": {
30+
"$ref": "#/definitions/A"
31+
},
32+
"LikeClass": {
33+
"$ref": "#/definitions/A"
34+
},
35+
"LikeInterface": {
36+
"$ref": "#/definitions/A"
37+
},
38+
"LikeOrType": {
39+
"$ref": "#/definitions/A"
40+
},
41+
"LikeType": {
42+
"$ref": "#/definitions/A"
43+
},
44+
"PromiseAlias": {
45+
"$ref": "#/definitions/A"
46+
},
47+
"PromiseClass": {
48+
"$ref": "#/definitions/A"
49+
},
50+
"PromiseInterface": {
51+
"$ref": "#/definitions/A"
52+
},
53+
"PromiseOrAlias": {
54+
"$ref": "#/definitions/A"
55+
},
56+
"ThenableClass": {
57+
"$ref": "#/definitions/A"
58+
},
59+
"ThenableInterface": {
60+
"$ref": "#/definitions/A"
61+
},
62+
"WithProperty": {
63+
"$ref": "#/definitions/A"
64+
}
65+
}
66+
}

test/vega-lite/schema.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27149,6 +27149,8 @@
2714927149
},
2715027150
"SingleDefUnitChannel": {
2715127151
"enum": [
27152+
"text",
27153+
"shape",
2715227154
"x",
2715327155
"y",
2715427156
"xOffset",
@@ -27173,9 +27175,7 @@
2717327175
"strokeDash",
2717427176
"size",
2717527177
"angle",
27176-
"shape",
2717727178
"key",
27178-
"text",
2717927179
"href",
2718027180
"url",
2718127181
"description"

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1976,6 +1976,11 @@
19761976
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"
19771977
integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==
19781978

1979+
"@types/ts-expose-internals@npm:ts-expose-internals@^5.4.5":
1980+
version "5.4.5"
1981+
resolved "https://registry.yarnpkg.com/ts-expose-internals/-/ts-expose-internals-5.4.5.tgz#94da2b665627135ad1281d98af3ccb08cb4c1950"
1982+
integrity sha512-0HfRwjgSIOyuDlHzkFedMWU4aHWq9pu4MUKHgH75U+L76wCAtK5WB0rc/dAIhulMRcPUlcKONeiiR5Sxy/7XcA==
1983+
19791984
"@types/yargs-parser@*":
19801985
version "21.0.3"
19811986
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15"

0 commit comments

Comments
 (0)