Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@ import org.jacodb.ets.dto.TemplateObjectExprDto
import org.jacodb.ets.dto.dtoModule
import org.jacodb.ets.dto.toEtsFile
import org.jacodb.ets.model.EtsAssignStmt
import org.jacodb.ets.model.EtsCallExpr
import org.jacodb.ets.model.EtsNewClassExpr
import org.jacodb.ets.model.EtsNewExpr
import org.jacodb.ets.model.EtsNullType
import org.jacodb.ets.model.EtsPtrCallExpr
import org.jacodb.ets.model.EtsSpreadExpansionExpr
import org.jacodb.ets.model.EtsTemplateObjectExpr
import org.jacodb.ets.model.EtsUndefinedType
import org.jacodb.ets.model.EtsUnionType
import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME
import org.jacodb.ets.utils.EtsIrProvider
import org.jacodb.ets.utils.generateEtsIR
Expand Down Expand Up @@ -69,6 +73,27 @@ class EtsTsCallLoweringTest {
.methods.single { it.signature.name == method }.body!!.cfg.blocks
.flatMap { it.stmts }.filterIsInstance<AssignStmtDto>()

@Test
fun `call return type retains explicit nullish alternatives through serialization`() {
val dto = frontendRoundTrip(
"""
type Maybe<T> = T | null | undefined;
declare class External { static choice<T>(): Maybe<T>; }
export function caller() { return External.choice<"known">(); }
""".trimIndent(),
)
val method = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME }
.methods.single { it.name == "caller" }
val call = method.cfg.stmts.filterIsInstance<EtsAssignStmt>()
.mapNotNull { it.rhv as? EtsCallExpr }.single { it.callee.name == "choice" }
val resultType = call.type as EtsUnionType

assertTrue(EtsNullType in resultType.types)
assertTrue(EtsUndefinedType in resultType.types)
assertEquals(3, resultType.types.size)
assertEquals(resultType, call.callee.returnType)
}

@Test
fun `comma constructor values decode as locals after computed access`() {
val dto = frontendRoundTrip(
Expand Down
15 changes: 15 additions & 0 deletions jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2012,6 +2012,12 @@ export class ExprLowerer {
parameters = this.parametersOfDeclaration(decl);
}
returnType = this.m.converter.convertType(this.m.checker.getReturnTypeOfSignature(signature));
if (decl?.type !== undefined) {
// With strictNullChecks disabled the checker erases explicitly declared nullish alternatives.
// Keep its instantiated generic result, then restore only those explicit alternatives.
const declared = this.m.converter.convertTypeNode(decl.type);
returnType = preserveDeclaredNullishReturn(returnType, declared);
}
}
} catch {
// keep unknowns
Expand Down Expand Up @@ -2066,6 +2072,15 @@ export class ExprLowerer {
}
}

function preserveDeclaredNullishReturn(resolved: TypeDto, declared: TypeDto): TypeDto {
const members = (type: TypeDto): TypeDto[] => type._ === "UnionType" ? type.types.flatMap(members) : [type];
const resolvedMembers = members(resolved);
const declaredNullish = members(declared).filter((type) => type._ === "NullType" || type._ === "UndefinedType");
const missing = declaredNullish.filter((type) => !resolvedMembers.some((member) => member._ === type._));
const uniqueMissing = missing.filter((type, index) => missing.findIndex((member) => member._ === type._) === index);
return uniqueMissing.length === 0 ? resolved : { _: "UnionType", types: [...resolvedMembers, ...uniqueMissing] };
}

// ----------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------
Expand Down
61 changes: 61 additions & 0 deletions jacodb-ets/ts-frontend/test/nullish-call-results.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { TypeDto } from "../src/dto/types";
import { CallExprDto } from "../src/dto/values";
import { serializeEtsFile } from "../src/serialize";
import { compile, lower, methodByName } from "./util";

function resultTypes(source: string): TypeDto[] {
// A script-level External declaration collides with lib.dom's global External interface.
const moduleSource = `export {};\n${source}`;
const { program } = compile(moduleSource);
expect(program.getSemanticDiagnostics()).toEqual([]);

const { file, diagnostics } = lower(moduleSource);
expect(diagnostics.messages).toEqual([]);
const roundTrip = JSON.parse(serializeEtsFile(file));
const method = methodByName(roundTrip, "caller");
const calls = method.body!.cfg.blocks.flatMap((block) => block.stmts).flatMap((statement) => {
const value = statement._ === "AssignStmt" ? statement.right : undefined;
const isCall = value?._ === "StaticCallExpr" || value?._ === "InstanceCallExpr" || value?._ === "PtrCallExpr";
return isCall ? [value as CallExprDto] : [];
});
const result = calls.find((call) => call.method.name === "choice")!.method.returnType;
return result._ === "UnionType" ? result.types : [result];
}

describe("declared nullish call results", () => {
it("keeps null and undefined beside finite literals without strictNullChecks", () => {
const types = resultTypes(`
declare class External { static choice(): "known" | 7 | true | null | undefined; }
function caller() { return External.choice(); }
`);

expect(types).toEqual(expect.arrayContaining([
{ _: "LiteralType", literal: "known" }, { _: "LiteralType", literal: 7 },
{ _: "LiteralType", literal: true }, { _: "NullType" }, { _: "UndefinedType" },
]));
expect(types).toHaveLength(5);
});

it("preserves generic instantiation while restoring declared alias nullish members", () => {
const types = resultTypes(`
type Maybe<T> = T | null | undefined;
declare class External { static choice<T>(): Maybe<T>; }
function caller() { return External.choice<"kept">(); }
`);

expect(types).toEqual(expect.arrayContaining([
{ _: "LiteralType", literal: "kept" }, { _: "NullType" }, { _: "UndefinedType" },
]));
expect(types).toHaveLength(3);
});

it("does not turn a nullable generic constraint into a return alternative", () => {
const types = resultTypes(`
declare class External { static choice<T extends number | null>(): T; }
function caller() { return External.choice<7>(); }
`);

expect(types).toEqual([{ _: "LiteralType", literal: 7 }]);
});
});
Loading