Add `TypeChecker.java`, a static type checking visitor
This takes the type annotation information saved in the `Resolver.Binding`s of an AST's `Identifier`s, and validates that the AST uses these types correctly.
The name matches an existing dynamic typechecking file in the `eval/` package, but we can disambiguate that in a future CL. A little stub logic is added to `StarlarkType.java`; in the future we should migrate all the abstract supertype/subtype logic to there.
`Any` is exposed to the user as a valid type annotation identifier.
Work toward #28037.
PiperOrigin-RevId: 846962357
Change-Id: If2abf273c4424814646b82528cd913057e382a85
diff --git a/src/main/java/net/starlark/java/eval/TypeChecker.java b/src/main/java/net/starlark/java/eval/TypeChecker.java
index 9d46311..c3e68e1 100644
--- a/src/main/java/net/starlark/java/eval/TypeChecker.java
+++ b/src/main/java/net/starlark/java/eval/TypeChecker.java
@@ -37,6 +37,7 @@
import net.starlark.java.types.Types.UnionType;
/** Type checker for Starlark types. */
+// TODO: #28043 - Replace or reformulate these using static helpers in StarlarkType.
public final class TypeChecker {
private static boolean isTupleSubtypeOf(TupleType tuple1, TupleType tuple2) {
diff --git a/src/main/java/net/starlark/java/syntax/BUILD b/src/main/java/net/starlark/java/syntax/BUILD
index 214df25..54d84eb 100644
--- a/src/main/java/net/starlark/java/syntax/BUILD
+++ b/src/main/java/net/starlark/java/syntax/BUILD
@@ -64,6 +64,7 @@
"TokenKind.java",
"TypeAliasStatement.java",
"TypeApplication.java",
+ "TypeChecker.java",
"TypeResolver.java",
"UnaryOperatorExpression.java",
"VarStatement.java",
diff --git a/src/main/java/net/starlark/java/syntax/Program.java b/src/main/java/net/starlark/java/syntax/Program.java
index edac73f..183d546 100644
--- a/src/main/java/net/starlark/java/syntax/Program.java
+++ b/src/main/java/net/starlark/java/syntax/Program.java
@@ -112,6 +112,9 @@
}
}
+ // TODO: #28037 - Call the static type checker when --experimental_starlark_type_checking is
+ // enabled. Blocked on having the type checker tolerate all AST nodes.
+
// Extract load statements.
ImmutableList.Builder<String> loads = ImmutableList.builder();
ImmutableList.Builder<Location> loadLocations = ImmutableList.builder();
diff --git a/src/main/java/net/starlark/java/syntax/TypeChecker.java b/src/main/java/net/starlark/java/syntax/TypeChecker.java
new file mode 100644
index 0000000..cfe1a34
--- /dev/null
+++ b/src/main/java/net/starlark/java/syntax/TypeChecker.java
@@ -0,0 +1,173 @@
+// Copyright 2025 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.starlark.java.syntax;
+
+import com.google.common.base.Preconditions;
+import com.google.errorprone.annotations.FormatMethod;
+import java.util.List;
+import net.starlark.java.types.StarlarkType;
+import net.starlark.java.types.Types;
+
+/**
+ * A visitor for validating that expressions and statements respect the types of the symbols
+ * appearing within them, as determined by the type resolver.
+ *
+ * <p>Type annotations are not traversed by this visitor.
+ */
+public final class TypeChecker extends NodeVisitor {
+
+ private final List<SyntaxError> errors;
+
+ // Formats and reports an error at the start of the specified node.
+ @FormatMethod
+ private void errorf(Node node, String format, Object... args) {
+ errorf(node.getStartLocation(), format, args);
+ }
+
+ // Formats and reports an error at the specified location.
+ @FormatMethod
+ private void errorf(Location loc, String format, Object... args) {
+ errors.add(new SyntaxError(loc, String.format(format, args)));
+ }
+
+ private TypeChecker(List<SyntaxError> errors) {
+ this.errors = errors;
+ }
+
+ /**
+ * Returns the annotated type of an identifier's symbol, asserting that the binding information is
+ * present.
+ *
+ * <p>If a type is not set on the binding it is taken to be {@code Any}.
+ */
+ // TODO: #27370 - An unannotated variable should either be treated as Any or else inferred from
+ // its first binding occurrence, depending on how the var is introduced and whether it's in typed
+ // code.
+ private StarlarkType getType(Identifier id) {
+ Resolver.Binding binding = id.getBinding();
+ Preconditions.checkNotNull(binding);
+ StarlarkType type = binding.getType();
+ return type != null ? type : Types.ANY;
+ }
+
+ /**
+ * Infers the type of an expression from a bottom-up traversal, relying on type information stored
+ * in identifier bindings by the {@link TypeResolver}.
+ *
+ * <p>May not be called on type expressions (annotations, var statements, type alias statements).
+ */
+ private StarlarkType infer(Expression expr) {
+ switch (expr.kind()) {
+ case IDENTIFIER -> {
+ var id = (Identifier) expr;
+ return switch (id.getName()) {
+ // As a hack, we special-case the names of these universal symbols that should really be
+ // keywords.
+ // TODO: #27728 - Instead of special casing, ensure type information is stored correctly
+ // for the universal/predeclared symbols in the module, and retrieve it from there at type
+ // resolution time. Then here we just need to get it from the binding like anything else.
+ case "True", "False" -> Types.BOOL;
+ case "None" -> Types.NONE;
+ default -> getType(id);
+ };
+ }
+ case STRING_LITERAL -> {
+ return Types.STR;
+ }
+ case INT_LITERAL -> {
+ return Types.INT;
+ }
+ case FLOAT_LITERAL -> {
+ return Types.FLOAT;
+ }
+ default -> {
+ // TODO: #28037 - support binaryop, call, cast, comprehension, conditional, dict_expr, dot,
+ // index, lambda, list, and unaryop expressions.
+ throw new UnsupportedOperationException(
+ String.format("cannot typecheck %s expression", expr.kind()));
+ }
+ }
+ }
+
+ // Expressions should only be visited via infer(), not the visit() dispatch mechanism.
+ // Override visit(Identifier) as a poison pill.
+ @Override
+ public void visit(Identifier id) {
+ throw new AssertionError(
+ String.format(
+ "TypeChecker#visit should not have reached Identifier node '%s'", id.getName()));
+ }
+
+ @Override
+ public void visit(AssignmentStatement assignment) {
+ if (assignment.isAugmented()) {
+ // TODO: #28037 - support this by validating that `lhs <op> rhs` would type check
+ throw new UnsupportedOperationException("cannot typecheck augmented assignment statements");
+ }
+ if (!(assignment.getLHS() instanceof Identifier id)) {
+ // TODO: #28037 - support LHSs containing multiple targets (list expression), field
+ // assignments, and subscript assignments.
+ throw new UnsupportedOperationException(
+ "cannot typecheck assignment statements with anything besides a single identifier on the"
+ + " LHS");
+ }
+ var lhsType = getType(id);
+ // TODO: #27370 - Do bidirectional inference, passing down information about the expected type
+ // from the LHS to the infer() call here, e.g. to construct the type of `[1, 2, 3]` as list[int]
+ // instead of list[object].
+ var rhsType = infer(assignment.getRHS());
+ if (!StarlarkType.assignableFrom(lhsType, rhsType)) {
+ errorf(assignment, "cannot assign type '%s' to '%s'", rhsType, lhsType);
+ }
+ }
+
+ @Override
+ public void visit(ExpressionStatement expr) {
+ // Check constraints in the expression, but ignore the resulting type.
+ // Don't dispatch to it via visit().
+ infer(expr.getExpression());
+ }
+
+ // No need to override visit() for FlowStatement.
+
+ @Override
+ public void visit(LoadStatement load) {
+ // Don't descend into children.
+ }
+
+ @Override
+ public void visit(TypeAliasStatement alias) {
+ // Don't descend into children.
+ }
+
+ @Override
+ public void visit(VarStatement var) {
+ // Don't descend into children.
+ }
+
+ // TODO: #28037 - Support `for`, `def`, `if`, and `return` statements.
+
+ /**
+ * Checks that the given file's AST satisfies the types in the bindings of its identifiers.
+ *
+ * <p>The file must have already been passed through the type resolver without error
+ *
+ * <p>Any type checking errors are appended to the file's errors list.
+ */
+ public static void check(StarlarkFile file) {
+ TypeChecker checker = new TypeChecker(file.errors);
+ checker.visit(file);
+ }
+}
diff --git a/src/main/java/net/starlark/java/syntax/TypeResolver.java b/src/main/java/net/starlark/java/syntax/TypeResolver.java
index 4d26b37..887da2d 100644
--- a/src/main/java/net/starlark/java/syntax/TypeResolver.java
+++ b/src/main/java/net/starlark/java/syntax/TypeResolver.java
@@ -37,7 +37,7 @@
*
* <p>Only a file that has passed the Resolver without errors should be run through this visitor.
*/
-public class TypeResolver extends NodeVisitor {
+public final class TypeResolver extends NodeVisitor {
// TODO: #27728 - Will be used when we support non-universal type symbols.
private final Module module;
diff --git a/src/main/java/net/starlark/java/types/StarlarkType.java b/src/main/java/net/starlark/java/types/StarlarkType.java
index bae9e3c..747bfe2 100644
--- a/src/main/java/net/starlark/java/types/StarlarkType.java
+++ b/src/main/java/net/starlark/java/types/StarlarkType.java
@@ -36,4 +36,29 @@
public List<StarlarkType> getSupertypes() {
return ImmutableList.of();
}
+
+ /**
+ * Returns whether a value of type {@code t2} can be assigned to a value of type {@code t1}.
+ *
+ * <p>In gradual typing terms, {@code t2} must be a "consistent subtype of" {@code t1}. This means
+ * that there is a way to substitute zero or more occurrences of {@code Any} in both terms, such
+ * that {@code t2} becomes a subtype of {@code t1} in the ordinary sense.
+ *
+ * <p>The Python glossary uses the term "assignable [to/from]" for this relation, and
+ * "materialization" to refer to the process of substituting {@code Any}.
+ */
+ // TODO: #28043 - Add support for:
+ // - subtyping (list[int] <= Sequence[int])
+ // - covariance (Sequence[int] <= Sequence[object])
+ // - proper treatment of materializing Any (Sequence[int] <= Sequence[Any])
+ // - transitive application of all of the above, including across unions
+ public static boolean assignableFrom(StarlarkType t1, StarlarkType t2) {
+ if (t1.equals(Types.ANY) || t2.equals(Types.ANY)) {
+ return true;
+ }
+ if (t1.equals(t2)) {
+ return true;
+ }
+ return false;
+ }
}
diff --git a/src/main/java/net/starlark/java/types/Types.java b/src/main/java/net/starlark/java/types/Types.java
index c9dfcc4..2e6014a 100644
--- a/src/main/java/net/starlark/java/types/Types.java
+++ b/src/main/java/net/starlark/java/types/Types.java
@@ -61,6 +61,7 @@
private static ImmutableMap<String, Object> makeTypeUniverse() {
ImmutableMap.Builder<String, Object> env = ImmutableMap.builder();
env //
+ .put("Any", ANY)
.put("None", NONE)
.put("bool", BOOL)
.put("int", INT)
diff --git a/src/test/java/net/starlark/java/syntax/BUILD b/src/test/java/net/starlark/java/syntax/BUILD
index c4ccaa9..6d58f53 100644
--- a/src/test/java/net/starlark/java/syntax/BUILD
+++ b/src/test/java/net/starlark/java/syntax/BUILD
@@ -29,6 +29,7 @@
"ResolverTest.java",
"StarlarkFileTest.java",
"SyntaxTests.java", # (suite)
+ "TypeCheckerTest.java",
"TypeResolverTest.java",
"TypesTest.java",
],
diff --git a/src/test/java/net/starlark/java/syntax/SyntaxTests.java b/src/test/java/net/starlark/java/syntax/SyntaxTests.java
index d51d476..48ffe06 100644
--- a/src/test/java/net/starlark/java/syntax/SyntaxTests.java
+++ b/src/test/java/net/starlark/java/syntax/SyntaxTests.java
@@ -30,6 +30,7 @@
ProgramTest.class,
ResolverTest.class,
StarlarkFileTest.class,
+ TypeCheckerTest.class,
TypeResolverTest.class,
TypesTest.class,
})
diff --git a/src/test/java/net/starlark/java/syntax/TypeCheckerTest.java b/src/test/java/net/starlark/java/syntax/TypeCheckerTest.java
new file mode 100644
index 0000000..e285285
--- /dev/null
+++ b/src/test/java/net/starlark/java/syntax/TypeCheckerTest.java
@@ -0,0 +1,176 @@
+// Copyright 2025 The Bazel Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package net.starlark.java.syntax;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static net.starlark.java.syntax.LexerTest.assertContainsError;
+
+import com.google.common.base.Joiner;
+import net.starlark.java.syntax.Resolver.Module;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class TypeCheckerTest {
+
+ private final FileOptions.Builder options =
+ FileOptions.builder()
+ .allowTypeSyntax(true)
+ // This lets us construct simpler test cases without wrapper `def` statements.
+ .allowToplevelRebinding(true);
+
+ /**
+ * Throws {@link AssertionError} if a file has errors, with an exception message that includes
+ * {@code what} and the errors.
+ */
+ private void assertNoErrors(String what, StarlarkFile file) {
+ if (!file.ok()) {
+ throw new AssertionError(
+ String.format("Unexpected errors: %s:\n%s", what, Joiner.on("\n").join(file.errors())));
+ }
+ }
+
+ /**
+ * Parses, resolve, and type-resolves a file, then statically typechecks it.
+ *
+ * <p>Asserts that steps before typechecking succeeded, but the typechecking itself may fail. The
+ * resulting errors are available in the returned {@code StarlarkFile}.
+ */
+ private StarlarkFile typecheckFilePossiblyFailing(String... lines) throws Exception {
+ ParserInput input = ParserInput.fromLines(lines);
+ StarlarkFile file = StarlarkFile.parse(input, options.build());
+ assertNoErrors("parsing", file);
+ Module module = Resolver.moduleWithPredeclared();
+ Resolver.resolveFile(file, module);
+ assertNoErrors("resolving", file);
+ TypeResolver.annotateFile(file, module);
+ assertNoErrors("type-resolving", file);
+
+ TypeChecker.check(file);
+ return file;
+ }
+
+ /** As in {@link #typecheckFilePossiblyFailing} but asserts that even type checking succeeded. */
+ private StarlarkFile assertValid(String... lines) throws Exception {
+ StarlarkFile file = typecheckFilePossiblyFailing(lines);
+ assertThat(file.ok()).isTrue();
+ return file;
+ }
+
+ /** Asserts that type checking fails with at least the specified error. */
+ private void assertInvalid(String expectedError, String... lines) throws Exception {
+ StarlarkFile file = typecheckFilePossiblyFailing(lines);
+ assertWithMessage("type checking suceeded unexpectedly").that(file.ok()).isFalse();
+ assertContainsError(file.errors(), expectedError);
+ }
+
+ @Test
+ public void assignment_simple() throws Exception {
+ assertValid(
+ """
+ x: int
+ x = 123
+ """);
+
+ assertInvalid(
+ ":2:1: cannot assign type 'str' to 'int'",
+ """
+ x: int
+ x = "abc"
+ """);
+ }
+
+ @Test
+ public void canLookupIdentifierTypeOnRhs() throws Exception {
+ assertInvalid(
+ ":3:1: cannot assign type 'bool' to 'int'",
+ """
+ x: int
+ y: bool
+ x = y
+ """);
+ }
+
+ @Test
+ public void primitiveTypesAreInferredFromLiteralsAndAreIncompatible() throws Exception {
+ assertInvalid(
+ ":1:1: cannot assign type 'str' to 'bool'",
+ """
+ x: bool = 'abc'
+ """);
+ assertInvalid(
+ ":1:1: cannot assign type 'int' to 'float'",
+ """
+ x: float = 123
+ """);
+ assertInvalid(
+ ":1:1: cannot assign type 'float' to 'None'",
+ """
+ x: None = 1.0
+ """);
+ }
+
+ // TODO: #27728 - We should add a test that the types of universals, and in particular the
+ // keyword-like symbols `None`, `True`, and `False`, are appropriately inferred to have types
+ // None, bool, and bool respectively. This test would have to live in the eval package, since the
+ // universal environment is not available to the syntax/ package.
+
+ @Test
+ public void anyIsCompatibleInEitherDirection() throws Exception {
+ assertValid(
+ """
+ x: int
+ y: Any
+ x = y
+ """);
+ assertValid(
+ """
+ x: Any
+ y: int
+ x = y
+ """);
+ assertValid(
+ """
+ x: Any
+ y: Any
+ x = y
+ """);
+ }
+
+ // TODO: #27370 - The real behavior we want is that an unannotated variable has an inferred type
+ // if it is a non-parameter local variable in typed code, and Any type otherwise.
+ @Test
+ public void unannotatedVarIsAnyType() throws Exception {
+ assertValid(
+ """
+ x: int
+ y = "ignored"
+ x = y
+ """);
+ }
+
+ @Test
+ public void canTolerateIrrelevantStatementTypes() throws Exception {
+ assertValid(
+ """
+ load("...", "B")
+ type A = B
+ B # expression statement
+ """);
+ // TODO: #28037 - Check break/continue, once we support for and def statements
+ }
+}