From 6547183da737b0292e95210765634777abb984fa Mon Sep 17 00:00:00 2001 From: rictic Date: Thu, 21 Aug 2025 14:38:15 -0700 Subject: [PATCH 1/6] Initial implementation of getLitExpressionType --- .../lib/type-helpers/lit-expression-type.ts | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts diff --git a/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts b/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts new file mode 100644 index 0000000000..391c5dde56 --- /dev/null +++ b/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts @@ -0,0 +1,165 @@ +import type * as ts from 'typescript'; + +/** + * Given a type of a binding expression, return the type that we should + * type check against. + * + * This is usually the same as just returning the type, however, lit-html's + * rendering behavior does have a few special values. + * + * The `noChange` and `nothing` symbols are always allowed in any binding. + * + * DirectiveResults should be type checked as the return type of the `render` + * method on the directive class. + */ +export function getLitExpressionType( + type: ts.Type, + typescript: typeof ts, + program: ts.Program +): ts.Type { + const checker: ExtendedTypeChecker = program.getTypeChecker(); + const specialType = isSpecialValue(type, typescript); + // nothing and noChange are assignable to anything in a binding. + if (specialType === SpecialValuesEnum.SentinelSymbol) { + return checker.getAnyType(); + } + // Unwrap a DirectiveResult to get the type it renders as. + if (specialType === SpecialValuesEnum.DirectiveResult) { + const directiveResultType = getRenderTypeFromDirectiveResult( + type, + typescript, + checker + ); + return directiveResultType ?? type; + } + // If our checker can't create new unions, we can't do anything more. + if (checker.getUnionType == null) { + return type; + } + // Apply the same transform through a union. + if (type.isUnion()) { + // Check if any of the types are special. If not, we can early exit. + let hasSpecial = false; + for (const subtype of type.types) { + const subtypeSpecial = isSpecialValue(subtype, typescript); + if (subtypeSpecial !== SpecialValuesEnum.NormalType) { + hasSpecial = true; + break; + } + } + if (!hasSpecial) { + return type; + } + // Apply the same transform to each subtype. + const newSubtypes = type.types.map((subtype) => + getLitExpressionType(subtype, typescript, program) + ); + return checker.getUnionType(newSubtypes); + } + return type; +} + +interface ExtendedTypeChecker extends ts.TypeChecker { + getUnionType?(types: Array): ts.Type; +} + +function isTypeReference( + type: ts.Type, + typescript: typeof ts +): type is ts.TypeReference { + return ( + (type.flags & typescript.TypeFlags.Object) !== 0 && + !!(type as ts.TypeReference).target + ); +} + +function getRenderTypeFromDirectiveResult( + type: ts.Type, + typescript: typeof ts, + checker: ExtendedTypeChecker +): ts.Type | undefined { + if (!isTypeReference(type, typescript)) { + return undefined; + } + // So, we have a DirectiveResult<{new (...): ActualDirective} + // and we want to get ReturnType + const constructorTypes = checker.getTypeArguments(type); + if (!constructorTypes || constructorTypes.length === 0) { + return undefined; + } + const finalTypes = []; + for (const constructorType of constructorTypes) { + const constructSignatures = constructorType.getConstructSignatures(); + for (const signature of constructSignatures) { + const actualDirectiveType = checker.getReturnTypeOfSignature(signature); + if (actualDirectiveType == null) { + continue; + } + const renderMethod = actualDirectiveType.getProperty('render'); + if (renderMethod == null) { + continue; + } + const renderType = checker.getTypeOfSymbol(renderMethod); + const callSignatures = renderType.getCallSignatures(); + for (const callSignature of callSignatures) { + finalTypes.push(callSignature.getReturnType()); + } + } + } + if (finalTypes.length === 0) { + return undefined; + } + if (finalTypes.length === 1) { + return finalTypes[0]; + } + if (checker.getUnionType != null) { + // Return a union of the types if we can. + return checker.getUnionType(finalTypes); + } + return finalTypes[0]; +} + +const SpecialValuesEnum = { + NormalType: 0 as const, + SentinelSymbol: 1 as const, + DirectiveResult: 2 as const, +}; +type SpecialValuesEnum = + (typeof SpecialValuesEnum)[keyof typeof SpecialValuesEnum]; + +function isSpecialValue( + type: ts.Type, + typescript: typeof ts +): SpecialValuesEnum { + const escapedName = type.symbol.getEscapedName(); + if ( + escapedName !== 'noChange' && + escapedName !== 'nothing' && + escapedName !== 'DirectiveResult' + ) { + return SpecialValuesEnum.NormalType; + } + // Is it declared inside of lit-html? + const declarations = type.symbol.declarations; + if (declarations) { + let isInsideLitHtml = false; + for (const decl of declarations) { + const sourceFile = decl.getSourceFile(); + if (sourceFile.fileName.includes('lit-html')) { + isInsideLitHtml = true; + break; + } + } + if (isInsideLitHtml === false) { + return SpecialValuesEnum.NormalType; + } + } + if (escapedName === 'DirectiveResult') { + return SpecialValuesEnum.DirectiveResult; + } + // Is it a unique symbol? + if (type.flags & typescript.TypeFlags.UniqueESSymbol) { + return SpecialValuesEnum.SentinelSymbol; + } + return SpecialValuesEnum.NormalType; +} From df8ded727e42fe69afdf2af8421c58f53402453e Mon Sep 17 00:00:00 2001 From: rictic Date: Thu, 21 Aug 2025 14:53:44 -0700 Subject: [PATCH 2/6] Add tests for getLitExpressionType. --- .../lib/type-helpers/lit-expression-type.ts | 2 +- .../src/test/lib/fake-lit-html-types.ts | 57 +++++++ .../src/test/lib/lit-expression-type_test.ts | 159 ++++++++++++++++++ 3 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 packages/labs/tsserver-plugin/src/test/lib/fake-lit-html-types.ts create mode 100644 packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts diff --git a/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts b/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts index 391c5dde56..c067f3b590 100644 --- a/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts +++ b/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts @@ -131,7 +131,7 @@ function isSpecialValue( type: ts.Type, typescript: typeof ts ): SpecialValuesEnum { - const escapedName = type.symbol.getEscapedName(); + const escapedName = type.symbol?.getEscapedName(); if ( escapedName !== 'noChange' && escapedName !== 'nothing' && diff --git a/packages/labs/tsserver-plugin/src/test/lib/fake-lit-html-types.ts b/packages/labs/tsserver-plugin/src/test/lib/fake-lit-html-types.ts new file mode 100644 index 0000000000..8b144075f8 --- /dev/null +++ b/packages/labs/tsserver-plugin/src/test/lib/fake-lit-html-types.ts @@ -0,0 +1,57 @@ +/** + * Test-only fake lit-html type declarations used to exercise + * `getLitExpressionType` logic without depending on the real lit-html. + * + * The filename intentionally contains `lit-html` so that the helper's + * `isSpecialValue` logic (which checks the source file path) treats these + * exports as coming from lit-html. + */ + +// Sentinel unique symbols (mimic lit-html exports). Using simple Symbol() so +// the declared type is a distinct unique symbol. +export const noChange = Symbol('noChange'); +export const nothing = Symbol('nothing'); + +// Minimal DirectiveResult generic. Only the generic parameter (a constructor) +// is used by the code under test; the shape is irrelevant. +export interface DirectiveResult< + C extends abstract new (...args: unknown[]) => unknown, +> { + /** brand */ readonly __brand?: 'DirectiveResult'; + // Reference the generic so it is not flagged as unused. + readonly __c?: C; +} + +// Two fake directive classes with different render return types so we can +// verify unwrapping (including union return types). +export class MyDirective { + render(): number { + return 42; + } +} + +export class OtherDirective { + render(): string | boolean { + return ''; + } +} + +// Export some typed values (the actual runtime values are not important; +// we just need the static types for the TypeScript type checker). +export const directiveResultNumber = {} as unknown as DirectiveResult< + typeof MyDirective +>; +export const directiveResultUnion = {} as unknown as DirectiveResult< + typeof OtherDirective +>; +export const directiveResultOrString = {} as unknown as + | DirectiveResult + | string; +export const sentinelOnly = noChange; +export const sentinelUnion: typeof noChange | number = noChange; +export const nothingValue = nothing; +export const nothingUnion: typeof nothing | string = nothing; + +// Plain non-special exports for negative test cases. +export const plainNumber = 123; +export const plainUnion: number | string = 0 as unknown as number | string; diff --git a/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts b/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts new file mode 100644 index 0000000000..d9cf53d147 --- /dev/null +++ b/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts @@ -0,0 +1,159 @@ +import assert from 'node:assert'; +import path from 'node:path'; +import {describe as suite, test} from 'node:test'; +import * as ts from 'typescript'; +import {getLitExpressionType} from '../../lib/type-helpers/lit-expression-type.js'; + +// Ensure the declarations are part of the program (side-effect import only). +import './fake-lit-html-types.js'; + +// Helper to build a Program including our fake lit-html types file. +function buildProgram(): ts.Program { + // Build the path relative to the package root (cwd during tests) to avoid + // accidentally duplicating path segments. The previous value incorrectly + // prefixed the repository path twice when running inside the package. + const fakeTypesPath = path.resolve('src/test/lib/fake-lit-html-types.ts'); + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + strict: true, + esModuleInterop: true, + skipLibCheck: true, + }; + return ts.createProgram([fakeTypesPath], options); +} + +function getExportType( + program: ts.Program, + exportName: string +): ts.Type | undefined { + const sourceFiles = program.getSourceFiles(); + const fakeFile = sourceFiles.find((f) => + /fake-lit-html-types\.ts$/.test(f.fileName) + ); + if (!fakeFile) return undefined; + const checker = program.getTypeChecker(); + const moduleSymbol = checker.getSymbolAtLocation(fakeFile); + if (!moduleSymbol) return undefined; + const exports = checker.getExportsOfModule(moduleSymbol); + const symbol = exports.find((e) => e.getEscapedName() === exportName); + if (!symbol) return undefined; + const type = checker.getTypeOfSymbol(symbol); + return type; +} + +suite('getLitExpressionType', () => { + test('sentinel unique symbols map to any', () => { + const program = buildProgram(); + // Use the direct exported sentinel symbols so the type has the correct symbol name. + const noChangeType = getExportType(program, 'noChange'); + const nothingType = getExportType(program, 'nothing'); + const noChangeResult = getLitExpressionType(noChangeType!, ts, program); + const nothingResult = getLitExpressionType(nothingType!, ts, program); + assert.ok(noChangeResult.flags & ts.TypeFlags.Any, 'noChange -> any'); + assert.ok(nothingResult.flags & ts.TypeFlags.Any, 'nothing -> any'); + }); + + test('DirectiveResult unwraps to render return type (primitive)', () => { + const program = buildProgram(); + const directiveResultNumber = getExportType( + program, + 'directiveResultNumber' + ); + const unwrapped = getLitExpressionType(directiveResultNumber!, ts, program); + assert.ok(unwrapped.flags & ts.TypeFlags.Number, 'Expected number'); + }); + + test('DirectiveResult unwraps to union of render return types', () => { + const program = buildProgram(); + const directiveResultUnion = getExportType(program, 'directiveResultUnion'); + const unwrapped = getLitExpressionType(directiveResultUnion!, ts, program); + assert.ok(unwrapped.isUnion(), 'Expected union'); + const flags = unwrapped.types.reduce((acc, t) => acc | t.flags, 0); + assert.ok(flags & ts.TypeFlags.String, 'Union includes string'); + // Accept either Boolean or BooleanLiteral flags (the union currently consists of the literal true/false types). + assert.ok( + flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral), + 'Union includes boolean' + ); + }); + + test('Union containing DirectiveResult unwraps only that member', () => { + const program = buildProgram(); + const mixed = getExportType(program, 'directiveResultOrString'); + const transformed = getLitExpressionType(mixed!, ts, program); + assert.ok(transformed.isUnion(), 'Expected union'); + const hasNumber = transformed.types.some( + (t) => t.flags & ts.TypeFlags.Number + ); + const hasString = transformed.types.some( + (t) => t.flags & ts.TypeFlags.String + ); + assert.ok(hasNumber, 'Contains number from directive render'); + assert.ok(hasString, 'Contains original string'); + }); + + test('Union with sentinel symbol collapses to any', () => { + const program = buildProgram(); + const sentinelUnion = getExportType(program, 'sentinelUnion'); + const transformed = getLitExpressionType(sentinelUnion!, ts, program); + assert.ok( + transformed.flags & ts.TypeFlags.Any, + 'Expected any from union including sentinel' + ); + }); + + test('Plain non-special type is unchanged', () => { + const plainProgram = buildProgram(); + const plainNumber = getExportType(plainProgram, 'plainNumber'); + const result = getLitExpressionType(plainNumber!, ts, plainProgram); + assert.strictEqual(result, plainNumber); + }); + + test('Union without special values is unchanged', () => { + const plainProgram = buildProgram(); + const plainUnion = getExportType(plainProgram, 'plainUnion'); + const result = getLitExpressionType(plainUnion!, ts, plainProgram); + assert.strictEqual(result, plainUnion); + }); + + test('Sentinel-like symbol outside lit-html scope not treated as special', () => { + // Create a synthetic unique symbol type named noChange but from a different file. + const sourceText = 'export const noChange = Symbol("local");'; + const tempFile = path.resolve('src/test/lib/local-file.ts'); + const compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + strict: true, + }; + const host = ts.createCompilerHost(compilerOptions); + host.readFile = (fileName) => { + if (fileName === tempFile) return sourceText; + return ts.sys.readFile(fileName); + }; + host.fileExists = (fileName) => { + if (fileName === tempFile) return true; + return ts.sys.fileExists(fileName); + }; + const shadowProgram = ts.createProgram( + [path.resolve('src/test/lib/fake-lit-html-types.ts'), tempFile], + compilerOptions, + host + ); + const checker = shadowProgram.getTypeChecker(); + const sf = shadowProgram + .getSourceFiles() + .find((f) => /local-file\.ts$/.test(f.fileName))!; + const moduleSymbol = checker.getSymbolAtLocation(sf)!; + const localSymbol = checker + .getExportsOfModule(moduleSymbol) + .find((s) => s.getEscapedName() === 'noChange')!; + const localType = checker.getTypeOfSymbol(localSymbol); + assert.ok(localType, 'got localtype'); + const result = getLitExpressionType(localType, ts, shadowProgram); + // Should be unchanged (unique symbol stays unique symbol) not widened to any. + assert.strictEqual(result, localType); + }); +}); From 11f2065df55c1ad52771b023a94ad79cd4562d60 Mon Sep 17 00:00:00 2001 From: rictic Date: Thu, 21 Aug 2025 15:12:36 -0700 Subject: [PATCH 3/6] Also test type strings --- .../src/test/lib/lit-expression-type_test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts b/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts index d9cf53d147..b4bc5903a2 100644 --- a/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts +++ b/packages/labs/tsserver-plugin/src/test/lib/lit-expression-type_test.ts @@ -44,30 +44,55 @@ function getExportType( } suite('getLitExpressionType', () => { + function normalizeUnion(s: string): string { + // Split on ' | ' and sort for order-insensitive comparison. + return s + .split(' | ') + .map((p) => p.trim()) + .sort() + .join(' | '); + } + test('sentinel unique symbols map to any', () => { const program = buildProgram(); + const checker = program.getTypeChecker(); // Use the direct exported sentinel symbols so the type has the correct symbol name. const noChangeType = getExportType(program, 'noChange'); const nothingType = getExportType(program, 'nothing'); + assert.strictEqual(checker.typeToString(noChangeType!), 'unique symbol'); + assert.strictEqual(checker.typeToString(nothingType!), 'unique symbol'); const noChangeResult = getLitExpressionType(noChangeType!, ts, program); const nothingResult = getLitExpressionType(nothingType!, ts, program); + assert.strictEqual(checker.typeToString(noChangeResult), 'any'); + assert.strictEqual(checker.typeToString(nothingResult), 'any'); assert.ok(noChangeResult.flags & ts.TypeFlags.Any, 'noChange -> any'); assert.ok(nothingResult.flags & ts.TypeFlags.Any, 'nothing -> any'); }); test('DirectiveResult unwraps to render return type (primitive)', () => { const program = buildProgram(); + const checker = program.getTypeChecker(); const directiveResultNumber = getExportType( program, 'directiveResultNumber' ); + assert.strictEqual( + checker.typeToString(directiveResultNumber!), + 'DirectiveResult' + ); const unwrapped = getLitExpressionType(directiveResultNumber!, ts, program); assert.ok(unwrapped.flags & ts.TypeFlags.Number, 'Expected number'); + assert.strictEqual(checker.typeToString(unwrapped), 'number'); }); test('DirectiveResult unwraps to union of render return types', () => { const program = buildProgram(); + const checker = program.getTypeChecker(); const directiveResultUnion = getExportType(program, 'directiveResultUnion'); + assert.strictEqual( + checker.typeToString(directiveResultUnion!), + 'DirectiveResult' + ); const unwrapped = getLitExpressionType(directiveResultUnion!, ts, program); assert.ok(unwrapped.isUnion(), 'Expected union'); const flags = unwrapped.types.reduce((acc, t) => acc | t.flags, 0); @@ -77,11 +102,21 @@ suite('getLitExpressionType', () => { flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral), 'Union includes boolean' ); + // Union order can vary; normalize. + assert.strictEqual( + normalizeUnion(checker.typeToString(unwrapped)), + normalizeUnion('boolean | string') + ); }); test('Union containing DirectiveResult unwraps only that member', () => { const program = buildProgram(); + const checker = program.getTypeChecker(); const mixed = getExportType(program, 'directiveResultOrString'); + assert.strictEqual( + normalizeUnion(checker.typeToString(mixed!)), + normalizeUnion('DirectiveResult | string') + ); const transformed = getLitExpressionType(mixed!, ts, program); assert.ok(transformed.isUnion(), 'Expected union'); const hasNumber = transformed.types.some( @@ -92,30 +127,50 @@ suite('getLitExpressionType', () => { ); assert.ok(hasNumber, 'Contains number from directive render'); assert.ok(hasString, 'Contains original string'); + assert.strictEqual( + normalizeUnion(checker.typeToString(transformed)), + normalizeUnion('number | string') + ); }); test('Union with sentinel symbol collapses to any', () => { const program = buildProgram(); + const checker = program.getTypeChecker(); const sentinelUnion = getExportType(program, 'sentinelUnion'); + const originalStr = checker.typeToString(sentinelUnion!); const transformed = getLitExpressionType(sentinelUnion!, ts, program); assert.ok( transformed.flags & ts.TypeFlags.Any, 'Expected any from union including sentinel' ); + // The sentinel side is a value symbol reference; we assert membership not order. + const parts = normalizeUnion(originalStr).split(' | '); + assert.ok(parts.includes('number')); // union with number + assert.ok(parts.includes('unique symbol')); + assert.strictEqual(checker.typeToString(transformed), 'any'); }); test('Plain non-special type is unchanged', () => { const plainProgram = buildProgram(); + const checker = plainProgram.getTypeChecker(); const plainNumber = getExportType(plainProgram, 'plainNumber'); + assert.strictEqual(checker.typeToString(plainNumber!), '123'); const result = getLitExpressionType(plainNumber!, ts, plainProgram); assert.strictEqual(result, plainNumber); + // literal 123 keeps its literal type. + assert.strictEqual(checker.typeToString(result), '123'); }); test('Union without special values is unchanged', () => { const plainProgram = buildProgram(); + const checker = plainProgram.getTypeChecker(); const plainUnion = getExportType(plainProgram, 'plainUnion'); const result = getLitExpressionType(plainUnion!, ts, plainProgram); assert.strictEqual(result, plainUnion); + assert.strictEqual( + normalizeUnion(checker.typeToString(plainUnion!)), + normalizeUnion(checker.typeToString(result)) + ); }); test('Sentinel-like symbol outside lit-html scope not treated as special', () => { @@ -155,5 +210,7 @@ suite('getLitExpressionType', () => { const result = getLitExpressionType(localType, ts, shadowProgram); // Should be unchanged (unique symbol stays unique symbol) not widened to any. assert.strictEqual(result, localType); + assert.strictEqual(checker.typeToString(localType), 'unique symbol'); + assert.strictEqual(checker.typeToString(result), 'unique symbol'); }); }); From ff75ec2425fdf6a96a7b34d38b79b92aca5f7ba5 Mon Sep 17 00:00:00 2001 From: Peter Burns Date: Thu, 21 Aug 2025 15:15:43 -0700 Subject: [PATCH 4/6] Changeset --- .changeset/tame-wombats-share.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tame-wombats-share.md diff --git a/.changeset/tame-wombats-share.md b/.changeset/tame-wombats-share.md new file mode 100644 index 0000000000..87802792e5 --- /dev/null +++ b/.changeset/tame-wombats-share.md @@ -0,0 +1,5 @@ +--- +"@lit-labs/tsserver-plugin": patch +--- + +Add getLitExpressionType to get the effective type of a binding expression From 287892b744efa9781c987f2eba7c672fe7ef70d5 Mon Sep 17 00:00:00 2001 From: rictic Date: Thu, 21 Aug 2025 15:18:09 -0700 Subject: [PATCH 5/6] Prettier. --- .changeset/tame-wombats-share.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/tame-wombats-share.md b/.changeset/tame-wombats-share.md index 87802792e5..235be534a0 100644 --- a/.changeset/tame-wombats-share.md +++ b/.changeset/tame-wombats-share.md @@ -1,5 +1,5 @@ --- -"@lit-labs/tsserver-plugin": patch +'@lit-labs/tsserver-plugin': patch --- Add getLitExpressionType to get the effective type of a binding expression From 76909a65cbfd61e2fdee935fddf7914559eed6f3 Mon Sep 17 00:00:00 2001 From: rictic Date: Thu, 21 Aug 2025 15:29:29 -0700 Subject: [PATCH 6/6] Add todo. --- .../tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts b/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts index c067f3b590..e5d7f1a47c 100644 --- a/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts +++ b/packages/labs/tsserver-plugin/src/lib/type-helpers/lit-expression-type.ts @@ -11,6 +11,8 @@ import type * as ts from 'typescript'; * * DirectiveResults should be type checked as the return type of the `render` * method on the directive class. + * + * TODO: Move into the analyzer. */ export function getLitExpressionType( type: ts.Type,