Skip to content

Commit 0f27bef

Browse files
fix: handle const Type Parameters
1 parent c4018e4 commit 0f27bef

8 files changed

Lines changed: 115 additions & 3 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@phenomnomnominal/tsquery": "^6.1.3",
5454
"@release-it/conventional-changelog": "^8.0.1",
5555
"@types/eslint": "^8.56.5",
56+
"@types/semver": "^7.5.8",
5657
"@typescript-eslint/eslint-plugin": "^7.3.1",
5758
"@typescript-eslint/parser": "^7.3.1",
5859
"@typescript/vfs": "^1.5.0",
@@ -81,6 +82,7 @@
8182
"prettier-plugin-curly": "^0.2.1",
8283
"prettier-plugin-packagejson": "^2.4.7",
8384
"release-it": "^17.0.1",
85+
"semver": "^7.6.2",
8486
"sentences-per-line": "^0.2.1",
8587
"should-semantic-release": "^0.3.0",
8688
"tsup": "^8.0.2",

pnpm-lock.yaml

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/flags.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ export const isSymbolFlagSet: (
8989
flag: ts.SymbolFlags,
9090
) => boolean = isFlagSetOnObject;
9191

92+
/**
93+
* Test if the given symbol's links has the given `ModifierFlags` set.
94+
* @internal
95+
*/
96+
export function isSymbolLinkFlagSet(
97+
symbol: ts.Symbol,
98+
flag: ts.ModifierFlags,
99+
): boolean {
100+
return symbol.links !== undefined && isFlagSet(symbol.links.checkFlags, flag);
101+
}
102+
92103
/**
93104
* Test if the given node has the given `TypeFlags` set.
94105
* @category Nodes - Flag Utilities

src/nodes/utilities.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import ts from "typescript";
55

6+
import { isSymbolLinkFlagSet } from "../flags";
67
import {
78
isConstAssertionExpression,
89
isEntityNameExpression,
@@ -27,11 +28,24 @@ export function isBindableObjectDefinePropertyCall(
2728
);
2829
}
2930

31+
/**
32+
* Detects whether the property assignment is affected by an enclosing `as const` assertion or const type parameter and therefore treated literally.
33+
*/
34+
export function isInConstContext(
35+
node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment,
36+
typeChecker: ts.TypeChecker,
37+
): boolean {
38+
return (
39+
isInAsConstContext(node.parent) ||
40+
isInConstTypeParameterContext(node, typeChecker)
41+
);
42+
}
43+
3044
/**
3145
* Detects whether an expression is affected by an enclosing `as const` assertion and therefore treated literally.
3246
* @internal
3347
*/
34-
export function isInConstContext(node: ts.Expression): boolean {
48+
function isInAsConstContext(node: ts.Expression): boolean {
3549
let current: ts.Node = node;
3650
while (true) {
3751
const parent = current.parent;
@@ -74,3 +88,42 @@ export function isInConstContext(node: ts.Expression): boolean {
7488
}
7589
}
7690
}
91+
92+
/**
93+
* Detects whether a property assignment is affected by a const type parameter and therefore treated literally.
94+
* @internal
95+
*/
96+
function isInConstTypeParameterContext(
97+
node: ts.PropertyAssignment | ts.ShorthandPropertyAssignment,
98+
typeChecker: ts.TypeChecker,
99+
): boolean {
100+
let current: ts.PropertyAssignment | ts.ShorthandPropertyAssignment = node;
101+
let callExpression: ts.CallExpression;
102+
103+
while (true) {
104+
if (
105+
ts.isPropertyAssignment(current.parent.parent) ||
106+
ts.isShorthandPropertyAssignment(current.parent.parent)
107+
) {
108+
current = current.parent.parent;
109+
continue;
110+
}
111+
112+
if (ts.isCallExpression(current.parent.parent)) {
113+
callExpression = current.parent.parent;
114+
break;
115+
}
116+
117+
return false;
118+
}
119+
120+
const type = typeChecker.getTypeAtLocation(callExpression);
121+
const property = type
122+
.getProperties()
123+
.find((property) => property.getName() === current.name.getText());
124+
125+
return (
126+
property !== undefined &&
127+
isSymbolLinkFlagSet(property, ts.ModifierFlags.Readonly)
128+
);
129+
}

src/types/utilities.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import semver from "semver";
12
import ts from "typescript";
23
import { describe, expect, it } from "vitest";
34

@@ -131,6 +132,34 @@ describe("symbolHasReadonlyDeclaration", () => {
131132

132133
expect(symbolHasReadonlyDeclaration(symbol, typeChecker)).toBe(expected);
133134
});
135+
136+
if (semver.gte(ts.version, "5.0.0")) {
137+
it("returns true when the symbol belongs to a property of a nested object literal directly passed into a function that declares the parameter with a const type parameter", () => {
138+
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(`
139+
declare const fn: <const A>(param: A) => A;
140+
141+
const bar = {
142+
baz: 1
143+
}
144+
fn({ foo: { bar } });
145+
`);
146+
147+
const statement = sourceFile.statements.at(-1) as ts.ExpressionStatement;
148+
const callExpression = statement.expression as ts.CallExpression;
149+
const objectLiteral1 = callExpression
150+
.arguments[0] as ts.ObjectLiteralExpression;
151+
const foo = objectLiteral1.properties[0] as ts.PropertyAssignment;
152+
const fooSymbol = (foo as { symbol?: ts.Symbol }).symbol!;
153+
const objectLiteral2 = foo.initializer as ts.ObjectLiteralExpression;
154+
const bar = objectLiteral2.properties[0] as ts.PropertyAssignment;
155+
const barSymbol = (bar as { symbol?: ts.Symbol }).symbol!;
156+
157+
expect(fooSymbol).toBeDefined();
158+
expect(barSymbol).toBeDefined();
159+
expect(symbolHasReadonlyDeclaration(fooSymbol, typeChecker)).toBe(true);
160+
expect(symbolHasReadonlyDeclaration(barSymbol, typeChecker)).toBe(true);
161+
});
162+
}
134163
});
135164

136165
describe("isFalsyType", () => {

src/types/utilities.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ export function symbolHasReadonlyDeclaration(
375375
ts.isEnumMember(node) ||
376376
((ts.isPropertyAssignment(node) ||
377377
ts.isShorthandPropertyAssignment(node)) &&
378-
isInConstContext(node.parent)),
378+
isInConstContext(node, typeChecker)),
379379
)
380380
);
381381
}

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
"strict": true,
1313
"target": "ES2021"
1414
},
15-
"include": ["src"]
15+
"include": ["src", "typings"]
1616
}

typings/typescript.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import "typescript";
2+
3+
declare module "typescript" {
4+
// internal TS APIs
5+
6+
interface Symbol {
7+
readonly links?: {
8+
readonly checkFlags: ModifierFlags;
9+
};
10+
}
11+
}

0 commit comments

Comments
 (0)