[8.8.0] Accept and ignore Bazel 10 Starlark type syntax (#30373)

### Description

Backports the Bazel 10 Starlark type syntax parser to the 8.x line in
"accept but ignore" mode: `.bzl` files using type annotations, type
aliases, generic `def` parameters, `...`, `cast()`, and `isinstance()`
now load successfully, with all type information ignored. This matches
master's default behavior (`--experimental_starlark_type_syntax`
defaults to true, type checking off ⇒ annotations are parsed tolerantly
as arbitrary expressions and never resolved).

Without this PR, 8.8.0 fails to load even the most basic annotated file
with a syntax error:

```starlark
def f(x: list[str]):  # syntax error at ':': type annotations are disallowed
    pass
```

The following commits are cherry-picked, in order:

* 299e903710 Extend syntax with parameter type annotations
* 2dbda274cd Use type specific productions
* d1c3727795 Extend syntax with return types
* ab034aea73 Implement --experimental_starlark_types* flags
* fb7ca12439 Implement TypeApplication and fix NodePrinter
* aa61fc8a8d Allow empty type arguments for type applications (parser
support only)
* 0b615ff55e Introduce Expression.parseTypeExpression() (parser support
only)
* f60a22e8ef Allow parsing arbitrary uninterpreted Starlark type
expressions
* f3653ca8ae Allow parsing type alias statements
* 88678c00c4 Allow parsing generic type parameters in `def` statements
* 96d2f60fb9 Allow parsing `cast` expressions (and evaluating them as
the value argument)
* 664e87b594 Evaluate type alias statements as a no-op
* 5c8655ca1f Add variable type annotation syntax and resolver behavior
* 2390ce6760 Add Ellipsis token and node
* 515083c24a Add `isinstance` keyword, and allow isinstance(x,t) to be
parsed - but not resolved
* 44665ecd71 Properly gate dynamic type checking with flag (syntax
tolerance only)
* f38f0e448a Disallow type syntax in .scl files
* f1b3273f9f, 2004bad12f, f0011d8f29 (the three parser-only slices from
#30092: `tuple[T, ...]`, `tuple[()]`, struct type)

### How to review

Every commit keeps its original `Change-Id`, so the deviation of each
cherry-pick from its master original can be computed mechanically with
`git range-diff` (checking out this PR as `<pr-head>`):

```sh
git log --reverse --format=%H origin/release-8.8.0..<pr-head> | while read pick; do
  cid=$(git log -1 --format=%B $pick | sed -n 's/^Change-Id: //p' | head -1)
  orig=$(git log --format=%H --grep="Change-Id: $cid" origin/master | tail -1)
  git range-diff --creation-factor=100 $orig^! $pick^!
done
```

Only the lines range-diff marks as changed between the two patches need
human review; everything else is upstream code verbatim. Running this
today gives the following review-effort map (changed diff lines per
commit): all commits are at or below 22 except 0b615ff55e (99),
f60a22e8ef (51), 664e87b594 (44), 5c8655ca1f (267), 44665ecd71 (133),
and f38f0e448a (91) — which are exactly the sliced/adapted commits
explained below. For the last three commits, substitute the
already-sliced commits from #30092 (1681dfb115, b172b104c3, 745ec85067)
as the range-diff baseline instead of the master originals; against that
baseline they deviate by 2, 12, and 2 lines respectively.

Each commit is best reviewed against its master original (referenced by
`PiperOrigin-RevId`/`Change-Id` in the message). Everything not listed
below is verbatim upstream code; in particular, the type grammar in
`Parser.java` (all `parseType*` productions, type aliases, generics,
`cast`/`isinstance`) and the new AST node files `TypeAliasStatement`,
`TypeApplication`, `Ellipsis`, and `IsInstanceExpression` are
byte-identical to current master.

Non-trivial deviations from master, grouped by cause:

**1. The type checking machinery is intentionally not backported**
(~4,800 lines on master: `StarlarkType`, `Types`, `TypeChecker`,
`TypeTagger`, `TypeResolver`, and the dynamic checks in
`Eval`/`StarlarkFunction`):

* 0b615ff55e is taken without the static `Resolver.resolveType()` (it
needs the types package).
* 44665ecd71 is reduced to its
`FileOptions`/`Parser`/flag/`BzlCompileFunction` hunks; the
`Eval`/`StarlarkFunction` dynamic-check gating hunks are dropped. The
`StarlarkSemantics.EXPERIMENTAL_STARLARK_TYPE_CHECKING` key constant is
added by hand since `BzlCompileFunction` still needs it.
* `CastExpression` lacks master's `starlarkType` field/getter/setter
(the type-tagging slot, only written by `TypeTagger`).
* Hunks touching checker test files
(`TypeCheckTest`/`DynamicTypeCheckTest`, the syntax-package
`StarlarkTypesTest`) are dropped wherever a commit touched them.
* Net effect: `--experimental_starlark_type_checking` (default off) on
8.x only switches annotations from tolerant to structured parsing; it
performs no type checking.

**2. The doc-comments feature (03921eded1) is not backported**, so its
threading is stripped from 5c8655ca1f's hunks:

* `AssignmentStatement` and `VarStatement` have no `docComments`
field/getter; their constructors take one fewer parameter.
* `Parser#parseAssignment` does not call `maybeParseTrailingDocComment`
(method not introduced), and `Expression#parseExpression` does not skip
`DOC_COMMENT_*` tokens.
* `Resolver#bind` is `bind(id, isLoad, hasType)` instead of master's
four-parameter form; `createBindingsForLHS` takes no doc-comments
parameter.

**3. `Binding.isSyntactic` does not exist on 8.x.** In the
one-annotation-per-declaration check inside `Resolver#bind`, master's
`bind.isSyntactic` is replaced by `bind.first != null` (on 8.x,
`Binding#first` is documented as "first binding use, if syntactic", so
the two are equivalent).

**4. Older test scaffolding on 8.x:**

* `EvaluationTestCase` gains `setFileOptions`/`getFileOptions` (ported
from master, folded into the 664e87b594 pick) because the cherry-picked
`EvaluationTest` cases need them.
* In `ParserTest`/`NodePrinterTest`/`ResolverTest`, only each commit's
own payload tests are taken where the surrounding context consists of
master-only tests that don't exist on 8.x; two `NodePrinterTest`
assertions stay in 8.x's `join(...)` style.

**5. The `parseExpression` → `parseExpr` rename** from f60a22e8ef is
applied in full so the parser stays textually close to master.

### Verification

`//src/test/java/net/starlark/java/...` and
`//src/test/java/com/google/devtools/build/lib/starlark:StarlarkTypesTest`
pass. Additionally verified end-to-end: a workspace exercising unknown
type names, providers as types, `list[str]`, `tuple[int, ...]`,
`tuple[()]`, `struct[{"a": int}]`, parameterized type aliases, variable
annotations, string-literal types, and runtime `cast()` passthrough
loads identically (default flags) with a Bazel built from this branch
and one built from master, and annotated module-level assignments
execute correctly.

### Motivation

Forward compatibility for the 8.x LTS line: once rulesets start adopting
Bazel 10 type annotations, users on Bazel 8 must still be able to load
those rulesets. This is the 8.x counterpart of the tolerance work that
already landed for 9.0.0 (#27760, #27838, #28069) and is pending for
9.3.0 (#30092).

### Build API Changes

Yes: this extends the accepted `.bzl` grammar and adds the experimental
flags `--experimental_starlark_type_syntax` (default true),
`--experimental_starlark_types_allowed_paths`, and
`--experimental_starlark_type_checking` (default false) to 8.x. Part of
the Starlark types effort (#27370). The change is purely additive and
backward compatible: previously invalid syntax now parses and is
ignored; `.scl` files continue to reject type syntax.

### Checklist

- [x] I have added tests for the new use cases (if any).
- [ ] I have updated the documentation (if applicable).

### Release Notes

RELNOTES: Bazel now parses and ignores the Starlark type annotation
syntax introduced by newer Bazel versions (gated by
`--experimental_starlark_type_syntax`, enabled by default).

---------

Co-authored-by: Googler <ilist@google.com>
Co-authored-by: arostovtsev <arostovtsev@google.com>
Co-authored-by: brandjon <brandjon@google.com>
Co-authored-by: Ian (Hee) Cha <heec@google.com>
diff --git a/src/main/java/com/google/devtools/build/lib/packages/semantics/BuildLanguageOptions.java b/src/main/java/com/google/devtools/build/lib/packages/semantics/BuildLanguageOptions.java
index 6f41b54..9699437 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/semantics/BuildLanguageOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/semantics/BuildLanguageOptions.java
@@ -777,6 +777,30 @@
   public boolean experimentalDormantDeps;
 
   @Option(
+      name = "experimental_starlark_type_syntax",
+      defaultValue = FlagConstants.DEFAULT_EXPERIMENTAL_STARLARK_TYPE_SYNTAX,
+      documentationCategory = OptionDocumentationCategory.STARLARK_SEMANTICS,
+      effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS},
+      metadataTags = {OptionMetadataTag.EXPERIMENTAL},
+      help =
+          """
+          Enables type annotations and related syntax in .bzl files. Locations of files where \
+          these are allowed is further restricted by `--experimental_starlark_types_allowed_paths`.
+          Type syntax is never permitted in .scl files regardless of this flag.
+          """)
+  public boolean experimentalStarlarkTypeSyntax;
+
+  @Option(
+      name = "experimental_starlark_types_allowed_paths",
+      converter = CommaSeparatedOptionListConverter.class,
+      defaultValue = FlagConstants.DEFAULT_EXPERIMENTAL_STARLARK_TYPES_ALLOWED_PATHS,
+      documentationCategory = OptionDocumentationCategory.STARLARK_SEMANTICS,
+      effectTags = {OptionEffectTag.LOADING_AND_ANALYSIS},
+      metadataTags = {OptionMetadataTag.EXPERIMENTAL},
+      help = "List of canonical Label prefixes under which Starlark type annotations are allowed.")
+  public List<String> experimentalStarlarkTypesAllowedPaths;
+
+  @Option(
       name = "incompatible_enable_deprecated_label_apis",
       defaultValue = "true",
       documentationCategory = OptionDocumentationCategory.STARLARK_SEMANTICS,
@@ -1007,6 +1031,8 @@
                 incompatibleDisableTargetDefaultProviderFields)
             .setBool(EXPERIMENTAL_RULE_EXTENSION_API, experimentalRuleExtensionApi)
             .setBool(EXPERIMENTAL_DORMANT_DEPS, experimentalDormantDeps)
+            .setBool(EXPERIMENTAL_STARLARK_TYPE_SYNTAX, experimentalStarlarkTypeSyntax)
+            .set(EXPERIMENTAL_STARLARK_TYPES_ALLOWED_PATHS, experimentalStarlarkTypesAllowedPaths)
             .setBool(INCOMPATIBLE_ENABLE_DEPRECATED_LABEL_APIS, enableDeprecatedLabelApis)
             .setBool(
                 INCOMPATIBLE_STOP_EXPORTING_BUILD_FILE_PATH, incompatibleStopExportingBuildFilePath)
@@ -1193,6 +1219,9 @@
   public static final String EXPERIMENTAL_RULE_EXTENSION_API =
       FlagConstants.DEFAULT_EXPERIMENTAL_RULE_EXTENSION_API_NAME;
   public static final String EXPERIMENTAL_DORMANT_DEPS = "-experimental_dormant_deps";
+
+  public static final String EXPERIMENTAL_STARLARK_TYPE_SYNTAX =
+      FlagConstants.EXPERIMENTAL_STARLARK_TYPE_SYNTAX_FLAG_NAME;
   public static final String INCOMPATIBLE_ENABLE_DEPRECATED_LABEL_APIS =
       "+incompatible_enable_deprecated_label_apis";
   public static final String INCOMPATIBLE_STOP_EXPORTING_BUILD_FILE_PATH =
@@ -1229,6 +1258,15 @@
       INCOMPATIBLE_ENFORCE_STARLARK_UTF8 =
           new StarlarkSemantics.Key<>(
               "incompatible_enforce_starlark_utf8", Utf8EnforcementMode.WARNING);
+  public static final StarlarkSemantics.Key<List<String>>
+      EXPERIMENTAL_STARLARK_TYPES_ALLOWED_PATHS =
+          new StarlarkSemantics.Key<>(
+              "experimental_starlark_types",
+              FlagConstants.DEFAULT_EXPERIMENTAL_STARLARK_TYPES_ALLOWED_PATHS.isEmpty()
+                  ? ImmutableList.of()
+                  : ImmutableList.copyOf(
+                      FlagConstants.DEFAULT_EXPERIMENTAL_STARLARK_TYPES_ALLOWED_PATHS.split(",")));
+
   public static final StarlarkSemantics.Key<Long> MAX_COMPUTATION_STEPS =
       new StarlarkSemantics.Key<>("max_computation_steps", 0L);
   public static final StarlarkSemantics.Key<Integer> NESTED_SET_DEPTH_LIMIT =
diff --git a/src/main/java/com/google/devtools/build/lib/packages/semantics/FlagConstants.java b/src/main/java/com/google/devtools/build/lib/packages/semantics/FlagConstants.java
index f550e1c..bdaa781 100644
--- a/src/main/java/com/google/devtools/build/lib/packages/semantics/FlagConstants.java
+++ b/src/main/java/com/google/devtools/build/lib/packages/semantics/FlagConstants.java
@@ -22,9 +22,11 @@
   private FlagConstants() {}
 
   public static final String DEFAULT_EXPERIMENTAL_RULE_EXTENSION_API = "true";
-  public static final String DEFAULT_EXPERIMENTAL_RULE_EXTENSION_API_NAME = "+experimental_rule_extension_api";
+  public static final String DEFAULT_EXPERIMENTAL_RULE_EXTENSION_API_NAME =
+      "+experimental_rule_extension_api";
 
-  // TODO - ilist@: once Java providers are removed, the whole line can be compressed to "@rules_java"
+  // TODO - ilist@: once Java providers are removed, the whole line can be compressed to
+  // "@rules_java"
   public static final String DEFAULT_INCOMPATIBLE_AUTOLOAD_EXTERNALLY =
       "+@rules_python," +
       "+java_common,+JavaInfo,+JavaPluginInfo,ProguardSpecProvider," +
@@ -34,6 +36,13 @@
       "@rules_shell," +
       "+@rules_android";
 
+  // Enable annotations, but not actual type checking, with the effect that the parser tolerates
+  // arbitrary expressions in annotations for now.
+  public static final String EXPERIMENTAL_STARLARK_TYPE_SYNTAX_FLAG_NAME =
+      "+experimental_starlark_type_syntax";
+  public static final String DEFAULT_EXPERIMENTAL_STARLARK_TYPE_SYNTAX = "true";
+  public static final String DEFAULT_EXPERIMENTAL_STARLARK_TYPES_ALLOWED_PATHS = "";
+
   public static final String DEFAULT_INCOMPATIBLE_PACKAGE_GROUP_HAS_PUBLIC_SYNTAX = "true";
   public static final String DEFAULT_INCOMPATIBLE_FIX_PACKAGE_GROUP_REPOROOT_SYNTAX = "true";
 
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/BzlCompileFunction.java b/src/main/java/com/google/devtools/build/lib/skyframe/BzlCompileFunction.java
index c65f773..184a5d4 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/BzlCompileFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/BzlCompileFunction.java
@@ -20,6 +20,7 @@
 import com.google.devtools.build.lib.cmdline.BazelCompileContext;
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.events.EventHandler;
 import com.google.devtools.build.lib.packages.AutoloadSymbols;
 import com.google.devtools.build.lib.packages.BazelStarlarkEnvironment;
 import com.google.devtools.build.lib.packages.semantics.BuildLanguageOptions;
@@ -32,10 +33,12 @@
 import com.google.devtools.build.skyframe.SkyKey;
 import com.google.devtools.build.skyframe.SkyValue;
 import java.io.IOException;
+import java.util.List;
 import javax.annotation.Nullable;
 import net.starlark.java.eval.Module;
 import net.starlark.java.eval.StarlarkSemantics;
 import net.starlark.java.syntax.FileOptions;
+import net.starlark.java.syntax.Location;
 import net.starlark.java.syntax.ParserInput;
 import net.starlark.java.syntax.Program;
 import net.starlark.java.syntax.StarlarkFile;
@@ -185,6 +188,9 @@
         StarlarkUtil.InvalidUtf8Exception e) {
       return BzlCompileValue.noFile("compilation of '%s' failed", inputName);
     }
+
+    boolean useTypeSyntax = shouldUseTypeSyntax(semantics, key);
+
     FileOptions options =
         FileOptions.builder()
             // By default, Starlark load statements create file-local bindings.
@@ -199,6 +205,10 @@
             // matching the error message or reworking the interpreter API to put more structured
             // detail in errors (i.e. new fields or error subclasses).
             .stringLiteralsAreAsciiOnly(key.isSclDialect())
+            .allowTypeSyntax(useTypeSyntax)
+            // Bazel 8 only tolerates type syntax, it never type checks, so arbitrary expressions
+            // are accepted in type positions.
+            .tolerateInvalidTypeExpressions(true)
             .build();
     StarlarkFile file = StarlarkFile.parse(input, options);
 
@@ -221,7 +231,7 @@
       Program prog = Program.compileFile(file, module);
       return BzlCompileValue.withProgram(prog, digest);
     } catch (SyntaxError.Exception ex) {
-      Event.replayEventsOn(env.getListener(), ex.errors());
+      addSyntaxErrorsToListener(env.getListener(), ex.errors(), key);
       return BzlCompileValue.noFile(
           "compilation of module '%s'%s failed",
           key.label.toPathFragment(),
@@ -229,6 +239,54 @@
     }
   }
 
+  /**
+   * Whether the file should permit type syntax (annotations, etc.) based on flags and the type of
+   * file.
+   */
+  private static boolean shouldUseTypeSyntax(StarlarkSemantics semantics, BzlCompileValue.Key key) {
+    boolean typeSyntaxFlag =
+        semantics.getBool(BuildLanguageOptions.EXPERIMENTAL_STARLARK_TYPE_SYNTAX);
+    List<String> allowlist =
+        semantics.get(BuildLanguageOptions.EXPERIMENTAL_STARLARK_TYPES_ALLOWED_PATHS);
+
+    boolean okFiletype =
+        // annotations in prelude not allowed (it has null key.label)
+        !key.isBuildPrelude()
+            // annotations in SCL now allowed (not yet compatible with Go-Starlark interpreter)
+            && !key.isSclDialect();
+
+    if (typeSyntaxFlag && okFiletype) {
+      if (allowlist.isEmpty()
+          || allowlist.stream().anyMatch(s -> key.label.getCanonicalForm().startsWith(s))) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /**
+   * Replays the syntax errors from a file onto an event handler, adding more context if necessary.
+   */
+  private static void addSyntaxErrorsToListener(
+      EventHandler handler, List<SyntaxError> errors, BzlCompileValue.Key key) {
+    Event.replayEventsOn(handler, errors);
+    // If type annotations are disallowed, it could either be because the required flags aren't
+    // enabled or because the filetype disallows it.
+    for (var err : errors) {
+      if (err.message().contains(": type annotations are disallowed")) {
+        Location fileLoc = Location.fromFile(err.location().file());
+        String explanation =
+            key.isSclDialect()
+                ? "Type annotations are not permitted in .scl files."
+                : """
+                Type annotations syntax can be enabled with --experimental_starlark_type_syntax \
+                and/or --experimental_starlark_types_allowed_paths.\
+                """;
+        handler.handle(Event.error(fileLoc, explanation));
+      }
+    }
+  }
+
   static final class FailedIOException extends Exception {
     private final Transience transience;
 
diff --git a/src/main/java/net/starlark/java/eval/Eval.java b/src/main/java/net/starlark/java/eval/Eval.java
index cbe576d..2a0fcac 100644
--- a/src/main/java/net/starlark/java/eval/Eval.java
+++ b/src/main/java/net/starlark/java/eval/Eval.java
@@ -28,6 +28,7 @@
 import net.starlark.java.syntax.AssignmentStatement;
 import net.starlark.java.syntax.BinaryOperatorExpression;
 import net.starlark.java.syntax.CallExpression;
+import net.starlark.java.syntax.CastExpression;
 import net.starlark.java.syntax.Comprehension;
 import net.starlark.java.syntax.ConditionalExpression;
 import net.starlark.java.syntax.DefStatement;
@@ -304,6 +305,10 @@
         return TokenKind.PASS;
       case RETURN:
         return execReturn(fr, (ReturnStatement) st);
+      case TYPE_ALIAS:
+        return TokenKind.PASS;
+      case VAR:
+        return TokenKind.PASS;
     }
     throw new IllegalArgumentException("unexpected statement: " + st.kind());
   }
@@ -559,6 +564,11 @@
         return evalDot(fr, (DotExpression) expr);
       case CALL:
         return evalCall(fr, (CallExpression) expr);
+      case CAST:
+        return eval(fr, ((CastExpression) expr).getValue());
+      case ISINSTANCE:
+        fr.setErrorLocation(expr.getStartLocation());
+        throw new EvalException("isinstance() is not yet supported");
       case IDENTIFIER:
         return evalIdentifier(fr, (Identifier) expr);
       case INDEX:
@@ -587,6 +597,10 @@
         return ((StringLiteral) expr).getValue();
       case UNARY_OPERATOR:
         return evalUnaryOperator(fr, (UnaryOperatorExpression) expr);
+      case ELLIPSIS:
+      case TYPE_APPLICATION:
+        // fall through, these only appear in type expressions and should be unreachable from
+        // evaluated code.
     }
     throw new IllegalArgumentException("unexpected expression: " + expr.kind());
   }
diff --git a/src/main/java/net/starlark/java/syntax/AssignmentStatement.java b/src/main/java/net/starlark/java/syntax/AssignmentStatement.java
index 0145e67..d0d00c2 100644
--- a/src/main/java/net/starlark/java/syntax/AssignmentStatement.java
+++ b/src/main/java/net/starlark/java/syntax/AssignmentStatement.java
@@ -14,6 +14,7 @@
 
 package net.starlark.java.syntax;
 
+import com.google.common.base.Preconditions;
 import javax.annotation.Nullable;
 
 /**
@@ -23,8 +24,13 @@
 public final class AssignmentStatement extends Statement {
 
   private final Expression lhs; // = IDENTIFIER | DOT | INDEX | LIST_EXPR
+
+  // non-null only when lhs is an identifier and we're not augmented
+  @Nullable private final Expression type;
+
   @Nullable private final TokenKind op; // TODO(adonovan): make this mandatory even when '='.
   private final int opOffset;
+
   private final Expression rhs;
 
   /**
@@ -32,14 +38,28 @@
    * expression must be of the form {@code id}, {@code x.y}, {@code x[i]}, {@code [e, ...]}, or
    * {@code (e, ...)}, where x, i, and e are arbitrary expressions. For an augmented assignment, the
    * list and tuple forms are disallowed.
+   *
+   * <p>If a type annotation is present ({@code x : T = ...}), the LHS expression must be an
+   * identifier, and the assignment must not be augmented.
    */
   AssignmentStatement(
-      FileLocations locs, Expression lhs, @Nullable TokenKind op, int opOffset, Expression rhs) {
+      FileLocations locs,
+      Expression lhs,
+      @Nullable Expression type,
+      @Nullable TokenKind op,
+      int opOffset,
+      Expression rhs) {
     super(locs, Kind.ASSIGNMENT);
     this.lhs = lhs;
+    this.type = type;
     this.op = op;
     this.opOffset = opOffset;
     this.rhs = rhs;
+    if (type != null) {
+      Preconditions.checkState(
+          lhs.kind() == Expression.Kind.IDENTIFIER, "Can't have type annotation on complex LHS");
+      Preconditions.checkState(op == null, "Can't have augmented assignment with type annotation");
+    }
   }
 
   /** Returns the LHS of the assignment. */
@@ -47,6 +67,12 @@
     return lhs;
   }
 
+  /** Returns the type expression (if present) of the variable on the LHS. */
+  @Nullable
+  public Expression getType() {
+    return type;
+  }
+
   /** Returns the operator of an augmented assignment, or null for an ordinary assignment. */
   @Nullable
   public TokenKind getOperator() {
diff --git a/src/main/java/net/starlark/java/syntax/BUILD b/src/main/java/net/starlark/java/syntax/BUILD
index 9b13f78..1797511 100644
--- a/src/main/java/net/starlark/java/syntax/BUILD
+++ b/src/main/java/net/starlark/java/syntax/BUILD
@@ -21,12 +21,14 @@
         "AssignmentStatement.java",
         "BinaryOperatorExpression.java",
         "CallExpression.java",
+        "CastExpression.java",
         "Comment.java",
         "Comprehension.java",
         "ConditionalExpression.java",
         "DefStatement.java",
         "DictExpression.java",
         "DotExpression.java",
+        "Ellipsis.java",
         "Expression.java",
         "ExpressionStatement.java",
         "FileLocations.java",
@@ -38,6 +40,7 @@
         "IfStatement.java",
         "IndexExpression.java",
         "IntLiteral.java",
+        "IsInstanceExpression.java",
         "LambdaExpression.java",
         "Lexer.java",
         "ListExpression.java",
@@ -58,7 +61,10 @@
         "StringLiteral.java",
         "SyntaxError.java",
         "TokenKind.java",
+        "TypeAliasStatement.java",
+        "TypeApplication.java",
         "UnaryOperatorExpression.java",
+        "VarStatement.java",
     ],
     visibility = ["//src/main/java/net/starlark/java:clients"],
     # Do not add Bazel or Google dependencies here!
diff --git a/src/main/java/net/starlark/java/syntax/CastExpression.java b/src/main/java/net/starlark/java/syntax/CastExpression.java
new file mode 100644
index 0000000..3b8732c
--- /dev/null
+++ b/src/main/java/net/starlark/java/syntax/CastExpression.java
@@ -0,0 +1,66 @@
+// 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;
+
+/** Syntax node for cast() expressions. */
+public final class CastExpression extends Expression {
+  private final int startOffset;
+  private final Expression type;
+  private final Expression value;
+  private final int rparenOffset;
+
+  CastExpression(
+      FileLocations locs, int startOffset, Expression type, Expression value, int rparenOffset) {
+    super(locs, Kind.CAST);
+    this.startOffset = startOffset;
+    this.type = type;
+    this.value = value;
+    this.rparenOffset = rparenOffset;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return startOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rparenOffset + 1;
+  }
+
+  public Expression getType() {
+    return type;
+  }
+
+  public Expression getValue() {
+    return value;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder buf = new StringBuilder();
+    buf.append("cast(");
+    buf.append(type);
+    buf.append(", ");
+    buf.append(value);
+    buf.append(')');
+    return buf.toString();
+  }
+
+  @Override
+  public void accept(NodeVisitor visitor) {
+    visitor.visit(this);
+  }
+}
diff --git a/src/main/java/net/starlark/java/syntax/DefStatement.java b/src/main/java/net/starlark/java/syntax/DefStatement.java
index 884010d..920e8b8 100644
--- a/src/main/java/net/starlark/java/syntax/DefStatement.java
+++ b/src/main/java/net/starlark/java/syntax/DefStatement.java
@@ -22,8 +22,10 @@
 
   private final int defOffset;
   private final Identifier identifier;
+  private final ImmutableList<Identifier> typeParameters;
   private final ImmutableList<Statement> body; // non-empty if well formed
   private final ImmutableList<Parameter> parameters;
+  @Nullable private final Expression returnType;
 
   // set by resolver
   @Nullable private Resolver.Function resolved;
@@ -32,12 +34,16 @@
       FileLocations locs,
       int defOffset,
       Identifier identifier,
+      ImmutableList<Identifier> typeParameters,
       ImmutableList<Parameter> parameters,
+      @Nullable Expression returnType,
       ImmutableList<Statement> body) {
     super(locs, Kind.DEF);
     this.defOffset = defOffset;
     this.identifier = identifier;
+    this.typeParameters = typeParameters;
     this.parameters = Preconditions.checkNotNull(parameters);
+    this.returnType = returnType;
     this.body = Preconditions.checkNotNull(body);
   }
 
@@ -58,10 +64,19 @@
     return body;
   }
 
+  public ImmutableList<Identifier> getTypeParameters() {
+    return typeParameters;
+  }
+
   public ImmutableList<Parameter> getParameters() {
     return parameters;
   }
 
+  @Nullable
+  public Expression getReturnType() {
+    return returnType;
+  }
+
   void setResolvedFunction(Resolver.Function resolved) {
     this.resolved = resolved;
   }
diff --git a/src/main/java/net/starlark/java/syntax/Ellipsis.java b/src/main/java/net/starlark/java/syntax/Ellipsis.java
new file mode 100644
index 0000000..d20206a
--- /dev/null
+++ b/src/main/java/net/starlark/java/syntax/Ellipsis.java
@@ -0,0 +1,46 @@
+// 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;
+
+/** Syntax node for the singleton ellipsis expression. */
+public final class Ellipsis extends Expression {
+
+  private final int startOffset;
+
+  Ellipsis(FileLocations locs, int startOffset) {
+    super(locs, Kind.ELLIPSIS);
+    this.startOffset = startOffset;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return startOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return startOffset + 3;
+  }
+
+  @Override
+  public String toString() {
+    return "...";
+  }
+
+  @Override
+  public void accept(NodeVisitor visitor) {
+    visitor.visit(this);
+  }
+}
diff --git a/src/main/java/net/starlark/java/syntax/Expression.java b/src/main/java/net/starlark/java/syntax/Expression.java
index fa66a18..92f6c8f 100644
--- a/src/main/java/net/starlark/java/syntax/Expression.java
+++ b/src/main/java/net/starlark/java/syntax/Expression.java
@@ -35,15 +35,19 @@
     DICT_EXPR,
     DOT,
     CALL,
+    CAST,
+    ELLIPSIS,
     FLOAT_LITERAL,
     IDENTIFIER,
     INDEX,
     INT_LITERAL,
+    ISINSTANCE,
     LAMBDA,
     LIST_EXPR,
     SLICE,
     STRING_LITERAL,
     UNARY_OPERATOR,
+    TYPE_APPLICATION,
   }
 
   // Materialize kind as a field so its accessor can be non-virtual.
@@ -73,4 +77,20 @@
   public static Expression parse(ParserInput input) throws SyntaxError.Exception {
     return parse(input, FileOptions.DEFAULT);
   }
+
+  /**
+   * Parses a type expression.
+   *
+   * @param options parsing options; note that {@link FileOptions#allowStarlarkTypeSyntax} doesn't
+   *     need to be set - this method supports Starlark types implicitly.
+   */
+  public static Expression parseTypeExpression(ParserInput input, FileOptions options)
+      throws SyntaxError.Exception {
+    return Parser.parseTypeExpression(input, options);
+  }
+
+  /** Parses a type expression with default options. */
+  public static Expression parseTypeExpression(ParserInput input) throws SyntaxError.Exception {
+    return parseTypeExpression(input, FileOptions.DEFAULT);
+  }
 }
diff --git a/src/main/java/net/starlark/java/syntax/FileOptions.java b/src/main/java/net/starlark/java/syntax/FileOptions.java
index a7111dc..b3106cb 100644
--- a/src/main/java/net/starlark/java/syntax/FileOptions.java
+++ b/src/main/java/net/starlark/java/syntax/FileOptions.java
@@ -78,6 +78,21 @@
    */
   public abstract boolean stringLiteralsAreAsciiOnly();
 
+  /** Whether type annotations and related syntax are allowed in the source code. */
+  public abstract boolean allowTypeSyntax();
+
+  /**
+   * If true, type expressions in annotations and {@code type} declarations may be any valid
+   * expression (except for unparenthesized tuples, which are grammatically ambiguous). Otherwise
+   * type expressions must represent a valid type.
+   *
+   * <p>Enabling this boolean is helpful for backwards compatibility, but results in an AST that is
+   * not usable for type checking.
+   *
+   * <p>This has no effect if {@link #allowTypeSyntax} is false.
+   */
+  public abstract boolean tolerateInvalidTypeExpressions();
+
   public static Builder builder() {
     // These are the DEFAULT values.
     return new AutoValue_FileOptions.Builder()
@@ -85,7 +100,9 @@
         .allowToplevelRebinding(false)
         .loadBindsGlobally(false)
         .requireLoadStatementsFirst(true)
-        .stringLiteralsAreAsciiOnly(false);
+        .stringLiteralsAreAsciiOnly(false)
+        .allowTypeSyntax(false)
+        .tolerateInvalidTypeExpressions(false);
   }
 
   public abstract Builder toBuilder();
@@ -104,6 +121,10 @@
 
     public abstract Builder stringLiteralsAreAsciiOnly(boolean value);
 
+    public abstract Builder allowTypeSyntax(boolean value);
+
+    public abstract Builder tolerateInvalidTypeExpressions(boolean value);
+
     public abstract FileOptions build();
   }
 }
diff --git a/src/main/java/net/starlark/java/syntax/IsInstanceExpression.java b/src/main/java/net/starlark/java/syntax/IsInstanceExpression.java
new file mode 100644
index 0000000..469f889
--- /dev/null
+++ b/src/main/java/net/starlark/java/syntax/IsInstanceExpression.java
@@ -0,0 +1,66 @@
+// 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;
+
+/** Syntax node for isinstance() expressions. */
+public final class IsInstanceExpression extends Expression {
+  private final int startOffset;
+  private final Expression value;
+  private final Expression type;
+  private final int rparenOffset;
+
+  IsInstanceExpression(
+      FileLocations locs, int startOffset, Expression value, Expression type, int rparenOffset) {
+    super(locs, Kind.ISINSTANCE);
+    this.startOffset = startOffset;
+    this.value = value;
+    this.type = type;
+    this.rparenOffset = rparenOffset;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return startOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rparenOffset + 1;
+  }
+
+  public Expression getValue() {
+    return value;
+  }
+
+  public Expression getType() {
+    return type;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder buf = new StringBuilder();
+    buf.append("isinstance(");
+    buf.append(value);
+    buf.append(", ");
+    buf.append(type);
+    buf.append(')');
+    return buf.toString();
+  }
+
+  @Override
+  public void accept(NodeVisitor visitor) {
+    visitor.visit(this);
+  }
+}
diff --git a/src/main/java/net/starlark/java/syntax/Lexer.java b/src/main/java/net/starlark/java/syntax/Lexer.java
index fcfa1d0..3d9e28e 100644
--- a/src/main/java/net/starlark/java/syntax/Lexer.java
+++ b/src/main/java/net/starlark/java/syntax/Lexer.java
@@ -487,6 +487,9 @@
 
   private static final Map<String, TokenKind> keywordMap = new HashMap<>();
 
+  /** Additional keywords that are only recognized if --experimental_starlark_type_syntax is set. */
+  private static final Map<String, TokenKind> typeSyntaxExtraKeywordMap = new HashMap<>();
+
   static {
     keywordMap.put("and", TokenKind.AND);
     keywordMap.put("as", TokenKind.AS);
@@ -519,6 +522,9 @@
     keywordMap.put("while", TokenKind.WHILE);
     keywordMap.put("with", TokenKind.WITH);
     keywordMap.put("yield", TokenKind.YIELD);
+
+    typeSyntaxExtraKeywordMap.put("cast", TokenKind.CAST);
+    typeSyntaxExtraKeywordMap.put("isinstance", TokenKind.ISINSTANCE);
   }
 
   /**
@@ -531,10 +537,13 @@
     int oldPos = pos - 1;
     String id = identInterner.intern(scanIdentifier());
     TokenKind kind = keywordMap.get(id);
+    if (kind == null && options.allowTypeSyntax()) {
+      kind = typeSyntaxExtraKeywordMap.get(id);
+    }
     if (kind == null) {
       setToken(TokenKind.IDENTIFIER, oldPos, pos);
-      // setValue allocates a new String for the raw text, but it's not retained so we don't bother
-      // interning it.
+      // setValue allocates a new String for the raw text, but it's not retained so we don't
+      // bother interning it.
       setValue(id);
     } else {
       setToken(kind, oldPos, pos);
@@ -693,7 +702,12 @@
           setToken(TokenKind.PLUS, pos - 1, pos);
           break;
         case '-':
-          setToken(TokenKind.MINUS, pos - 1, pos);
+          if (peek(0) == '>') {
+            setToken(TokenKind.RARROW, pos - 1, pos + 1);
+            pos += 1;
+          } else {
+            setToken(TokenKind.MINUS, pos - 1, pos);
+          }
           break;
         case '|':
           setToken(TokenKind.PIPE, pos - 1, pos);
@@ -777,10 +791,10 @@
             }
           }
 
-          // int or float literal, or dot
+          // int or float literal, or dot, or ellipsis
           if (c == '.' || isdigit(c)) {
             pos--; // unconsume
-            scanNumberOrDot(c);
+            scanNumberOrDotOrEllipsis(c);
             break;
           }
 
@@ -808,22 +822,28 @@
     setToken(TokenKind.EOF, pos, pos);
   }
 
-  // Scans a number (INT or FLOAT) or DOT.
+  // Scans a number (INT or FLOAT) or DOT or ELLIPSIS.
   // Precondition: c == peek(0) (a dot or digit)
   //
   // TODO(adonovan): make this the precondition for all scan functions;
-  // currenly most assume their argument c has been consumed already.
-  private void scanNumberOrDot(int c) {
+  // currently most assume their argument c has been consumed already.
+  private void scanNumberOrDotOrEllipsis(int c) {
     int start = this.pos;
     boolean fraction = false;
     boolean exponent = false;
 
     if (c == '.') {
-      // dot or start of fraction
+      // dot or ellipsis or start of fraction
       if (!isdigit(peek(1))) {
-        pos++; // consume '.'
-        setToken(TokenKind.DOT, start, pos);
-        return;
+        if (peek(1) == '.' && peek(2) == '.') {
+          pos += 3; // consume '...'
+          setToken(TokenKind.ELLIPSIS, start, pos);
+          return;
+        } else {
+          pos++; // consume '.'
+          setToken(TokenKind.DOT, start, pos);
+          return;
+        }
       }
       fraction = true;
 
diff --git a/src/main/java/net/starlark/java/syntax/NodePrinter.java b/src/main/java/net/starlark/java/syntax/NodePrinter.java
index 16ca6e2..f21a596 100644
--- a/src/main/java/net/starlark/java/syntax/NodePrinter.java
+++ b/src/main/java/net/starlark/java/syntax/NodePrinter.java
@@ -97,7 +97,7 @@
     } else if (arg instanceof Argument.StarStar) {
       buf.append("**");
     }
-    printExpr(arg.getValue());
+    printExpr(arg.getValue(), true);
   }
 
   private void printParameter(Parameter param) {
@@ -129,14 +129,33 @@
   void printDefSignature(DefStatement def) {
     buf.append("def ");
     printExpr(def.getIdentifier());
+    if (!def.getTypeParameters().isEmpty()) {
+      buf.append("[");
+      String sep = "";
+      for (Identifier typeParam : def.getTypeParameters()) {
+        buf.append(sep);
+        printExpr(typeParam);
+        sep = ", ";
+      }
+      buf.append("]");
+    }
     buf.append('(');
     String sep = "";
     for (Parameter param : def.getParameters()) {
       buf.append(sep);
       printParameter(param);
+      if (param.getType() != null) {
+        buf.append(": ");
+        printExpr(param.getType(), true);
+      }
       sep = ", ";
     }
-    buf.append("):");
+    buf.append(")");
+    if (def.getReturnType() != null) {
+      buf.append(" -> ");
+      printExpr(def.getReturnType(), true);
+    }
+    buf.append(":");
   }
 
   private void printStmt(Statement s) {
@@ -147,6 +166,11 @@
         {
           AssignmentStatement stmt = (AssignmentStatement) s;
           printExpr(stmt.getLHS());
+          Expression type = stmt.getType();
+          if (type != null) {
+            buf.append(" : ");
+            printExpr(type);
+          }
           buf.append(' ');
           if (stmt.isAugmented()) {
             buf.append(stmt.getOperator());
@@ -250,23 +274,61 @@
           buf.append('\n');
           break;
         }
+
+      case TYPE_ALIAS:
+        {
+          TypeAliasStatement stmt = (TypeAliasStatement) s;
+          buf.append("type ");
+          printExpr(stmt.getIdentifier());
+          if (!stmt.getParameters().isEmpty()) {
+            buf.append('[');
+            String sep = "";
+            for (Identifier param : stmt.getParameters()) {
+              buf.append(sep);
+              printExpr(param);
+              sep = ", ";
+            }
+            buf.append(']');
+          }
+          buf.append(" = ");
+          printExpr(stmt.getDefinition(), /* canSkipParenthesis= */ true);
+          buf.append('\n');
+          break;
+        }
+
+      case VAR:
+        {
+          VarStatement stmt = (VarStatement) s;
+          printExpr(stmt.getIdentifier());
+          buf.append(" : ");
+          printExpr(stmt.getType());
+          buf.append('\n');
+          break;
+        }
     }
   }
 
   private void printExpr(Expression expr) {
+    printExpr(expr, false);
+  }
+
+  private void printExpr(Expression expr, boolean canSkipParenthesis) {
     switch (expr.kind()) {
       case BINARY_OPERATOR:
         {
           BinaryOperatorExpression binop = (BinaryOperatorExpression) expr;
-          // TODO(bazel-team): retain parentheses in the syntax tree so we needn't
-          // conservatively emit them here.
-          buf.append('(');
+          // TODO(bazel-team): print minimal number of parentheses
+          if (!canSkipParenthesis) {
+            buf.append('(');
+          }
           printExpr(binop.getX());
           buf.append(' ');
           buf.append(binop.getOperator());
           buf.append(' ');
           printExpr(binop.getY());
-          buf.append(')');
+          if (!canSkipParenthesis) {
+            buf.append(')');
+          }
           break;
         }
 
@@ -342,6 +404,23 @@
           break;
         }
 
+      case CAST:
+        {
+          CastExpression cast = (CastExpression) expr;
+          buf.append("cast(");
+          printExpr(cast.getType(), /* canSkipParenthesis= */ true);
+          buf.append(", ");
+          printExpr(cast.getValue(), /* canSkipParenthesis= */ true);
+          buf.append(')');
+          break;
+        }
+
+      case ELLIPSIS:
+        {
+          buf.append("...");
+          break;
+        }
+
       case IDENTIFIER:
         buf.append(((Identifier) expr).getName());
         break;
@@ -362,6 +441,17 @@
           break;
         }
 
+      case ISINSTANCE:
+        {
+          IsInstanceExpression isinstance = (IsInstanceExpression) expr;
+          buf.append("isinstance(");
+          printExpr(isinstance.getValue(), /* canSkipParenthesis= */ true);
+          buf.append(", ");
+          printExpr(isinstance.getType(), /* canSkipParenthesis= */ true);
+          buf.append(')');
+          break;
+        }
+
       case FLOAT_LITERAL:
         {
           buf.append(((FloatLiteral) expr).getValue());
@@ -390,7 +480,7 @@
           String sep = "";
           for (Expression e : list.getElements()) {
             buf.append(sep);
-            printExpr(e);
+            printExpr(e, true);
             sep = ", ";
           }
           if (list.isTuple() && list.getElements().size() == 1) {
@@ -472,12 +562,30 @@
       case UNARY_OPERATOR:
         {
           UnaryOperatorExpression unop = (UnaryOperatorExpression) expr;
-          // TODO(bazel-team): retain parentheses in the syntax tree so we needn't
-          // conservatively emit them here.
+          // TODO(bazel-team): print minimal number of parentheses
           buf.append(unop.getOperator() == TokenKind.NOT ? "not " : unop.getOperator().toString());
-          buf.append('(');
+          if (!canSkipParenthesis) {
+            buf.append('(');
+          }
           printExpr(unop.getX());
-          buf.append(')');
+          if (!canSkipParenthesis) {
+            buf.append(')');
+          }
+          break;
+        }
+
+      case TYPE_APPLICATION:
+        {
+          TypeApplication typeApplication = (TypeApplication) expr;
+          printExpr(typeApplication.getConstructor());
+          buf.append('[');
+          String sep = "";
+          for (Expression arg : typeApplication.getArguments()) {
+            buf.append(sep);
+            printExpr(arg, true);
+            sep = ", ";
+          }
+          buf.append(']');
           break;
         }
     }
diff --git a/src/main/java/net/starlark/java/syntax/NodeVisitor.java b/src/main/java/net/starlark/java/syntax/NodeVisitor.java
index 096f950..d399842 100644
--- a/src/main/java/net/starlark/java/syntax/NodeVisitor.java
+++ b/src/main/java/net/starlark/java/syntax/NodeVisitor.java
@@ -32,6 +32,7 @@
 
   // All four subclasses of Parameter are handled together.
   public void visit(Parameter node) {
+    // TODO(brandjon): visit type annotation
     visit(node.getIdentifier());
     if (node.getDefaultValue() != null) {
       visit(node.getDefaultValue());
@@ -53,6 +54,16 @@
     visitAll(node.getArguments());
   }
 
+  public void visit(CastExpression node) {
+    visit(node.getValue());
+  }
+
+  public void visit(IsInstanceExpression node) {
+    visit(node.getValue());
+  }
+
+  public void visit(Ellipsis node) {}
+
   public void visit(Identifier node) {}
 
   public void visit(Comprehension node) {
@@ -98,6 +109,7 @@
   public void visit(@SuppressWarnings("unused") StringLiteral node) {}
 
   public void visit(AssignmentStatement node) {
+    // TODO(brandjon): Visit type annotation if present
     visit(node.getRHS());
     visit(node.getLHS());
   }
@@ -115,6 +127,7 @@
   }
 
   public void visit(DefStatement node) {
+    // TODO(brandjon): Visit return type annotation
     visit(node.getIdentifier());
     visitAll(node.getParameters());
     visitBlock(node.getBody());
@@ -128,6 +141,14 @@
 
   public void visit(FlowStatement node) {}
 
+  public void visit(TypeAliasStatement node) {
+    // TODO(brandjon): resolve type alias
+  }
+
+  public void visit(VarStatement node) {
+    // TODO(brandjon): resolve var statement
+  }
+
   public void visit(DictExpression node) {
     visitAll(node.getEntries());
   }
diff --git a/src/main/java/net/starlark/java/syntax/Parameter.java b/src/main/java/net/starlark/java/syntax/Parameter.java
index 8b7da9b..836a469 100644
--- a/src/main/java/net/starlark/java/syntax/Parameter.java
+++ b/src/main/java/net/starlark/java/syntax/Parameter.java
@@ -13,6 +13,7 @@
 // limitations under the License.
 package net.starlark.java.syntax;
 
+import com.google.common.base.Preconditions;
 import javax.annotation.Nullable;
 
 /**
@@ -20,14 +21,19 @@
  *
  * <p>Parameters may be of four forms, as in {@code def f(a, b=c, *args, **kwargs)}. They are
  * represented by the subclasses Mandatory, Optional, Star, and StarStar.
+ *
+ * <p>Each parameter may have a type annotation. Star parameter without id/name, `(..., *, ...)`,
+ * cannot be annotated.
  */
 public abstract class Parameter extends Node {
 
   @Nullable private final Identifier id;
+  @Nullable private final Expression type;
 
-  private Parameter(FileLocations locs, @Nullable Identifier id) {
+  private Parameter(FileLocations locs, @Nullable Identifier id, @Nullable Expression type) {
     super(locs);
     this.id = id;
+    this.type = type;
   }
 
   @Nullable
@@ -45,13 +51,18 @@
     return null;
   }
 
+  @Nullable
+  public Expression getType() {
+    return type;
+  }
+
   /**
    * Syntax node for a mandatory parameter, {@code f(id)}. It may be positional or keyword-only
    * depending on its position.
    */
   public static final class Mandatory extends Parameter {
-    Mandatory(FileLocations locs, Identifier id) {
-      super(locs, id);
+    Mandatory(FileLocations locs, Identifier id, @Nullable Expression type) {
+      super(locs, id, type);
     }
 
     @Override
@@ -61,7 +72,7 @@
 
     @Override
     public int getEndOffset() {
-      return getIdentifier().getEndOffset();
+      return getType() != null ? getType().getEndOffset() : getIdentifier().getEndOffset();
     }
   }
 
@@ -73,8 +84,9 @@
 
     public final Expression defaultValue;
 
-    Optional(FileLocations locs, Identifier id, @Nullable Expression defaultValue) {
-      super(locs, id);
+    Optional(
+        FileLocations locs, Identifier id, @Nullable Expression type, Expression defaultValue) {
+      super(locs, id, type);
       this.defaultValue = defaultValue;
     }
 
@@ -100,12 +112,14 @@
     }
   }
 
-  /** Syntax node for a star parameter, {@code f(*id)} or or {@code f(..., *, ...)}. */
+  /** Syntax node for a star parameter, {@code f(*id)} or {@code f(..., *, ...)}. */
   public static final class Star extends Parameter {
     private final int starOffset;
 
-    Star(FileLocations locs, int starOffset, @Nullable Identifier id) {
-      super(locs, id);
+    Star(FileLocations locs, int starOffset, @Nullable Identifier id, @Nullable Expression type) {
+      super(locs, id, type);
+      Preconditions.checkArgument(
+          id != null || type == null, "Star parameter without id cannot have a type");
       this.starOffset = starOffset;
     }
 
@@ -116,7 +130,7 @@
 
     @Override
     public int getEndOffset() {
-      return getIdentifier().getEndOffset();
+      return getType() != null ? getType().getEndOffset() : getIdentifier().getEndOffset();
     }
   }
 
@@ -124,8 +138,8 @@
   public static final class StarStar extends Parameter {
     private final int starStarOffset;
 
-    StarStar(FileLocations locs, int starStarOffset, Identifier id) {
-      super(locs, id);
+    StarStar(FileLocations locs, int starStarOffset, Identifier id, @Nullable Expression type) {
+      super(locs, id, type);
       this.starStarOffset = starStarOffset;
     }
 
@@ -136,7 +150,7 @@
 
     @Override
     public int getEndOffset() {
-      return getIdentifier().getEndOffset();
+      return getType() != null ? getType().getEndOffset() : getIdentifier().getEndOffset();
     }
   }
 
diff --git a/src/main/java/net/starlark/java/syntax/Parser.java b/src/main/java/net/starlark/java/syntax/Parser.java
index 6f65fdc..5ddb304 100644
--- a/src/main/java/net/starlark/java/syntax/Parser.java
+++ b/src/main/java/net/starlark/java/syntax/Parser.java
@@ -18,12 +18,15 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
 import com.google.errorprone.annotations.FormatMethod;
 import java.util.ArrayList;
 import java.util.EnumSet;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import javax.annotation.Nullable;
 
 /** Parser is a recursive-descent parser for Starlark. */
@@ -90,15 +93,25 @@
           TokenKind.RPAREN,
           TokenKind.SLASH);
 
+  /** "type" is a keyword iff it precedes an identifier (such as in a type alias expression). */
+  private static final String TYPE_SOFT_KEYWORD = "type";
+
+  private static final boolean DEBUGGING = false;
+
   /** Current lookahead token. May be mutated by the parser. */
   private final Lexer token; // token.kind is a prettier alias for lexer.kind
 
-  private static final boolean DEBUGGING = false;
+  private final FileOptions options;
 
   private final Lexer lexer;
   private final FileLocations locs;
   private final List<SyntaxError> errors;
 
+  // State tracking whether we're currently parsing a type expression.
+  // Used for conditionally allowing the Ellipsis token.
+  private boolean insideTypeExpr = false;
+
+
   // TODO(adonovan): opt: compute this by subtraction.
   private static final Map<TokenKind, TokenKind> augmentedAssignments =
       new ImmutableMap.Builder<TokenKind, TokenKind>()
@@ -150,11 +163,12 @@
   // lexer can't handle.
   private final Map<String, String> stringInterner = new HashMap<>();
 
-  private Parser(Lexer lexer, List<SyntaxError> errors) {
+  private Parser(Lexer lexer, List<SyntaxError> errors, FileOptions options) {
     this.lexer = lexer;
     this.locs = lexer.locs;
     this.errors = errors;
     this.token = lexer;
+    this.options = options;
     nextToken();
   }
 
@@ -174,7 +188,7 @@
   static ParseResult parseFile(ParserInput input, FileOptions options) {
     List<SyntaxError> errors = new ArrayList<>();
     Lexer lexer = new Lexer(input, errors, options);
-    Parser parser = new Parser(lexer, errors);
+    Parser parser = new Parser(lexer, errors, options);
 
     StarlarkFile.ParseProfiler profiler = Parser.profiler;
     long profileStartNanos = profiler != null ? profiler.start() : -1;
@@ -209,12 +223,23 @@
   /** Parses an expression, possibly followed by newline tokens. */
   static Expression parseExpression(ParserInput input, FileOptions options)
       throws SyntaxError.Exception {
+    return parseValueOrTypeExpr(input, options, /* isTypeExpr= */ false);
+  }
+
+  /** Parses a type expression, possibly preceded or followed by comments or whitespace. */
+  static Expression parseTypeExpression(ParserInput input, FileOptions options)
+      throws SyntaxError.Exception {
+    return parseValueOrTypeExpr(input, options, /* isTypeExpr= */ true);
+  }
+
+  private static Expression parseValueOrTypeExpr(
+      ParserInput input, FileOptions options, boolean isTypeExpr) throws SyntaxError.Exception {
     List<SyntaxError> errors = new ArrayList<>();
     Lexer lexer = new Lexer(input, errors, options);
-    Parser parser = new Parser(lexer, errors);
+    Parser parser = new Parser(lexer, errors, options);
     Expression result = null;
     try {
-      result = parser.parseExpression();
+      result = isTypeExpr ? parser.parseTypeExprWithFallback() : parser.parseExpr();
       while (parser.token.kind == TokenKind.NEWLINE) {
         parser.nextToken();
       }
@@ -235,14 +260,20 @@
     return result;
   }
 
-  // Equivalent to 'testlist' rule in Python grammar. It can parse every kind of
-  // expression. In many cases, we need to use parseTest to avoid ambiguity:
-  //   e.g. fct(x, y)  vs  fct((x, y))
+  // Parses every kind of expression, including unparenthesized tuples.
   //
-  // A trailing comma is disallowed in an unparenthesized tuple.
-  // This prevents bugs where a one-element tuple is surprisingly created:
-  //   e.g. foo = f(x),
-  private Expression parseExpression() {
+  // In Python the corresponding grammar production is called `expressions` (or previously, in
+  // Python 3.8 and older, `testlist`).
+  //
+  // In many cases we need to use parseTest() in place of parseExpr() to avoid ambiguity, e.g.:
+  //
+  //   f(x, y)  vs  f((x, y))
+  //
+  // Unlike Python, a trailing comma is disallowed in an unparenthesized tuple.
+  // This prevents bugs where a one-element tuple is surprisingly created, e.g.:
+  //
+  //   foo = f(x),
+  private Expression parseExpr() {
     Expression e = parseTest();
     if (token.kind != TokenKind.COMMA) {
       return e;
@@ -266,12 +297,16 @@
   }
 
   private void syntaxError(String message) {
+    syntaxError(token.start, token.kind, token.value, message);
+  }
+
+  private void syntaxError(int offset, TokenKind tokenKind, Object tokenValue, String message) {
     if (!recoveryMode) {
-      if (token.kind == TokenKind.INDENT) {
-        reportError(token.start, "indentation error");
+      if (tokenKind == TokenKind.INDENT) {
+        reportError(offset, "indentation error");
       } else {
         reportError(
-            token.start, "syntax error at '%s': %s", tokenString(token.kind, token.value), message);
+            offset, "syntax error at '%s': %s", tokenString(tokenKind, tokenValue), message);
       }
       recoveryMode = true;
     }
@@ -432,14 +467,21 @@
     return new Argument.Positional(locs, expr);
   }
 
-  // arg = IDENTIFIER '=' test
-  //     | IDENTIFIER
-  private Parameter parseParameter() {
+  // arg = IDENTIFIER [':' TypeExpr] [ '=' test ]
+  //     | * [IDENTIFIER [':' TypeExpr]]
+  //     | ** IDENTIFIER [':' TypeExpr]
+  // Type annotations are only available on def statements (not lambdas)
+  private Parameter parseParameter(boolean defStatement) {
+    Expression type = null;
+
     // **kwargs
     if (token.kind == TokenKind.STAR_STAR) {
       int starStarOffset = nextToken();
       Identifier id = parseIdent();
-      return new Parameter.StarStar(locs, starStarOffset, id);
+      if (defStatement) {
+        type = maybeParseTypeAnnotationAfter(TokenKind.COLON);
+      }
+      return new Parameter.StarStar(locs, starStarOffset, id, type);
     }
 
     // * or *args
@@ -447,21 +489,30 @@
       int starOffset = nextToken();
       if (token.kind == TokenKind.IDENTIFIER) {
         Identifier id = parseIdent();
-        return new Parameter.Star(locs, starOffset, id);
+        if (defStatement) {
+          type = maybeParseTypeAnnotationAfter(TokenKind.COLON);
+        }
+        return new Parameter.Star(locs, starOffset, id, type);
       }
-      return new Parameter.Star(locs, starOffset, null);
-    }
-
-    // name=default
-    Identifier id = parseIdent();
-    if (token.kind == TokenKind.EQUALS) {
-      nextToken(); // TODO: save token pos?
-      Expression expr = parseTest();
-      return new Parameter.Optional(locs, id, expr);
+      return new Parameter.Star(locs, starOffset, null, null);
     }
 
     // name
-    return new Parameter.Mandatory(locs, id);
+    Identifier id = parseIdent();
+
+    // name: type
+    if (defStatement) {
+      type = maybeParseTypeAnnotationAfter(TokenKind.COLON);
+    }
+
+    // name=default
+    if (token.kind == TokenKind.EQUALS) {
+      nextToken(); // TODO: save token pos?
+      Expression expr = parseTest();
+      return new Parameter.Optional(locs, id, type, expr);
+    }
+
+    return new Parameter.Mandatory(locs, id, type);
   }
 
   // call_suffix = '(' arg_list? ')'
@@ -475,6 +526,36 @@
     return new CallExpression(locs, fn, locs.getLocation(lparenOffset), args, rparenOffset);
   }
 
+  // cast_expression = 'cast' '(' TypeExpr ',' expr [','] ')'
+  private Expression parseCastExpression() {
+    checkAllowTypeSyntax(token.start, token.kind, token.value);
+    int startOffset = expect(TokenKind.CAST);
+    expect(TokenKind.LPAREN);
+    Expression typeExpr = parseTypeExprWithFallback();
+    expect(TokenKind.COMMA);
+    Expression valueExpr = parseTest();
+    if (token.kind == TokenKind.COMMA) {
+      expect(TokenKind.COMMA);
+    }
+    int rparenOffset = expect(TokenKind.RPAREN);
+    return new CastExpression(locs, startOffset, typeExpr, valueExpr, rparenOffset);
+  }
+
+  // isinstance_expression = 'isinstance' '(' expr ',' TypeExpr [','] ')'
+  private Expression parseIsInstanceExpression() {
+    checkAllowTypeSyntax(token.start, token.kind, token.value);
+    int startOffset = expect(TokenKind.ISINSTANCE);
+    expect(TokenKind.LPAREN);
+    Expression valueExpr = parseTest();
+    expect(TokenKind.COMMA);
+    Expression typeExpr = parseTypeExprWithFallback();
+    if (token.kind == TokenKind.COMMA) {
+      expect(TokenKind.COMMA);
+    }
+    int rparenOffset = expect(TokenKind.RPAREN);
+    return new IsInstanceExpression(locs, startOffset, valueExpr, typeExpr, rparenOffset);
+  }
+
   // Parse a list of call arguments.
   //
   // arg_list = ( (arg ',')* arg ','? )?
@@ -575,6 +656,8 @@
   //          | '(' expr ')'               // a parenthesized expression
   //          | dict_expression
   //          | '-' primary_with_suffix
+  //          | cast_expression
+  //          | ellipsis                   // if in type expression
   private Expression parsePrimary() {
     switch (token.kind) {
       case INT:
@@ -655,6 +738,20 @@
           return new UnaryOperatorExpression(locs, op, offset, x);
         }
 
+      case CAST:
+        return parseCastExpression();
+
+      case ISINSTANCE:
+        return parseIsInstanceExpression();
+
+      case ELLIPSIS:
+        if (!insideTypeExpr) {
+          syntaxError("ellipsis ('...') is not allowed outside type expressions");
+          // Fall-through, may as well emit this instead of makeErrorExpression().
+        }
+        int offset = nextToken();
+        return new Ellipsis(locs, offset);
+
       default:
         {
           int start = token.start;
@@ -691,7 +788,7 @@
     Expression step = null;
 
     if (token.kind != TokenKind.COLON) {
-      start = parseExpression();
+      start = parseExpr();
 
       // index x[i]
       if (token.kind == TokenKind.RBRACKET) {
@@ -718,7 +815,7 @@
   // Equivalent to 'exprlist' rule in Python grammar.
   // loop_variables = primary_with_suffix ( ',' primary_with_suffix )* ','?
   private Expression parseForLoopVariables() {
-    // We cannot reuse parseExpression because it would parse the 'in' operator.
+    // We cannot reuse parseExpr because it would parse the 'in' operator.
     // e.g.  "for i in e: pass"  -> we want to parse only "i" here.
     Expression e1 = parsePrimaryWithSuffix();
     if (token.kind != TokenKind.COMMA) {
@@ -929,11 +1026,244 @@
     return new BinaryOperatorExpression(locs, x, op, opOffset, y);
   }
 
-  // Parses a non-tuple expression ("test" in Python terminology).
+  /**
+   * Returns true if type syntax is allowed. Otherwise, reports a syntax error for the given offset
+   * and token kind and value, and returns false.
+   */
+  @CanIgnoreReturnValue
+  private boolean checkAllowTypeSyntax(int offset, TokenKind tokenKind, Object tokenValue) {
+    if (options.allowTypeSyntax()) {
+      return true;
+    } else {
+      syntaxError(offset, tokenKind, tokenValue, "type annotations are disallowed");
+      return false;
+    }
+  }
+
+  @Nullable
+  private Expression maybeParseTypeAnnotationAfter(TokenKind expectedToken) {
+    if (token.kind == expectedToken && checkAllowTypeSyntax(token.start, token.kind, token.value)) {
+      nextToken();
+      return parseTypeExprWithFallback();
+    }
+    return null;
+  }
+
+  // Hook for parsing either a structured type expression, or an unstructured arbitrary expression
+  // (except for unparenthesized tuples). The latter is useless for type checking but allows the
+  // parser to never fail on parsing a type annotation it doesn't recognize (e.g. supported by a
+  // future version of Bazel), so long as it's valid expression syntax.
+  private Expression parseTypeExprWithFallback() {
+    Expression result;
+    this.insideTypeExpr = true;
+    if (options.tolerateInvalidTypeExpressions()) {
+      // parseTest, because allowing unparenthesized tuples here would consume subsequent params in
+      // function signatures.
+      result = parseTest();
+    } else {
+      result = parseTypeExpr();
+    }
+    this.insideTypeExpr = false;
+    return result;
+  }
+
+  // TypeExpr = TypeAtom {'|' TypeAtom}.
+  // TypeAtom = identifier [TypeArguments].
+  private Expression parseTypeExpr() {
+    if (token.kind != TokenKind.IDENTIFIER) {
+      int start = token.start;
+      syntaxError("expected a type");
+      int end = syncTo(EXPR_TERMINATOR_SET);
+      return makeErrorExpression(start, end);
+    }
+    Identifier typeOrConstructor = parseIdent();
+    Expression expr;
+    if (token.kind == TokenKind.LBRACKET) {
+      expr = parseTypeApplication(typeOrConstructor);
+    } else {
+      expr = typeOrConstructor;
+    }
+    while (token.kind == TokenKind.PIPE) {
+      int opOffset = nextToken();
+      Identifier secondTypeOrConstructor = parseIdent();
+      Expression y;
+      if (token.kind == TokenKind.LBRACKET) {
+        y = parseTypeApplication(secondTypeOrConstructor);
+      } else {
+        y = secondTypeOrConstructor;
+      }
+      expr = new BinaryOperatorExpression(locs, expr, TokenKind.PIPE, opOffset, y);
+    }
+    return expr;
+  }
+
+  // TypeArgument = TypeExpr | ListOfTypes | DictOfTypes | '(' ')' | string | ellipsis
+  private Expression parseTypeArgument() {
+    switch (token.kind) {
+      case LBRACKET: // [...]
+        return parseTypeList();
+      case LBRACE: // {...}
+        return parseTypeDict();
+      case LPAREN: // the empty tuple ()
+        {
+          int lparenOffset = expect(TokenKind.LPAREN);
+          int rparenOffset = expect(TokenKind.RPAREN);
+          return new ListExpression(
+              locs, /* isTuple= */ true, lparenOffset, ImmutableList.of(), rparenOffset);
+        }
+      case STRING:
+        return parseStringLiteral();
+      case ELLIPSIS:
+        return parsePrimary();
+      default:
+    }
+    if (token.kind != TokenKind.IDENTIFIER) {
+      int start = token.start;
+      syntaxError("expected a type argument");
+      int end = syncTo(EXPR_TERMINATOR_SET);
+      return makeErrorExpression(start, end);
+    }
+    return parseTypeExpr();
+  }
+
+  // ListOfTypes = '[' [TypeArgument {',' TypeArgument} [',']] ']'.
+  private Expression parseTypeList() {
+    int lbracketOffset = expect(TokenKind.LBRACKET);
+    ImmutableList.Builder<Expression> elems = ImmutableList.builder();
+    if (token.kind != TokenKind.RBRACKET) {
+      elems.add(parseTypeArgument());
+    }
+    while (token.kind != TokenKind.RBRACKET && token.kind != TokenKind.EOF) {
+      expect(TokenKind.COMMA);
+      if (token.kind == TokenKind.RBRACKET) {
+        break;
+      }
+      elems.add(parseTypeArgument());
+    }
+    int rbracketOffset = nextToken();
+    return new ListExpression(
+        locs, /* isTuple= */ false, lbracketOffset, elems.build(), rbracketOffset);
+  }
+
+  // TypeEntry = string ':' TypeArgument .
+  private DictExpression.Entry parseTypeDictEntry() {
+    Expression key;
+    if (token.kind == TokenKind.STRING) {
+      key = parseStringLiteral();
+    } else {
+      int start = token.start;
+      syntaxError(String.format("expected %s", TokenKind.STRING));
+      int end = syncTo(EXPR_TERMINATOR_SET);
+      key = makeErrorExpression(start, end);
+    }
+    int colonOffset = expect(TokenKind.COLON);
+    Expression value = parseTypeArgument();
+    return new DictExpression.Entry(locs, key, colonOffset, value);
+  }
+
+  // DictOfTypes = '{' [TypeEntry {',' TypeEntry} [',']] '}' .
+  private Expression parseTypeDict() {
+    int lbraceOffset = expect(TokenKind.LBRACE);
+
+    ImmutableList.Builder<DictExpression.Entry> entries = ImmutableList.builder();
+    if (token.kind != TokenKind.RBRACE) {
+      entries.add(parseTypeDictEntry());
+    }
+    while (token.kind != TokenKind.RBRACE && token.kind != TokenKind.EOF) {
+      expect(TokenKind.COMMA);
+      if (token.kind == TokenKind.RBRACE) {
+        break;
+      }
+      entries.add(parseTypeDictEntry());
+    }
+
+    int rbraceOffset = nextToken();
+    return new DictExpression(locs, lbraceOffset, entries.build(), rbraceOffset);
+  }
+
+  // TypeArguments = '[' TypeArgument {',' TypeArgument} ']'.
+  private Expression parseTypeApplication(Identifier constructor) {
+    expect(TokenKind.LBRACKET);
+    ImmutableList.Builder<Expression> args = ImmutableList.builder();
+    args.add(parseTypeArgument());
+    while (token.kind != TokenKind.RBRACKET && token.kind != TokenKind.EOF) {
+      expect(TokenKind.COMMA);
+      args.add(parseTypeArgument());
+    }
+    int rbracketOffset = expect(TokenKind.RBRACKET);
+    return new TypeApplication(locs, constructor, args.build(), rbracketOffset);
+  }
+
+  private static boolean isTypeSoftKeyword(Node node) {
+    return node instanceof Identifier id && id.getName().equals(TYPE_SOFT_KEYWORD);
+  }
+
+  // type_alias_stmt = 'type' type_alias_stmt_tail
+  // type_alias_stmt_tail = identifier optional_type_params '=' TypeExpr
+  //
+  // This method assumes that 'type' has already been consumed to produce typeSoftKeywordNode.
+  private Statement parseTypeAliasStatementTail(Node typeSoftKeywordNode) {
+    Preconditions.checkArgument(isTypeSoftKeyword(typeSoftKeywordNode));
+    int startOffset = typeSoftKeywordNode.getStartOffset();
+    // For user-friendliness, mark the error as if it was detected at 'type'
+    checkAllowTypeSyntax(startOffset, TokenKind.IDENTIFIER, TYPE_SOFT_KEYWORD);
+    Identifier identifier = parseIdent();
+    ImmutableList<Identifier> parameters = parseOptionalTypeParameters();
+    expect(TokenKind.EQUALS);
+    Expression definition = parseTypeExprWithFallback();
+    return new TypeAliasStatement(locs, startOffset, identifier, parameters, definition);
+  }
+
+  // optional_type_params = ['[' identifier {',' identifier} [','] ']']
+  //
+  // For syntactic compatibility with Python, the list of identifiers in optional_type_params cannot
+  // contain duplicates; duplicate identifiers are treated as a syntax error.
+  //
+  // If the optional_type_params is absent (in other words, if the initial token is not '['), this
+  // method returns an empty list. (Note that if optional_type_params is present, it must contain at
+  // least one identifier.)
+  private ImmutableList<Identifier> parseOptionalTypeParameters() {
+    if (token.kind == TokenKind.LBRACKET) {
+      checkAllowTypeSyntax(token.start, token.kind, token.value);
+      nextToken();
+      ImmutableList.Builder<Identifier> parameters = ImmutableList.builder();
+      Set<String> uniqueParameterNames = new HashSet<>();
+      parameters.add(parseTypeParameter(uniqueParameterNames));
+      while (token.kind != TokenKind.RBRACKET && token.kind != TokenKind.EOF) {
+        expect(TokenKind.COMMA);
+        if (token.kind == TokenKind.RBRACKET) {
+          break;
+        }
+        parameters.add(parseTypeParameter(uniqueParameterNames));
+      }
+      expect(TokenKind.RBRACKET);
+      return parameters.build();
+    } else {
+      return ImmutableList.of();
+    }
+  }
+
+  private Identifier parseTypeParameter(Set<String> uniqueParameterNames) {
+    int tokenStart = token.start;
+    TokenKind tokenKind = token.kind;
+    Object tokenValue = token.value;
+    Identifier ident = parseIdent();
+    // If parseIdent() encountered a syntax error, Identifier.isValid(param.getName()) would be
+    // false, and in that case, there's no need to check for the param's uniqueness.
+    if (Identifier.isValid(ident.getName()) && !uniqueParameterNames.add(ident.getName())) {
+      syntaxError(tokenStart, tokenKind, tokenValue, "duplicate type parameter");
+    }
+    return ident;
+  }
+
+  // Parses any expression except for an unparenthesized tuple.
+  //
+  // In Python the corresponding grammar production is called `expression` (or previously, in
+  // Python 3.8 and older, `test`).
   private Expression parseTest() {
     int start = token.start;
     if (token.kind == TokenKind.LAMBDA) {
-      return parseLambda(/*allowCond=*/ true);
+      return parseLambda(/* allowCond= */ true);
     }
 
     Expression expr = parseTest(0);
@@ -966,7 +1296,7 @@
   // The allowCond flag allows the body to be an 'a if b else c' conditional.
   private LambdaExpression parseLambda(boolean allowCond) {
     int lambdaOffset = expect(TokenKind.LAMBDA);
-    ImmutableList<Parameter> params = parseParameters();
+    ImmutableList<Parameter> params = parseParameters(/* defStatement= */ false);
     expect(TokenKind.COLON);
     Expression body = allowCond ? parseTest() : parseTestNoCond();
     return new LambdaExpression(locs, lambdaOffset, params, body);
@@ -1114,14 +1444,18 @@
   }
 
   //     small_stmt = assign_stmt
+  //                | type_alias_stmt
   //                | expr
   //                | load_stmt
   //                | return_stmt
+  //                | var_stmt
   //                | BREAK | CONTINUE | PASS
   //
-  //     assign_stmt = expr ('=' | augassign) expr
+  //     assign_stmt = expr (':' expr)? ('=' | augassign) expr
   //
   //     augassign = '+=' | '-=' | '*=' | '/=' | '%=' | '//=' | '&=' | '|=' | '^=' |'<<=' | '>>='
+  //
+  //     var_stmt = IDENTIFIER ':' expr DOC_COMMENT_TRAILING?
   private Statement parseSmallStatement() {
     // return
     if (token.kind == TokenKind.RETURN) {
@@ -1142,16 +1476,61 @@
       return parseLoadStatement();
     }
 
-    Expression lhs = parseExpression();
+    // All other cases require an expression. Parse it now.
+    Expression lhs = parseExpr();
 
-    // lhs = rhs  or  lhs += rhs
+    // Type alias. This is the only context in which an identifier can be immediately followed by
+    // another identifier; the first identifier is the soft keyword `type`.
+    if (token.kind == TokenKind.IDENTIFIER && isTypeSoftKeyword(lhs)) {
+      return parseTypeAliasStatementTail(lhs);
+    }
+
+    // Possible type expression for var statement or assignment.
+    int colonOffset = token.start; // valid only if type != null below.
+    @Nullable Expression type = maybeParseTypeAnnotationAfter(TokenKind.COLON);
+
+    // If it's an assignment, the equals or augmented-equals operator will be next.
+    // op == null for ordinary assignment. TODO(adonovan): represent as EQUALS.
     TokenKind op = augmentedAssignments.get(token.kind);
     if (token.kind == TokenKind.EQUALS || op != null) {
+      // Assignment.
       int opOffset = nextToken();
-      Expression rhs = parseExpression();
+      Expression rhs = parseExpr();
+      // Validate usage of type annotation if present.
+      if (type != null) {
+        if (!(lhs instanceof Identifier)) {
+          syntaxError(
+              colonOffset,
+              TokenKind.COLON,
+              null,
+              "type annotations must have a single identifier on the left-hand side");
+          type = null;
+        }
+        if (op != null) {
+          syntaxError(
+              colonOffset,
+              TokenKind.COLON,
+              null,
+              "type annotations not allowed on augmented assignment statements");
+          type = null;
+        }
+      }
       // op == null for ordinary assignment. TODO(adonovan): represent as EQUALS.
-      return new AssignmentStatement(locs, lhs, op, opOffset, rhs);
+      return new AssignmentStatement(locs, lhs, type, op, opOffset, rhs);
+    } else if (type != null) {
+      // Var statement.
+      if (!(lhs instanceof Identifier id)) {
+        syntaxError(
+            colonOffset,
+            TokenKind.COLON,
+            null,
+            "type annotations must have a single identifier on the left-hand side");
+        return new ExpressionStatement(
+            locs, makeErrorExpression(lhs.getStartOffset(), type.getEndOffset()));
+      }
+      return new VarStatement(locs, id, type);
     } else {
+      // Not an assignment or var statement, so must be an expression.
       return new ExpressionStatement(locs, lhs);
     }
   }
@@ -1187,27 +1566,29 @@
     int forOffset = expect(TokenKind.FOR);
     Expression vars = parseForLoopVariables();
     expect(TokenKind.IN);
-    Expression collection = parseExpression();
+    Expression collection = parseExpr();
     expect(TokenKind.COLON);
     ImmutableList<Statement> body = parseSuite();
     return new ForStatement(locs, forOffset, vars, collection, body);
   }
 
-  // def_stmt = DEF IDENTIFIER '(' arguments ')' ':' suite
+  // def_stmt = DEF IDENTIFIER optional_type_parameters '(' arguments ')' ['->' TypeExpr] ':' suite
   private DefStatement parseDefStatement() {
     int defOffset = expect(TokenKind.DEF);
     Identifier ident = parseIdent();
+    ImmutableList<Identifier> typeParams = parseOptionalTypeParameters();
     expect(TokenKind.LPAREN);
-    ImmutableList<Parameter> params = parseParameters();
+    ImmutableList<Parameter> params = parseParameters(/* defStatement= */ true);
     expect(TokenKind.RPAREN);
+    Expression returnType = maybeParseTypeAnnotationAfter(TokenKind.RARROW);
     expect(TokenKind.COLON);
     ImmutableList<Statement> block = parseSuite();
-    return new DefStatement(locs, defOffset, ident, params, block);
+    return new DefStatement(locs, defOffset, ident, typeParams, params, returnType, block);
   }
 
   // Parse a list of function parameters.
   // Validation of parameter ordering and uniqueness is the job of the Resolver.
-  private ImmutableList<Parameter> parseParameters() {
+  private ImmutableList<Parameter> parseParameters(boolean defStatement) {
     boolean hasParam = false;
     ImmutableList.Builder<Parameter> list = ImmutableList.builder();
 
@@ -1221,7 +1602,7 @@
           break;
         }
       }
-      Parameter param = parseParameter();
+      Parameter param = parseParameter(defStatement);
       hasParam = true;
       list.add(param);
     }
@@ -1256,7 +1637,7 @@
 
     Expression result = null;
     if (!STATEMENT_TERMINATOR_SET.contains(token.kind)) {
-      result = parseExpression();
+      result = parseExpr();
     }
     return new ReturnStatement(locs, returnOffset, result);
   }
diff --git a/src/main/java/net/starlark/java/syntax/Resolver.java b/src/main/java/net/starlark/java/syntax/Resolver.java
index a9fe1c0..3495d9c 100644
--- a/src/main/java/net/starlark/java/syntax/Resolver.java
+++ b/src/main/java/net/starlark/java/syntax/Resolver.java
@@ -154,6 +154,7 @@
     private final String name;
     private final Location location;
     private final ImmutableList<Parameter> params;
+    @Nullable private final Expression returnType;
     private final ImmutableList<Statement> body;
     private final boolean hasVarargs;
     private final boolean hasKwargs;
@@ -169,6 +170,7 @@
         String name,
         Location loc,
         ImmutableList<Parameter> params,
+        @Nullable Expression returnType,
         ImmutableList<Statement> body,
         boolean hasVarargs,
         boolean hasKwargs,
@@ -179,6 +181,7 @@
       this.name = name;
       this.location = loc;
       this.params = params;
+      this.returnType = returnType;
       this.body = body;
       this.hasVarargs = hasVarargs;
       this.hasKwargs = hasKwargs;
@@ -283,6 +286,11 @@
       return params;
     }
 
+    @Nullable
+    public Expression getReturnType() {
+      return returnType;
+    }
+
     /**
      * Returns the effective statements of the function's body. (For the implicit function created
      * to evaluate a single standalone expression, this may contain a synthesized Return statement.)
@@ -456,7 +464,16 @@
   private void createBindings(Statement stmt) {
     switch (stmt.kind()) {
       case ASSIGNMENT:
-        createBindingsForLHS(((AssignmentStatement) stmt).getLHS());
+        AssignmentStatement assignStmt = (AssignmentStatement) stmt;
+        if (assignStmt.getType() != null) {
+          bind((Identifier) assignStmt.getLHS(), /* isLoad= */ false, /* hasType= */ true);
+        } else {
+          createBindingsForLHS(assignStmt.getLHS());
+        }
+        break;
+      case VAR:
+        VarStatement varStmt = (VarStatement) stmt;
+        bind(varStmt.getIdentifier(), /* isLoad= */ false, /* hasType= */ true);
         break;
       case IF:
         IfStatement ifStmt = (IfStatement) stmt;
@@ -472,7 +489,15 @@
         break;
       case DEF:
         DefStatement def = (DefStatement) stmt;
-        bind(def.getIdentifier(), /*isLoad=*/ false);
+        // Def statements are considered to supply a type annotation on the function identifier
+        // iff they have at least one piece of type syntax in their signature -- a type annotation
+        // on a parameter or return value, or a list of generic type variables.
+        // .
+        boolean hasType =
+            def.getParameters().stream().anyMatch(p -> p.getType() != null)
+                || def.getReturnType() != null
+                || !def.getTypeParameters().isEmpty();
+        bind(def.getIdentifier(), /* isLoad= */ false, /* hasType= */ hasType);
         break;
       case LOAD:
         LoadStatement load = (LoadStatement) stmt;
@@ -488,12 +513,14 @@
           // even if options.allowToplevelRebinding.
           Identifier local = b.getLocalName();
           if (names.add(local.getName())) {
-            bind(local, /*isLoad=*/ true);
+            bind(local, /* isLoad= */ true, /* hasType= */ false);
           } else {
             errorf(local, "load statement defines '%s' more than once", local.getName());
           }
         }
         break;
+      case TYPE_ALIAS:
+      // TODO(brandjon): create a type-valence binding for the alias
       case EXPRESSION:
       case FLOW:
       case RETURN:
@@ -501,9 +528,14 @@
     }
   }
 
+  /**
+   * Calls {@link #bind} for appropriate identifiers of the LHS of an assignment.
+   *
+   * <p>This is only appropriate when no type annotation applies.
+   */
   private void createBindingsForLHS(Expression lhs) {
     for (Identifier id : Identifier.boundIdentifiers(lhs)) {
-      bind(id, /*isLoad=*/ false);
+      bind(id, /* isLoad= */ false, /* hasType= */ false);
     }
   }
 
@@ -644,8 +676,7 @@
     pushLocalBlock(node, this.locals.frame, this.locals.freevars);
 
     for (Comprehension.Clause clause : clauses) {
-      if (clause instanceof Comprehension.For) {
-        Comprehension.For forClause = (Comprehension.For) clause;
+      if (clause instanceof Comprehension.For forClause) {
         createBindingsForLHS(forClause.getVars());
       }
     }
@@ -714,6 +745,29 @@
     assign(node.getLHS());
   }
 
+  @Override
+  public void visit(VarStatement node) {
+    assign(node.getIdentifier());
+  }
+
+  @Override
+  public void visit(IsInstanceExpression node) {
+    // TODO(b/350661266): restrict the types that can be used on the RHS of isinstance(); e.g.
+    // `list` or `list | tuple` (or aliases resolving to those!) are allowed, but `list[int]` isn't,
+    // since a list can subsequently be mutated to add a non-int element.
+    errorf(node, "isinstance() is not yet supported");
+  }
+
+  @Override
+  public void visit(TypeAliasStatement node) {
+    if (!(locals.syntax instanceof StarlarkFile)) {
+      errorf(node, "type alias statement not at top level");
+    }
+
+    // TODO(brandjon): resolve type alias
+    super.visit(node);
+  }
+
   // Resolves a non-binding identifier to an existing binding, or null.
   @Nullable
   private Binding use(Identifier id) {
@@ -909,6 +963,7 @@
         name,
         loc,
         params.build(),
+        syntax instanceof DefStatement def ? def.getReturnType() : null,
         body,
         star != null && star.getIdentifier() != null,
         starStar != null,
@@ -919,7 +974,13 @@
   }
 
   private void bindParam(ImmutableList.Builder<Parameter> params, Parameter param) {
-    if (bind(param.getIdentifier(), /*isLoad=*/ false)) {
+    if (!bind(
+        param.getIdentifier(),
+        /* isLoad= */ false,
+        // We set hasType to false, even if there is a param annotation. This is to avoid
+        // complaining that an erroneous duplicated parameter has a type annotation, when we should
+        // really just be complaining about the fact the param was duplicated at all.
+        /* hasType= */ false)) {
       errorf(param, "duplicate parameter: %s", param.getName());
     }
     params.add(param);
@@ -927,9 +988,14 @@
 
   /**
    * Process a binding use of a name by adding a binding to the current block if not already bound,
-   * and associate the identifier with it. Reports whether the name was already bound in this block.
+   * and associate the identifier with it.
+   *
+   * @param hasType true if this binding use has a type annotation associated with it and is not a
+   *     function parameter; an error is reported when hasType is true but the binding already
+   *     exists in this block.
+   * @return true if the name was newly bound in this block, or false if it already existed
    */
-  private boolean bind(Identifier id, boolean isLoad) {
+  private boolean bind(Identifier id, boolean isLoad, boolean hasType) {
     String name = id.getName();
     boolean isNew = false;
     Binding bind;
@@ -990,9 +1056,24 @@
     }
 
     id.setBinding(bind);
-    return !isNew;
+
+    if (hasType && !isNew) {
+      if (bind.first != null) {
+        errorf(
+            id, "type annotation on '%s' may only appear at its first declaration", id.getName());
+        errorf(bind.first, "'%s' first declared here", id.getName());
+      } else {
+        // The binding already exists and yet had no definition in this syntax tree. This shouldn't
+        // really be possible -- any binding that we should be shadowing would've be introduced by
+        // `use()` in the `visit()` traversal, which hasn't run yet.
+        errorf(id, "symbol '%s' cannot be annotated with a type", id.getName());
+      }
+    }
+
+    return isNew;
   }
 
+
   // Report conflicting top-level bindings of same scope, unless options.allowToplevelRebinding.
   private void toplevelRebinding(Identifier id, Binding prev) {
     if (!options.allowToplevelRebinding()) {
@@ -1090,13 +1171,14 @@
         new Function(
             "<toplevel>",
             file.getStartLocation(),
-            /*params=*/ ImmutableList.of(),
-            /*body=*/ stmts,
-            /*hasVarargs=*/ false,
-            /*hasKwargs=*/ false,
-            /*numKeywordOnlyParams=*/ 0,
+            /* params= */ ImmutableList.of(),
+            /* returnType= */ null,
+            /* body= */ stmts,
+            /* hasVarargs= */ false,
+            /* hasKwargs= */ false,
+            /* numKeywordOnlyParams= */ 0,
             frame,
-            /*freevars=*/ ImmutableList.of(),
+            /* freevars= */ ImmutableList.of(),
             r.globals));
   }
 
@@ -1123,13 +1205,14 @@
     return new Function(
         "<expr>",
         expr.getStartLocation(),
-        /*params=*/ ImmutableList.of(),
+        /* params= */ ImmutableList.of(),
+        /* returnType= */ null,
         ImmutableList.of(ReturnStatement.make(expr)),
-        /*hasVarargs=*/ false,
-        /*hasKwargs=*/ false,
-        /*numKeywordOnlyParams=*/ 0,
+        /* hasVarargs= */ false,
+        /* hasKwargs= */ false,
+        /* numKeywordOnlyParams= */ 0,
         frame,
-        /*freevars=*/ ImmutableList.of(),
+        /* freevars= */ ImmutableList.of(),
         r.globals);
   }
 
diff --git a/src/main/java/net/starlark/java/syntax/Statement.java b/src/main/java/net/starlark/java/syntax/Statement.java
index 717abe6..860ed4c 100644
--- a/src/main/java/net/starlark/java/syntax/Statement.java
+++ b/src/main/java/net/starlark/java/syntax/Statement.java
@@ -29,6 +29,8 @@
     IF,
     LOAD,
     RETURN,
+    TYPE_ALIAS,
+    VAR,
   }
 
   // Materialize kind as a field so its accessor can be non-virtual.
diff --git a/src/main/java/net/starlark/java/syntax/TokenKind.java b/src/main/java/net/starlark/java/syntax/TokenKind.java
index 0b6ac6e..dab4cc8 100644
--- a/src/main/java/net/starlark/java/syntax/TokenKind.java
+++ b/src/main/java/net/starlark/java/syntax/TokenKind.java
@@ -24,6 +24,8 @@
   BREAK("break"),
   CARET("^"),
   CARET_EQUALS("^="),
+  /** Emitted only if --experimental_starlark_type_syntax is enabled. */
+  CAST("cast"),
   CLASS("class"),
   COLON(":"),
   COMMA(","),
@@ -32,6 +34,8 @@
   DEL("del"),
   DOT("."),
   ELIF("elif"),
+  /** Valid only in type expressions. */
+  ELLIPSIS("..."),
   ELSE("else"),
   EOF("EOF"),
   EQUALS("="),
@@ -54,6 +58,8 @@
   INDENT("indent"),
   INT("integer literal"),
   IS("is"),
+  /** Emitted only if --experimental_starlark_type_syntax is enabled. */
+  ISINSTANCE("isinstance"),
   LAMBDA("lambda"),
   LBRACE("{"),
   LBRACKET("["),
@@ -80,6 +86,7 @@
   PLUS("+"),
   PLUS_EQUALS("+="),
   RAISE("raise"),
+  RARROW("->"),
   RBRACE("}"),
   RBRACKET("]"),
   RETURN("return"),
diff --git a/src/main/java/net/starlark/java/syntax/TypeAliasStatement.java b/src/main/java/net/starlark/java/syntax/TypeAliasStatement.java
new file mode 100644
index 0000000..f17e894
--- /dev/null
+++ b/src/main/java/net/starlark/java/syntax/TypeAliasStatement.java
@@ -0,0 +1,83 @@
+// 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.collect.ImmutableList;
+
+/** Represents a type alias statement in the Starlark AST. */
+public final class TypeAliasStatement extends Statement {
+  private final int startOffset;
+  private final Identifier identifier;
+  private final ImmutableList<Identifier> parameters;
+  private final Expression definition;
+
+  TypeAliasStatement(
+      FileLocations locs,
+      int startOffset,
+      Identifier identifier,
+      ImmutableList<Identifier> parameters,
+      Expression definition) {
+    super(locs, Kind.TYPE_ALIAS);
+    this.startOffset = startOffset;
+    this.identifier = identifier;
+    this.parameters = parameters;
+    this.definition = definition;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder buf = new StringBuilder();
+    buf.append("type ");
+    buf.append(identifier.getName());
+    if (!parameters.isEmpty()) {
+      buf.append('[');
+      ListExpression.appendNodes(buf, parameters);
+      buf.append(']');
+    }
+    buf.append(" = ...\n");
+    return buf.toString();
+  }
+
+  public Identifier getIdentifier() {
+    return identifier;
+  }
+
+  public ImmutableList<Identifier> getParameters() {
+    return parameters;
+  }
+
+  public Expression getDefinition() {
+    return definition;
+  }
+
+  /**
+   * {@inheritDoc}
+   *
+   * <p>Note that this is the start offset of the statement's {@code type} keyword.
+   */
+  @Override
+  public int getStartOffset() {
+    return startOffset;
+  }
+
+  @Override
+  public int getEndOffset() {
+    return definition.getEndOffset();
+  }
+
+  @Override
+  public void accept(NodeVisitor visitor) {
+    visitor.visit(this);
+  }
+}
diff --git a/src/main/java/net/starlark/java/syntax/TypeApplication.java b/src/main/java/net/starlark/java/syntax/TypeApplication.java
new file mode 100644
index 0000000..4cbfa5f
--- /dev/null
+++ b/src/main/java/net/starlark/java/syntax/TypeApplication.java
@@ -0,0 +1,72 @@
+// 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.common.collect.ImmutableList;
+
+/** Syntax node for a type application expression. */
+public final class TypeApplication extends Expression {
+
+  private final Identifier constructor;
+  private final ImmutableList<Expression> arguments;
+  private final int rbracketOffset;
+
+  TypeApplication(
+      FileLocations locs,
+      Identifier constructor,
+      ImmutableList<Expression> arguments,
+      int rbracketOffset) {
+    super(locs, Kind.TYPE_APPLICATION);
+    this.constructor = Preconditions.checkNotNull(constructor);
+    this.arguments = arguments;
+    this.rbracketOffset = rbracketOffset;
+  }
+
+  /** Returns the type constructor. */
+  public Identifier getConstructor() {
+    return this.constructor;
+  }
+
+  /** Returns the type arguments. */
+  public ImmutableList<Expression> getArguments() {
+    return arguments;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return constructor.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return rbracketOffset + 1;
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder buf = new StringBuilder();
+    buf.append(constructor);
+    buf.append('[');
+    ListExpression.appendNodes(buf, arguments);
+    buf.append(']');
+    return buf.toString();
+  }
+
+  @Override
+  public void accept(NodeVisitor visitor) {
+    visitor.visit(this);
+  }
+}
diff --git a/src/main/java/net/starlark/java/syntax/VarStatement.java b/src/main/java/net/starlark/java/syntax/VarStatement.java
new file mode 100644
index 0000000..5d65449
--- /dev/null
+++ b/src/main/java/net/starlark/java/syntax/VarStatement.java
@@ -0,0 +1,67 @@
+// 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 javax.annotation.Nullable;
+
+/**
+ * Syntax node for a variable type annotation appearing as its own statement ({@code foo : int}), as
+ * opposed to in an assignment statement where there's an initializer on the right-hand side.
+ *
+ * <p>(The name of this class is meant to be reminiscent of the `var` keyword that some languages
+ * use, although Python and Starlark have no special keyword for variable declarations.)
+ */
+public final class VarStatement extends Statement {
+
+  private final Identifier identifier;
+
+  private final Expression type;
+
+
+  /** Constructs a {@code VarStatement}. */
+  VarStatement(
+      FileLocations locs,
+      Identifier identifier,
+      Expression type) {
+    super(locs, Kind.VAR);
+    this.identifier = identifier;
+    this.type = type;
+  }
+
+  @Override
+  public int getStartOffset() {
+    return identifier.getStartOffset();
+  }
+
+  @Override
+  public int getEndOffset() {
+    return type.getEndOffset();
+  }
+
+  /** Returns the variable being declared and annotated. */
+  public Identifier getIdentifier() {
+    return identifier;
+  }
+
+  /** Returns the type expression associated with the variable. */
+  public Expression getType() {
+    return type;
+  }
+
+  @Override
+  public void accept(NodeVisitor visitor) {
+    visitor.visit(this);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/packages/semantics/ConsistencyTest.java b/src/test/java/com/google/devtools/build/lib/packages/semantics/ConsistencyTest.java
index 2ab8688..7e7f8c1 100644
--- a/src/test/java/com/google/devtools/build/lib/packages/semantics/ConsistencyTest.java
+++ b/src/test/java/com/google/devtools/build/lib/packages/semantics/ConsistencyTest.java
@@ -30,7 +30,9 @@
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
-// TODO(b/173631499): We really should just delete this test entirely.
+// TODO(b/173631499): We really should just delete this test entirely. However, it does catch the
+// case of flipping a flag default but forgetting to update its string from "-foo" to "+foo", so
+// make sure we have coverage for that.
 
 /**
  * Tests for the flow of flags from {@link BuildLanguageOptions} to {@link StarlarkSemantics}, and
diff --git a/src/test/java/com/google/devtools/build/lib/starlark/BUILD b/src/test/java/com/google/devtools/build/lib/starlark/BUILD
index 7db7312..4af9504 100644
--- a/src/test/java/com/google/devtools/build/lib/starlark/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/starlark/BUILD
@@ -119,6 +119,17 @@
 )
 
 java_test(
+    name = "StarlarkTypesTest",
+    srcs = ["StarlarkTypesTest.java"],
+    shard_count = 2,
+    deps = [
+        "//src/test/java/com/google/devtools/build/lib/analysis/util",
+        "//third_party:junit4",
+        "//third_party:truth",
+    ],
+)
+
+java_test(
     name = "StarlarkRuleClassFunctionsTest",
     srcs = ["StarlarkRuleClassFunctionsTest.java"],
     shard_count = 5,
diff --git a/src/test/java/com/google/devtools/build/lib/starlark/StarlarkTypesTest.java b/src/test/java/com/google/devtools/build/lib/starlark/StarlarkTypesTest.java
new file mode 100644
index 0000000..f445c80
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/starlark/StarlarkTypesTest.java
@@ -0,0 +1,120 @@
+// 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 com.google.devtools.build.lib.starlark;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.devtools.build.lib.analysis.util.BuildViewTestCase;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for Starlark types. */
+@RunWith(JUnit4.class)
+public class StarlarkTypesTest extends BuildViewTestCase {
+  @Test
+  public void experimentalStarlarkTypes_on_allowsTypeAnnotations() throws Exception {
+    setBuildLanguageOptions(
+        "--experimental_starlark_type_syntax",
+        "--experimental_starlark_types_allowed_paths=//test");
+    scratch.file(
+        "test/foo.bzl",
+        """
+        def f(a: int):
+          pass\
+        """);
+    scratch.file("test/BUILD", "load(':foo.bzl', 'f')");
+
+    getTarget("//test:BUILD");
+
+    assertNoEvents();
+  }
+
+  @Test
+  public void experimentalStarlarkTypes_off_disallowsTypeAnnotations() throws Exception {
+    setBuildLanguageOptions(
+        "--noexperimental_starlark_type_syntax",
+        "--experimental_starlark_types_allowed_paths=//test");
+    scratch.file(
+        "test/foo.bzl",
+        """
+        def f(a: int):
+          pass\
+        """);
+    scratch.file("test/BUILD", "load(':foo.bzl', 'f')");
+
+    checkLoadingPhaseError("//test:BUILD", "syntax error at ':': type annotations are disallowed");
+    assertContainsEvent(
+        "Type annotations syntax can be enabled with --experimental_starlark_type_syntax and/or"
+            + " --experimental_starlark_types_allowed_paths.");
+  }
+
+  @Test
+  public void experimentalStarlarkTypes_prohibitedInSclRegardlessOfFlag() throws Exception {
+    setBuildLanguageOptions("--experimental_starlark_type_syntax");
+    scratch.file(
+        "test/foo.scl",
+        """
+        def f(a: int):
+          pass\
+        """);
+    scratch.file("test/BUILD", "load(':foo.scl', 'f')");
+
+    checkLoadingPhaseError("//test:BUILD", "syntax error at ':': type annotations are disallowed");
+    assertContainsEvent("Type annotations are not permitted in .scl files.");
+  }
+
+  @Test
+  public void starlarkTypesAllowedPath_notOnPath_disallowsTypeAnnotations() throws Exception {
+    setBuildLanguageOptions(
+        "--experimental_starlark_type_syntax",
+        "--experimental_starlark_types_allowed_paths=//main");
+    scratch.file(
+        "test/foo.bzl",
+        """
+        def f(a: int):
+          pass\
+        """);
+    scratch.file("test/BUILD", "load(':foo.bzl', 'f')");
+
+    checkLoadingPhaseError("//test:BUILD", "syntax error at ':': type annotations are disallowed");
+    assertContainsEvent(
+        "Type annotations syntax can be enabled with --experimental_starlark_type_syntax and/or"
+            + " --experimental_starlark_types_allowed_paths.");
+  }
+
+  @Test
+  public void starlarkTypesAllowedPath_externalPath_allowsTypeAnnotations() throws Exception {
+    setBuildLanguageOptions(
+        "--experimental_starlark_type_syntax",
+        "--experimental_starlark_types_allowed_paths=@@r+//test");
+    scratch.overwriteFile(
+        "MODULE.bazel", "bazel_dep(name='r')", "local_path_override(module_name='r', path='/r')");
+    scratch.file("/r/MODULE.bazel", "module(name='r')");
+    scratch.file(
+        "/r/test/foo.bzl",
+        """
+        def f(a: int):
+          pass\
+        """);
+    scratch.file("/r/test/BUILD", "load(':foo.bzl', 'f')");
+
+    // Required since we have a new MODULE.bazel file.
+    invalidatePackages(true);
+    getTarget("@@r+//test:BUILD");
+
+    assertNoEvents();
+  }
+}
diff --git a/src/test/java/net/starlark/java/eval/EvaluationTest.java b/src/test/java/net/starlark/java/eval/EvaluationTest.java
index 23446bd..47c7dec 100644
--- a/src/test/java/net/starlark/java/eval/EvaluationTest.java
+++ b/src/test/java/net/starlark/java/eval/EvaluationTest.java
@@ -865,4 +865,53 @@
     }
     assertThat(module.getDocumentation()).isEqualTo("preset docstring");
   }
+
+  @Test
+  public void typeAliasStatement_evalsAsNoop() throws Exception {
+    ev.setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    ev.new Scenario().setUp("type X = int").testLookup("X", null);
+    ev.new Scenario().setUp("Y = 'foo'; type Y = bool").testLookup("Y", "foo");
+  }
+
+  @Test
+  public void varStatement_evalsAsNoop() throws Exception {
+    ev.setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    ev.new Scenario().setUp("X : int").testLookup("X", null);
+  }
+
+  @Test
+  public void varStatement_canLeaveToplevelSymbolcUninitialized() throws Exception {
+    ev.setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    ev.new Scenario()
+        .setUp(
+            """
+            X : int
+            def f():
+                print(X)
+            """)
+        .testIfErrorContains("global variable 'X' is referenced before assignment", "f()");
+  }
+
+  @Test
+  public void castExpression_evalsAsIdentity() throws Exception {
+    // The dynamic behavior of `cast` (disregarding type checking) is to return its value unchanged.
+    ev.setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    ev.new Scenario()
+        .setUp(
+            """
+            x = cast(list, [1])
+            y = cast(int, "this is not an int")
+            z = cast(dict[str, str], 42)
+            """)
+        .testEval("x", "[1]")
+        .testEval("y", "\"this is not an int\"")
+        .testEval("z", "42");
+  }
+
+  // TODO(b/350661266): resolve types in isinstance().
+  @Test
+  public void isinstanceExpression_notYetSupported() throws Exception {
+    ev.setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    ev.new Scenario().testIfExactError("isinstance() is not yet supported", "isinstance(x, list)");
+  }
 }
diff --git a/src/test/java/net/starlark/java/eval/EvaluationTestCase.java b/src/test/java/net/starlark/java/eval/EvaluationTestCase.java
index 272101b..e96d271 100644
--- a/src/test/java/net/starlark/java/eval/EvaluationTestCase.java
+++ b/src/test/java/net/starlark/java/eval/EvaluationTestCase.java
@@ -33,6 +33,7 @@
 class EvaluationTestCase {
 
   private StarlarkSemantics semantics = StarlarkSemantics.DEFAULT;
+  private FileOptions fileOptions = FileOptions.DEFAULT;
   private StarlarkThread thread = null; // created lazily by getStarlarkThread
   private Module module = null; // created lazily by getModule
 
@@ -48,6 +49,14 @@
     this.module = null;
   }
 
+  public FileOptions getFileOptions() {
+    return fileOptions;
+  }
+
+  public void setFileOptions(FileOptions fileOptions) {
+    this.fileOptions = fileOptions;
+  }
+
   // TODO(adonovan): don't let subclasses inherit vaguely specified "helpers".
   // Separate all the tests clearly into tests of the scanner, parser, resolver,
   // and evaluation.
@@ -69,14 +78,14 @@
   /** Joins the lines, parses them as an expression, and evaluates it. */
   final Object eval(String... lines) throws Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    return Starlark.eval(input, FileOptions.DEFAULT, getModule(), getStarlarkThread());
+    return Starlark.eval(input, fileOptions, getModule(), getStarlarkThread());
   }
 
   /** Joins the lines, parses them as a file, and executes it. */
   final void exec(String... lines)
       throws SyntaxError.Exception, EvalException, InterruptedException {
     ParserInput input = ParserInput.fromLines(lines);
-    Starlark.execFile(input, FileOptions.DEFAULT, getModule(), getStarlarkThread());
+    Starlark.execFile(input, fileOptions, getModule(), getStarlarkThread());
   }
 
   // A hook for subclasses to alter the created module.
diff --git a/src/test/java/net/starlark/java/syntax/BUILD b/src/test/java/net/starlark/java/syntax/BUILD
index cc7d468..c952f26 100644
--- a/src/test/java/net/starlark/java/syntax/BUILD
+++ b/src/test/java/net/starlark/java/syntax/BUILD
@@ -34,5 +34,6 @@
         "//third_party:guava",
         "//third_party:junit4",
         "//third_party:truth",
+        "@maven//:com_google_testparameterinjector_test_parameter_injector",
     ],
 )
diff --git a/src/test/java/net/starlark/java/syntax/LexerTest.java b/src/test/java/net/starlark/java/syntax/LexerTest.java
index 1346919..e3e1790 100644
--- a/src/test/java/net/starlark/java/syntax/LexerTest.java
+++ b/src/test/java/net/starlark/java/syntax/LexerTest.java
@@ -339,6 +339,17 @@
     check("foo.123", "IDENTIFIER(foo) FLOAT(0.123) NEWLINE EOF");
     check("foo.bcd", "IDENTIFIER(foo) DOT IDENTIFIER(bcd) NEWLINE EOF"); // 'b' are hex chars
     check("foo.xyz", "IDENTIFIER(foo) DOT IDENTIFIER(xyz) NEWLINE EOF");
+
+    check("..", "DOT DOT NEWLINE EOF");
+    check("...", "ELLIPSIS NEWLINE EOF");
+    check("....", "ELLIPSIS DOT NEWLINE EOF"); // ellipsis is consumed greedily before dot
+    check(".......", "ELLIPSIS ELLIPSIS DOT NEWLINE EOF");
+    check(". . . ", "DOT DOT DOT NEWLINE EOF");
+
+    check("1...", "FLOAT(1.0) DOT DOT NEWLINE EOF");
+    check("1...1", "FLOAT(1.0) DOT FLOAT(0.1) NEWLINE EOF");
+    check("1....1", "FLOAT(1.0) ELLIPSIS INT(1) NEWLINE EOF");
+    check("foo...bcd", "IDENTIFIER(foo) ELLIPSIS IDENTIFIER(bcd) NEWLINE EOF");
   }
 
   @Test
diff --git a/src/test/java/net/starlark/java/syntax/NodePrinterTest.java b/src/test/java/net/starlark/java/syntax/NodePrinterTest.java
index ac3bae0..da135ab 100644
--- a/src/test/java/net/starlark/java/syntax/NodePrinterTest.java
+++ b/src/test/java/net/starlark/java/syntax/NodePrinterTest.java
@@ -24,22 +24,27 @@
 /** Tests {@link Node#toString} and {@code NodePrinter}. */
 @RunWith(JUnit4.class)
 public final class NodePrinterTest {
+  private FileOptions fileOptions = FileOptions.DEFAULT;
 
-  private static StarlarkFile parseFile(String... lines) throws SyntaxError.Exception {
+  private void setFileOptions(FileOptions fileOptions) {
+    this.fileOptions = fileOptions;
+  }
+
+  private StarlarkFile parseFile(String... lines) throws SyntaxError.Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    StarlarkFile file = StarlarkFile.parse(input);
+    StarlarkFile file = StarlarkFile.parse(input, fileOptions);
     if (!file.ok()) {
       throw new SyntaxError.Exception(file.errors());
     }
     return file;
   }
 
-  private static Statement parseStatement(String... lines) throws SyntaxError.Exception {
+  private Statement parseStatement(String... lines) throws SyntaxError.Exception {
     return parseFile(lines).getStatements().get(0);
   }
 
-  private static Expression parseExpression(String... lines) throws SyntaxError.Exception {
-    return Expression.parse(ParserInput.fromLines(lines));
+  private Expression parseExpression(String... lines) throws SyntaxError.Exception {
+    return Expression.parse(ParserInput.fromLines(lines), fileOptions);
   }
 
   private static String join(String... lines) {
@@ -74,27 +79,27 @@
    * Parses the given string as an expression, and asserts that its pretty print matches the given
    * string.
    */
-  private static void assertExprPrettyMatches(String source, String expected)
+  private void assertExprPrettyMatches(String source, String expected)
       throws SyntaxError.Exception {
-      Expression node = parseExpression(source);
-      assertPrettyMatches(node, expected);
+    Expression node = parseExpression(source);
+    assertPrettyMatches(node, expected);
   }
 
   /**
    * Parses the given string as an expression, and asserts that its {@code toString} matches the
    * given string.
    */
-  private static void assertExprTostringMatches(String source, String expected)
+  private void assertExprTostringMatches(String source, String expected)
       throws SyntaxError.Exception {
-      Expression node = parseExpression(source);
-      assertThat(node.toString()).isEqualTo(expected);
+    Expression node = parseExpression(source);
+    assertThat(node.toString()).isEqualTo(expected);
   }
 
   /**
    * Parses the given string as an expression, and asserts that both its pretty print and {@code
    * toString} return the original string.
    */
-  private static void assertExprBothRoundTrip(String source) throws SyntaxError.Exception {
+  private void assertExprBothRoundTrip(String source) throws SyntaxError.Exception {
     assertExprPrettyMatches(source, source);
     assertExprTostringMatches(source, source);
   }
@@ -103,7 +108,7 @@
    * Parses the given string as a statement, and asserts that its pretty print with one indent
    * matches the given string.
    */
-  private static void assertStmtIndentedPrettyMatches(String source, String expected)
+  private void assertStmtIndentedPrettyMatches(String source, String expected)
       throws SyntaxError.Exception {
     Statement node = parseStatement(source);
     assertIndentedPrettyMatches(node, expected);
@@ -113,7 +118,7 @@
    * Parses the given string as an statement, and asserts that its {@code toString} matches the
    * given string.
    */
-  private static void assertStmtTostringMatches(String source, String expected)
+  private void assertStmtTostringMatches(String source, String expected)
       throws SyntaxError.Exception {
     Statement node = parseStatement(source);
     assertThat(node.toString()).isEqualTo(expected);
@@ -158,6 +163,7 @@
     assertExprBothRoundTrip("f(a)");
     assertExprBothRoundTrip("f(a, b = B, c = C, *d, **e)");
     assertExprBothRoundTrip("o.f()");
+    assertExprBothRoundTrip("f(1 + 1)");
   }
 
   @Test
@@ -168,6 +174,9 @@
   @Test
   public void indexExpression() throws SyntaxError.Exception {
     assertExprBothRoundTrip("a[i]");
+    assertExprBothRoundTrip("a[(1,)]");
+    assertExprPrettyMatches("a[1,2]", "a[(1, 2)]");
+    assertExprTostringMatches("a[1,2]", "a[(1, 2)]");
   }
 
   @Test
@@ -246,6 +255,13 @@
   }
 
   @Test
+  public void assignmentStatementWithTypeAnnotation() throws SyntaxError.Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    assertStmtIndentedPrettyMatches("x : T = y", "  x : T = y\n");
+    assertStmtTostringMatches("x : T = y", "x : T = y\n");
+  }
+
+  @Test
   public void expressionStatement() throws SyntaxError.Exception {
     assertStmtIndentedPrettyMatches("5", "  5\n");
     assertStmtTostringMatches("5", "5\n");
@@ -276,16 +292,87 @@
             "  print(x)"),
         "def f(a, b=B, *c, d=D, **e): ...\n");
 
+    assertStmtIndentedPrettyMatches(join("def f():", "  pass"), join("  def f():", "    pass", ""));
+    assertStmtTostringMatches(join("def f():", "  pass"), "def f(): ...\n");
+  }
+
+  @Test
+  public void defStatementWithTypeAnnotations() throws SyntaxError.Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
     assertStmtIndentedPrettyMatches(
-        join("def f():",
-             "  pass"),
-        join("  def f():",
-             "    pass",
-             ""));
+        join("def f(x:int):", "  print(x)"), join("  def f(x: int):", "    print(x)", ""));
+    assertStmtTostringMatches(join("def f(x:bool):", "  print(x)"), "def f(x: bool): ...\n");
+
+    assertStmtIndentedPrettyMatches(
+        join("def f()->int:", "  print(x)"), join("  def f() -> int:", "    print(x)", ""));
+    assertStmtTostringMatches(join("def f() -> bool:", "  print(x)"), "def f() -> bool: ...\n");
+    assertStmtIndentedPrettyMatches(
+        """
+        def f[T,U,](x:dict[T,U])->list[U]:
+          print(x)\
+        """,
+        """
+          def f[T, U](x: dict[T, U]) -> list[U]:
+            print(x)
+        """);
     assertStmtTostringMatches(
-        join("def f():",
-             "  pass"),
-        "def f(): ...\n");
+        """
+        def f[T,U,](x:dict[T,U]|set[U]) -> bool:
+          print(x)
+        """,
+        "def f[T, U](x: dict[T, U] | set[U]) -> bool: ...\n");
+  }
+
+  @Test
+  public void typeAnnotations() throws SyntaxError.Exception {
+    // TODO(ilist@): replace with parsing type annotations directly (remove `def` from this test)
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    assertStmtTostringMatches("def f(x:bool): pass", "def f(x: bool): ...\n");
+    assertStmtTostringMatches("def f(x:None | bool): pass", "def f(x: None | bool): ...\n");
+    assertStmtTostringMatches("def f(x:list[str]): pass", "def f(x: list[str]): ...\n");
+    assertStmtTostringMatches("def f(x:dict[str,int]): pass", "def f(x: dict[str, int]): ...\n");
+    assertStmtTostringMatches(
+        "def f(x:Callable[[str],int]): pass", "def f(x: Callable[[str], int]): ...\n");
+    assertStmtTostringMatches(
+        "def f(x:Callable[[str|int],int]): pass", "def f(x: Callable[[str | int], int]): ...\n");
+    assertStmtTostringMatches(
+        "def f(x:TypedDict[{'field1': int}]): pass",
+        "def f(x: TypedDict[{\"field1\": int}]): ...\n");
+  }
+
+  @Test
+  public void typeAliasStatement() throws SyntaxError.Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    assertStmtTostringMatches("type my_int=int", "type my_int = ...\n");
+    assertStmtIndentedPrettyMatches("type my_int=int", "  type my_int = int\n");
+    assertStmtTostringMatches("type X[T,U]=dict[T,U]|list[U]", "type X[T, U] = ...\n");
+    assertStmtIndentedPrettyMatches(
+        "type X[T,U]=dict[T,U]|list[U]", "  type X[T, U] = dict[T, U] | list[U]\n");
+  }
+
+  @Test
+  public void ellipsisExpression() throws SyntaxError.Exception {
+    setFileOptions(
+        FileOptions.builder().allowTypeSyntax(true).tolerateInvalidTypeExpressions(true).build());
+    // Use `def` rather than `type` to wrap the type expression, because `type`'s toString()
+    // introduces its own metasyntactic "..." placeholder.
+    assertStmtTostringMatches(
+        "def f(x:Callable[...,int]): pass", "def f(x: Callable[(..., int)]): ...\n");
+    assertStmtIndentedPrettyMatches("type x=...", "  type x = ...\n");
+  }
+
+  @Test
+  public void castExpression() throws SyntaxError.Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    assertExprPrettyMatches("cast(list[int]|str,x+y)", "cast(list[int] | str, x + y)");
+    assertExprTostringMatches("cast(set|None,bar(),)", "cast(set | None, bar())");
+  }
+
+  @Test
+  public void isinstanceExpression() throws SyntaxError.Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    assertExprPrettyMatches("isinstance(x+y, list|tuple)", "isinstance(x + y, list | tuple)");
+    assertExprTostringMatches("isinstance(foo(), T[U],)", "isinstance(foo(), T[U])");
   }
 
   @Test
@@ -366,6 +453,13 @@
   }
 
   @Test
+  public void varStatement() throws SyntaxError.Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    assertStmtIndentedPrettyMatches("x : T", "  x : T\n");
+    assertStmtTostringMatches("x : T\n", "x : T\n");
+  }
+
+  @Test
   public void returnStatement() throws SyntaxError.Exception {
     assertStmtIndentedPrettyMatches("return \"foo\"", "  return \"foo\"\n");
     assertStmtTostringMatches("return \"foo\"", "return \"foo\"\n");
diff --git a/src/test/java/net/starlark/java/syntax/ParserTest.java b/src/test/java/net/starlark/java/syntax/ParserTest.java
index 5abf798..7d849d5 100644
--- a/src/test/java/net/starlark/java/syntax/ParserTest.java
+++ b/src/test/java/net/starlark/java/syntax/ParserTest.java
@@ -21,19 +21,21 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
+import com.google.testing.junit.testparameterinjector.TestParameter;
+import com.google.testing.junit.testparameterinjector.TestParameterInjector;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import org.junit.Test;
 import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
 
 /** Tests of parser. */
-@RunWith(JUnit4.class)
+@RunWith(TestParameterInjector.class)
 public final class ParserTest {
 
   private final List<SyntaxError> events = new ArrayList<>();
   private boolean failFast = true;
+  private FileOptions fileOptions = FileOptions.DEFAULT;
 
   private SyntaxError assertContainsError(String expectedMessage) {
     return LexerTest.assertContainsError(events, expectedMessage);
@@ -43,21 +45,52 @@
     this.failFast = failFast;
   }
 
+  private void setFileOptions(FileOptions fileOptions) {
+    this.fileOptions = fileOptions;
+  }
+
   // Joins the lines, parse, and returns an expression.
-  private static Expression parseExpression(String... lines) throws SyntaxError.Exception {
+  private Expression parseExpression(String... lines) throws SyntaxError.Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    return Expression.parse(input);
+    return Expression.parse(input, fileOptions);
   }
 
   // Parses the expression, asserts that parsing fails,
   // and returns the first error message.
-  private static String parseExpressionError(String src) {
+  private String parseExpressionError(String src) {
     ParserInput input = ParserInput.fromLines(src);
     try {
-      Expression.parse(input);
-      throw new AssertionError("parseExpression(%s) succeeded unexpectedly: " + src);
+      Expression.parse(input, fileOptions);
+      throw new AssertionError("parseExpressionError() succeeded unexpectedly: " + src);
     } catch (SyntaxError.Exception ex) {
-      return ex.errors().get(0).message();
+      return ex.errors().get(0).toString();
+    }
+  }
+
+  // Parses the statement, asserts that parsing fails, and returns the first error message.
+  private String parseStatementError(String src) throws SyntaxError.Exception {
+    ParserInput input = ParserInput.fromLines(src);
+    StarlarkFile file = StarlarkFile.parse(input, fileOptions);
+    if (file.ok()) {
+      throw new AssertionError("parseStatementError() succeeded unexpectedly: " + src);
+    }
+    return file.errors().get(0).toString();
+  }
+
+  // Joins the lines, parse, and returns a type expression.
+  private Expression parseTypeExpression(String... lines) throws SyntaxError.Exception {
+    ParserInput input = ParserInput.fromLines(lines);
+    return Expression.parseTypeExpression(input, fileOptions);
+  }
+
+  // Parses the type expression, asserts that parsing fails, and returns the first error message.
+  private String parseTypeExpressionError(String src) {
+    ParserInput input = ParserInput.fromLines(src);
+    try {
+      Expression.parseTypeExpression(input, fileOptions);
+      throw new AssertionError("parseTypeExpressionError() succeeded unexpectedly: " + src);
+    } catch (SyntaxError.Exception ex) {
+      return ex.errors().get(0).toString();
     }
   }
 
@@ -65,7 +98,7 @@
   // Errors are added to this.events, or thrown if this.failFast;
   private StarlarkFile parseFile(String... lines) throws SyntaxError.Exception {
     ParserInput input = ParserInput.fromLines(lines);
-    StarlarkFile file = StarlarkFile.parse(input);
+    StarlarkFile file = StarlarkFile.parse(input, fileOptions);
     if (!file.ok()) {
       if (failFast) {
         throw new SyntaxError.Exception(file.errors());
@@ -339,8 +372,7 @@
     assertLocation(0, 14, slice);
   }
 
-  private static void evalSlice(String statement, Object... expectedArgs)
-      throws SyntaxError.Exception {
+  private void evalSlice(String statement, Object... expectedArgs) throws SyntaxError.Exception {
     SliceExpression e = (SliceExpression) parseExpression(statement);
 
     // There is no way to evaluate the expression here, so we rely on string comparison.
@@ -474,6 +506,89 @@
   }
 
   @Test
+  public void testVarAnnotation_basic() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+
+    Statement stmt = parseStatement("x : T");
+    assertThat(stmt).isInstanceOf(VarStatement.class);
+    assertThat(((VarStatement) stmt).getType().toString()).isEqualTo("T");
+
+    stmt = parseStatement("x : T = 123");
+    assertThat(stmt).isInstanceOf(AssignmentStatement.class);
+    assertThat(((AssignmentStatement) stmt).getType().toString()).isEqualTo("T");
+  }
+
+  @Test
+  public void testVarAnnotation_requiresTypeSyntax() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(false).build());
+    assertThat(parseStatementError("x : T"))
+        .contains("syntax error at ':': type annotations are disallowed");
+    assertThat(parseStatementError("x : T = 123"))
+        .contains("syntax error at ':': type annotations are disallowed");
+  }
+
+  @Test
+  public void testVarAnnotation_takesOneIdentifier() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+
+    // Complaint located at colon at column 6.
+    String errMessage =
+        ":1:6: syntax error at ':': type annotations must have a single identifier on the"
+            + " left-hand side";
+
+    assertThat(parseStatementError("x, y : T")).contains(errMessage);
+    assertThat(parseStatementError("x, y : T = 123")).contains(errMessage);
+
+    // This is *not* parsed as `x : (T, y)`, even though it might look unambiguous. Doing so would
+    // require knowing what the comma is before we know whether we're in a VarStatement or
+    // assignment statement. It's also not allowed by Python.
+    assertThat(parseStatementError("x : T, y"))
+        .contains(":1:6: syntax error at ',': expected newline");
+    assertThat(parseStatementError("x : T, y = 123"))
+        .contains(":1:6: syntax error at ',': expected newline");
+
+    assertThat(parseStatementError("x[0] : T")).contains(errMessage);
+    assertThat(parseStatementError("x[0] : T = 123")).contains(errMessage);
+
+    // Only applicable to assignment, not VarStatement.
+    assertThat(parseStatementError("(x : T, y) = 123"))
+        // TODO: #27370 - Is there a reasonable way to produce a more informative error message
+        // here, e.g. "type annotations are only allowed in assignment statements or variable
+        // declarations"?
+        .contains(":1:4: syntax error at ':': expected )");
+  }
+
+  @Test
+  public void testVarAnnotation_notAllowedOnAugmentedAssignment() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    assertThat(parseStatementError("x : T += 123"))
+        .contains(
+            ":1:3: syntax error at ':': type annotations not allowed on augmented assignment"
+                + " statements");
+  }
+
+  @Test
+  public void testVarAnnotation_illegalTypeExpression_allowedWithFlag() throws Exception {
+    setFileOptions(
+        FileOptions.builder().allowTypeSyntax(true).tolerateInvalidTypeExpressions(true).build());
+    assertThat(((VarStatement) parseStatement("x : (lambda x: x)")).getType())
+        .isInstanceOf(LambdaExpression.class);
+    assertThat(((AssignmentStatement) parseStatement("x : (lambda x: x) = 123")).getType())
+        .isInstanceOf(LambdaExpression.class);
+  }
+
+  @Test
+  public void testAssignWithAnnotation_illegalTypeExpression_disallowedWithoutFlag()
+      throws Exception {
+    setFileOptions(
+        FileOptions.builder().allowTypeSyntax(true).tolerateInvalidTypeExpressions(false).build());
+    assertThat(parseStatementError("x : (lambda x: x)"))
+        .contains(":1:5: syntax error at '(': expected a type");
+    assertThat(parseStatementError("x : (lambda x: x) = 123"))
+        .contains(":1:5: syntax error at '(': expected a type");
+  }
+
+  @Test
   public void testPrettyPrintFunctions() throws Exception {
     assertThat(parseStatements("x[1:3]").toString()).isEqualTo("[x[1:3]\n]");
     assertThat(parseStatements("x[1:3:1]").toString()).isEqualTo("[x[1:3:1]\n]");
@@ -624,7 +739,7 @@
     assertThat(getText(stmtStr, stmt)).isEqualTo(stmtStr);
   }
 
-  private static void assertExpressionLocationCorrect(String exprStr) throws SyntaxError.Exception {
+  private void assertExpressionLocationCorrect(String exprStr) throws SyntaxError.Exception {
     Expression expr = parseExpression(exprStr);
     assertThat(getText(exprStr, expr)).isEqualTo(exprStr);
     // Also try it with another token at the end (newline), which broke the location in the past.
@@ -1014,6 +1129,435 @@
   }
 
   @Test
+  public void testTypeExpression() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    // basic examples
+    assertThat(parseTypeExpression("int")).isInstanceOf(Identifier.class);
+    assertThat(parseTypeExpression("list[str]")).isInstanceOf(TypeApplication.class);
+    assertThat(parseTypeExpression("dict[str, int]")).isInstanceOf(TypeApplication.class);
+    // type applications must have at least one argument
+    assertThat(assertThrows(SyntaxError.Exception.class, () -> parseTypeExpression("tuple[]")))
+        .hasMessageThat()
+        .contains("syntax error at ']': expected a type argument");
+    // type expressions can use list literals
+    assertThat(parseTypeExpression("Callable[[int, str], int]"))
+        .isInstanceOf(TypeApplication.class);
+    // type expressions can use dict literals with string keys and type expression values
+    assertThat(parseTypeExpression("TypedDict[{'a': int, 'b': bool}]"))
+        .isInstanceOf(TypeApplication.class);
+    // (non-string keys, or non-type-expression values, are a parse-time error)
+    assertThat(
+            assertThrows(
+                SyntaxError.Exception.class, () -> parseTypeExpression("TypedDict[{x: y}]")))
+        .hasMessageThat()
+        .contains("syntax error at 'x': expected string literal");
+    assertThat(
+            assertThrows(
+                SyntaxError.Exception.class, () -> parseTypeExpression("TypedDict[{'x': foo()}]")))
+        .hasMessageThat()
+        .contains("syntax error at '(': expected ,");
+    // type expressions can use empty tuple literals
+    assertThat(parseTypeExpression("tuple[()]")).isInstanceOf(TypeApplication.class);
+    // ...but not non-empty tuples
+    assertThat(
+            assertThrows(
+                SyntaxError.Exception.class, () -> parseTypeExpression("tuple[(int, str)]")))
+        .hasMessageThat()
+        .contains("syntax error at 'int': expected )");
+    // type expressions can use string literals
+    assertThat(parseTypeExpression("Literal['abc']")).isInstanceOf(TypeApplication.class);
+    // composition
+    assertThat(parseTypeExpression("list[str, dict[str, bool]]"))
+        .isInstanceOf(TypeApplication.class);
+    // type unions
+    assertThat(parseTypeExpression("str | int")).isInstanceOf(BinaryOperatorExpression.class);
+    assertThat(parseTypeExpression("str | int | bool"))
+        .isInstanceOf(BinaryOperatorExpression.class);
+    // empty dict and list literals
+    assertThat(parseTypeExpression("Callable[[], TypeDict[{}]]"))
+        .isInstanceOf(TypeApplication.class);
+    // trailing commas in dict and list arguments
+    assertThat(parseTypeExpression("Callable[[int,],bool]")).isInstanceOf(TypeApplication.class);
+    assertThat(parseTypeExpression("TypeDict[{'foo': int, }]")).isInstanceOf(TypeApplication.class);
+  }
+
+  @Test
+  public void testIllegalTypeExpression_disallowed() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    parseStatement("def f(a : (lambda x: x)): pass");
+    assertContainsError("syntax error at '(': expected a type");
+  }
+
+  @Test
+  public void testIllegalTypeExpression_allowedWithFlag() throws Exception {
+    setFileOptions(
+        FileOptions.builder().allowTypeSyntax(true).tolerateInvalidTypeExpressions(true).build());
+
+    parseStatement("def f(a : (lambda x: x)): pass");
+    assertThat(parseTypeExpression("lambda x: x")).isInstanceOf(LambdaExpression.class);
+
+    // Annotations shouldn't consume adjacent params.
+    Statement stmt = parseStatement("def f(p1 : x, p2): pass");
+    assertThat(stmt.kind()).isEqualTo(Statement.Kind.DEF);
+    assertThat(((DefStatement) stmt).getParameters().stream().map(p -> p.getName()))
+        .containsExactly("p1", "p2")
+        .inOrder();
+  }
+
+  @Test
+  public void testDefWithTypeAnnotations() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    parseStatement("def f(a: int): pass");
+    parseStatement("def f(a: tuple[()]): pass");
+    parseStatement("def f(a: list[str]): pass");
+    parseStatement("def f(a: dict[str, int]): pass");
+
+    // test with default values
+    parseStatement("def f(a: int, *, b: bool = True, c): pass");
+
+    // test args and kwargs
+    parseStatement("def f(*args: list[int]): pass");
+    parseStatement("def f(**kwargs: dict[str, Any]): pass");
+
+    // Return type
+    parseStatement("def f() -> int: pass");
+
+    // Type parameters
+    parseStatement("def f[T, U](x: dict[T, U]) -> dict[U, T]: pass");
+  }
+
+  @Test
+  public void testDefBareStarCannotHaveTypeAnnotation() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    parseStatement("def f(a, *: int, b: bool): pass");
+    assertContainsError("syntax error at ':': expected )");
+  }
+
+  // TODO(ilist): Python allows trailing commas in type arguments - we probably should too.
+  @Test
+  public void testTrailingCommaNotAllowedInTypeArgumentList() throws Exception {
+    assertThat(parseTypeExpressionError("list[int,]"))
+        .contains("syntax error at ']': expected a type argument");
+  }
+
+  @Test
+  public void testDefWithDisallowedTypeAnnotations() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(false).build());
+    setFailFast(false);
+    parseStatement("def f(a: int): pass");
+    assertContainsError("syntax error at ':': type annotations are disallowed");
+    events.clear();
+    parseStatement("def f[T](): pass");
+    assertContainsError("syntax error at '[': type annotations are disallowed");
+  }
+
+  @Test
+  public void testTypeApplicationsRequireConstructor() throws Exception {
+    assertThat(parseTypeExpressionError("[int]")).contains("syntax error at '[': expected a type");
+  }
+
+  @Test
+  public void testFunctionCallsNotAllowedInTypeExpressions() throws Exception {
+    assertThat(parseTypeExpressionError("int[f(1)]")).contains("syntax error at '(': expected ,");
+  }
+
+  @Test
+  public void testOnlyPipeOperatorsAllowedInTypeExpressions() throws Exception {
+    ImmutableList<TokenKind> badOperators =
+        ImmutableList.of(
+            TokenKind.AMPERSAND,
+            TokenKind.EQUALS,
+            TokenKind.GREATER,
+            TokenKind.LESS,
+            TokenKind.MINUS,
+            TokenKind.PLUS,
+            TokenKind.SLASH,
+            TokenKind.STAR);
+    for (TokenKind op : badOperators) {
+      assertThat(parseTypeExpressionError("int " + op + " str"))
+          .contains("syntax error at '" + op + "'");
+    }
+  }
+
+  @Test
+  public void testTypeAliasStatement_basicFunctionality() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    TypeAliasStatement stmt = (TypeAliasStatement) parseStatement("type X = list");
+    assertThat(stmt.getIdentifier().getName()).isEqualTo("X");
+    assertThat(stmt.getParameters()).isEmpty();
+    assertThat(stmt.getDefinition()).isInstanceOf(Identifier.class);
+    assertThat(((Identifier) stmt.getDefinition()).getName()).isEqualTo("list");
+  }
+
+  @Test
+  public void testTypeAliasStatement_typeParams() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    TypeAliasStatement stmt =
+        (TypeAliasStatement) parseStatement("type my_nullable_dict[T, U] = dict[T, U] | None");
+    assertThat(stmt.getIdentifier().getName()).isEqualTo("my_nullable_dict");
+    assertThat(stmt.getParameters().stream().map(p -> p.getName()))
+        .containsExactly("T", "U")
+        .inOrder();
+    assertThat(stmt.getDefinition()).isInstanceOf(BinaryOperatorExpression.class);
+  }
+
+  @Test
+  public void testTypeAliasStatement_requiresTypeSyntax() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(false).build());
+    setFailFast(false);
+    parseStatement("type X = list");
+    assertContainsError("syntax error at 'type': type annotations are disallowed");
+  }
+
+  @Test
+  public void testTypeAliasStatement_requiresExactlyOneName() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    parseStatement("type X, Y = list, int");
+    assertContainsError("syntax error at ',': expected =");
+  }
+
+  @Test
+  public void testTypeAliasStatement_requiresDefinition() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    parseStatement("type X # = define_later");
+    assertContainsError("syntax error at 'newline': expected =");
+  }
+
+  @Test
+  public void testTypeAliasStatement_allowsParsingWithUnresolvableDefinition() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    parseStatement("type x = no_such_type");
+  }
+
+  @Test
+  public void testTypeAliasStatement_disallowsIllegalDefinition() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    parseStatement("type X = lambda x: x");
+    assertContainsError("syntax error at 'lambda': expected a type");
+  }
+
+  @Test
+  public void testTypeAliasStatement_allowsIllegalDefinition_withFlag() throws Exception {
+    setFileOptions(
+        FileOptions.builder().allowTypeSyntax(true).tolerateInvalidTypeExpressions(true).build());
+    TypeAliasStatement stmt = (TypeAliasStatement) parseStatement("type X = lambda x: x");
+    assertThat(stmt.getDefinition()).isInstanceOf(LambdaExpression.class);
+  }
+
+  @Test
+  public void testTypeIsSoftKeyword() throws Exception {
+    // Test that `type` may be used as an identifier in any context where identifiers are allowed.
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    parseStatement("type = type.type(type)");
+    parseStatement("type type = type");
+  }
+
+  private static enum TypeParamTestKind {
+    DEF_STATEMENT,
+    TYPE_ALIAS_STATEMENT
+  }
+
+  private Statement parseTypeParamTestCaseStatement(TypeParamTestKind testKind, String typeParams)
+      throws Exception {
+    return switch (testKind) {
+      // Extra space in DEF_STATEMENT case to place typeParams at offset 6 in both cases
+      case DEF_STATEMENT -> parseStatement(String.format("def  f%s(): pass", typeParams));
+      case TYPE_ALIAS_STATEMENT -> parseStatement(String.format("type X%s = int", typeParams));
+    };
+  }
+
+  @Test
+  public void testTypeParams_mayBeUnused(@TestParameter TypeParamTestKind testKind)
+      throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    parseTypeParamTestCaseStatement(testKind, "[T, U]");
+  }
+
+  @Test
+  public void testTypeParams_allowOnlyIdentifiers(@TestParameter TypeParamTestKind testKind)
+      throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    parseTypeParamTestCaseStatement(testKind, "[1]");
+    assertContainsError("syntax error at '1': expected identifier");
+    events.clear();
+    parseTypeParamTestCaseStatement(testKind, "['two']");
+    assertContainsError("syntax error at '\"two\"': expected identifier");
+    events.clear();
+    parseTypeParamTestCaseStatement(testKind, "[(THREE)]");
+    assertContainsError("syntax error at '(': expected identifier");
+  }
+
+  @Test
+  public void testTypeParams_disallowDuplicates(@TestParameter TypeParamTestKind testKind)
+      throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    parseTypeParamTestCaseStatement(testKind, "[T, U, T]");
+    assertContainsError("1:14: syntax error at 'T': duplicate type parameter");
+  }
+
+  @Test
+  public void testTypeParams_allowsTrailingCommas(@TestParameter TypeParamTestKind testKind)
+      throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    parseTypeParamTestCaseStatement(testKind, "[T, U,]");
+  }
+
+  @Test
+  public void testTypeParams_cannotBeEmptyIfPresent(@TestParameter TypeParamTestKind testKind)
+      throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    parseTypeParamTestCaseStatement(testKind, "[]");
+    assertContainsError("syntax error at ']': expected identifier");
+    events.clear();
+    parseTypeParamTestCaseStatement(testKind, "[,]");
+    assertContainsError("syntax error at ',': expected identifier");
+  }
+
+  @Test
+  public void testEllipsisNotAllowedInValueExpressions() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    assertThat(parseExpressionError("print(...)"))
+        .contains("ellipsis ('...') is not allowed outside type expressions");
+  }
+
+  @Test
+  public void testEllipsisAllowedInTypeExpressionArgumentsOnly() throws Exception {
+    setFileOptions(
+        FileOptions.builder().allowTypeSyntax(true).tolerateInvalidTypeExpressions(false).build());
+    parseStatement("x : tuple[int, ...]");
+    assertThat(parseStatementError("x : ...")).contains("syntax error at '...': expected a type");
+    assertThat(parseStatementError("x : int | ..."))
+        .contains("syntax error at '...': expected identifier");
+    assertThat(parseStatementError("x : tuple[int | ...]"))
+        .contains("syntax error at '...': expected identifier");
+  }
+
+  @Test
+  public void testCastExpression_basicFunctionality() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    CastExpression cast = (CastExpression) parseExpression("cast(list[int], foo())");
+    assertThat(cast.getType()).isInstanceOf(TypeApplication.class);
+    assertThat(cast.getValue()).isInstanceOf(CallExpression.class);
+  }
+
+  @Test
+  public void testCastExpression_isExpression() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    parseStatement("cast(list, x) += cast(list[list], (cast(struct, y)).foo())[cast(int, z)]");
+  }
+
+  @Test
+  public void testCast_isKeyword() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    assertThat(parseExpressionError("something.cast(list, x)"))
+        .contains("syntax error at 'cast': expected identifier after dot");
+    assertThat(parseExpressionError("(cast)(list, x)")).contains("expected (");
+  }
+
+  @Test
+  public void testCastExpression_requiresTypeSyntax() throws Exception {
+    // If type syntax is disabled, `cast` is treated as an ordinary identifier.
+    setFileOptions(FileOptions.builder().allowTypeSyntax(false).build());
+    assertThat(parseExpression("cast(list[str], foo())")).isInstanceOf(CallExpression.class);
+  }
+
+  @Test
+  public void testCastExpression_goodSyntax() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    parseExpression(
+        """
+        cast(
+            int,
+            n
+        )\
+        """);
+    parseExpression("cast(int, x,)");
+  }
+
+  @Test
+  public void testCastExpression_badSyntax() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    assertThat(parseExpressionError("cast int, x")).contains("syntax error at 'int': expected (");
+    assertThat(parseExpressionError("cast(int, x, y)")).contains("syntax error at 'y': expected )");
+    assertThat(parseExpressionError("cast(int, x"))
+        .contains("syntax error at 'newline': expected )");
+    assertThat(parseExpressionError("cast(*args)"))
+        .contains("syntax error at '*': expected a type");
+    assertThat(parseExpressionError("cast(type=int, value=x"))
+        .contains("syntax error at '=': expected ,");
+  }
+
+  @Test
+  public void testIsInstanceExpression_basicFunctionality() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    IsInstanceExpression cast =
+        (IsInstanceExpression) parseExpression("isinstance(foo(), list | tuple)");
+    assertThat(cast.getType()).isInstanceOf(BinaryOperatorExpression.class);
+    assertThat(cast.getValue()).isInstanceOf(CallExpression.class);
+  }
+
+  @Test
+  public void testIsInstanceExpression_isExpression() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    parseStatement("if isinstance(isinstance(y, list), bool): isinstance(z, str)");
+  }
+
+  @Test
+  public void testIsInstance_isKeyword() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    assertThat(parseExpressionError("something.isinstance(x, list)"))
+        .contains("syntax error at 'isinstance': expected identifier after dot");
+    assertThat(parseExpressionError("(isinstance)(x, list)")).contains("expected (");
+  }
+
+  @Test
+  public void testIsInstanceExpression_requiresTypeSyntax() throws Exception {
+    // If type syntax is disabled, `isinstance` is treated as an ordinary identifier.
+    setFileOptions(FileOptions.builder().allowTypeSyntax(false).build());
+    assertThat(parseExpression("isinstance(x, T)")).isInstanceOf(CallExpression.class);
+  }
+
+  @Test
+  public void testIsInstanceExpression_goodSyntax() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    parseExpression(
+        """
+        isinstance(
+            x,
+            T | U[V]
+        )\
+        """);
+    parseExpression("isinstance(x, tuple,)");
+  }
+
+  @Test
+  public void testIsInstanceExpression_badSyntax() throws Exception {
+    setFileOptions(FileOptions.builder().allowTypeSyntax(true).build());
+    setFailFast(false);
+    assertThat(parseExpressionError("isinstance x, int"))
+        .contains("syntax error at 'x': expected (");
+    assertThat(parseExpressionError("isinstance(x, y, int)"))
+        .contains("syntax error at 'int': expected )");
+    assertThat(parseExpressionError("isinstance(x, int"))
+        .contains("syntax error at 'newline': expected )");
+    assertThat(parseExpressionError("isinstance(*args)"))
+        .contains("syntax error at '*': expected expression");
+    assertThat(parseExpressionError("isinstance(value=x, type=int)"))
+        .contains("syntax error at '=': expected ,");
+  }
+
+  @Test
   public void testLambda() throws Exception {
     parseExpression("lambda a, b=1, *args, **kwargs: a+b");
     parseExpression("lambda *, a, *b: 0");
diff --git a/src/test/java/net/starlark/java/syntax/ResolverTest.java b/src/test/java/net/starlark/java/syntax/ResolverTest.java
index 847340c..378b9b0 100644
--- a/src/test/java/net/starlark/java/syntax/ResolverTest.java
+++ b/src/test/java/net/starlark/java/syntax/ResolverTest.java
@@ -40,10 +40,7 @@
 
   // Assertions that parsing and resolution succeeds.
   private void assertValid(String... lines) throws SyntaxError.Exception {
-    StarlarkFile file = resolveFile(lines);
-    if (!file.ok()) {
-      throw new SyntaxError.Exception(file.errors());
-    }
+    getValidFile(lines);
   }
 
   // Asserts that parsing of the program succeeds but resolution fails
@@ -53,6 +50,14 @@
     assertContainsError(errors, expectedError);
   }
 
+  private StarlarkFile getValidFile(String... lines) throws SyntaxError.Exception {
+    StarlarkFile file = resolveFile(lines);
+    if (!file.ok()) {
+      throw new SyntaxError.Exception(file.errors());
+    }
+    return file;
+  }
+
   // Returns the non-empty list of resolution errors of the program.
   private List<SyntaxError> getResolutionErrors(String... lines) throws SyntaxError.Exception {
     StarlarkFile file = resolveFile(lines);
@@ -456,6 +461,8 @@
     assertThat(errors.get(0).message()).isEqualTo("name 'undef' is not defined");
   }
 
+  // TODO: #27370 - Add resolver behavior for type expressions, add bindingScopeAndIndex tests here.
+
   @Test
   public void testBindingScopeAndIndex() throws Exception {
     checkBindings(
@@ -505,6 +512,236 @@
         "      aᶠ₀, bᴳ₀, cᶠ₁, dᶠ₂, eᴸ₀, fᴳ₁, gᶠ₃, hᶠ₄");
   }
 
+  @Test
+  public void testBindingScopeAndIndex_varStatement() throws Exception {
+    options.allowTypeSyntax(true);
+    checkBindings(
+        // Var statement creates a binding, even in the absence of assignment.
+        "xᴳ₀ : T",
+        // Var statement can shadow predeclared.
+        "preᴳ₁ : T",
+        "def fᴳ₂():",
+        "  xᴳ₀",
+        "  preᴳ₁");
+  }
+
+  @Test
+  public void testTypeAliasStatement_mustBeAtTopLevel() throws Exception {
+    options.allowTypeSyntax(true);
+    assertInvalid(
+        ":2:3: type alias statement not at top level",
+        """
+        def f():
+          type X = int
+        """);
+  }
+
+  @Test
+  public void testMultipleTypeAnnotationsDisallowed_topLevel() throws Exception {
+    options.allowTypeSyntax(true);
+    List<SyntaxError> errors =
+        getResolutionErrors(
+            // All four permutations of VarStatement vs annotated assignment statement.
+            """
+            a : int
+            a : str
+
+            b : int = 123
+            b : str
+
+            c : int
+            c : str = "abc"
+
+            d : int = 123
+            d : str = "abc"
+            """);
+    assertContainsError(errors, ":2:1: 'a' redeclared at top level");
+    assertContainsError(errors, ":5:1: 'b' redeclared at top level");
+    assertContainsError(errors, ":8:1: 'c' redeclared at top level");
+    assertContainsError(errors, ":11:1: 'd' redeclared at top level");
+  }
+
+  @Test
+  public void testMultipleTypeAnnotationsDisallowed_localLevel() throws Exception {
+    // Same as testMultipleTypeAnnotationsDisallowed_topLevel but inside a function, where
+    // reassignment is always allowed.
+    options.allowTypeSyntax(true);
+    List<SyntaxError> errors =
+        getResolutionErrors(
+            // All four permutations of VarStatement vs annotated assignment statement.
+            """
+            def f():
+                a : int
+                a : str
+
+                b : int = 123
+                b : str
+
+                c : int
+                c : str = "abc"
+
+                d : int = 123
+                d : str = "abc"
+            """);
+    assertContainsError(errors, "type annotation on 'a' may only appear at its first declaration");
+    assertContainsError(errors, "type annotation on 'b' may only appear at its first declaration");
+    assertContainsError(errors, "type annotation on 'c' may only appear at its first declaration");
+    assertContainsError(errors, "type annotation on 'd' may only appear at its first declaration");
+  }
+
+  @Test
+  public void testMultipleTypeAnnotationsDisallowed_defStatement() throws Exception {
+    options.allowTypeSyntax(true);
+
+    assertValid(
+        """
+        def f():
+            # Redefinition is allowed (but bad style) if second definition has no type
+            # annotation.
+            def a(x : int):
+                pass
+            def a(x):
+                pass
+        """);
+
+    List<SyntaxError> errors =
+        getResolutionErrors(
+            """
+            def f():
+                # Second definition may not have a type annotation, even if first definition has
+                # none.
+                def b(x):
+                    pass
+                def b(x : int):
+                    pass
+
+                # Return type annotation counts too.
+                def c(x):
+                    pass
+                def c(x) -> int:
+                    pass
+
+                # Even generic type vars count.
+                def d(x):
+                    pass
+                def d[T](x):
+                    pass
+            """);
+    // TODO: #27371 - For the case of redefining a function, the error message is a little
+    // confusing. But this is also a pretty rare case.
+    assertContainsError(errors, "type annotation on 'b' may only appear at its first declaration");
+    assertContainsError(errors, "type annotation on 'c' may only appear at its first declaration");
+    assertContainsError(errors, "type annotation on 'd' may only appear at its first declaration");
+  }
+
+  @Test
+  public void testSingleAnnotationWithReassignmentIsAllowed() throws Exception {
+    options.allowTypeSyntax(true);
+    assertValid(
+        """
+        def f():
+            a : int
+            a = 123
+        """);
+  }
+
+  @Test
+  public void testAnnotationFollowedByAssignmentStillCountsAsRedeclaration() throws Exception {
+    options.allowTypeSyntax(true);
+    assertInvalid(
+        "'a' redeclared at top level",
+        """
+        a : int
+        a = 123
+        """);
+  }
+
+  @Test
+  public void testVarStatementMustPreceedAssignment() throws Exception {
+    options.allowTypeSyntax(true);
+    assertInvalid(
+        "type annotation on 'x' may only appear at its first declaration",
+        """
+        def f():
+            x = 123
+            x : int
+        """);
+  }
+
+  @Test
+  public void onlyFirstAssignmentMayBeAnnotated() throws Exception {
+    options.allowTypeSyntax(true);
+    assertInvalid(
+        "type annotation on 'x' may only appear at its first declaration",
+        """
+        def f():
+            x = 123
+            x : int = 123
+        """);
+  }
+
+  @Test
+  public void cannotAnnotateParamInBody() throws Exception {
+    options.allowTypeSyntax(true);
+    assertInvalid(
+        "type annotation on 'x' may only appear at its first declaration",
+        """
+        def f(x):
+            # Invalid even though x has no type annotation above.
+            x : int
+        """);
+  }
+
+  @Test
+  public void testCastExpression_cannotBeLhsOfAssignment() throws Exception {
+    options.allowTypeSyntax(true);
+    StarlarkFile file =
+        resolveFile(
+            """
+            cast(int, x) = 42
+            cast(int, y[0]) = 42
+            cast(list[int], z) += [42]
+            """);
+    assertThat(file.ok()).isFalse();
+    assertContainsError(file.errors(), "cannot assign to 'cast(int, x)'");
+    assertContainsError(file.errors(), "cannot assign to 'cast(int, y[0])'");
+    assertContainsError(file.errors(), "cannot assign to 'cast(list[int], z)'");
+  }
+
+  @Test
+  public void testCastExpression_value_isResolved() throws Exception {
+    options.allowTypeSyntax(true);
+    StarlarkFile badFile = resolveFile("cast(int, f())");
+    assertThat(badFile.ok()).isFalse();
+    assertContainsError(badFile.errors(), "name 'f' is not defined");
+
+    StarlarkFile goodFile =
+        resolveFile(
+            """
+            def f():
+              return 1
+            cast(int, f())
+            """);
+    assertThat(goodFile.ok()).isTrue();
+  }
+
+  @Test
+  public void testCastExpression_type_notResolved() throws Exception {
+    // TODO(brandjon): resolve the cast's type once we have type checking.
+    options.allowTypeSyntax(true);
+    StarlarkFile badFile = resolveFile("cast(NoSuchType[int], 42)");
+    assertThat(badFile.ok()).isTrue();
+  }
+
+  // TODO(b/350661266): resolve types in isinstance().
+  @Test
+  public void testIsInstanceExpression_notYetSupported() throws Exception {
+    options.allowTypeSyntax(true);
+    StarlarkFile badFile = resolveFile("isinstance(x, list)");
+    assertThat(badFile.ok()).isFalse();
+    assertContainsError(badFile.errors(), "isinstance() is not yet supported");
+  }
+
   // checkBindings verifies the binding (scope and index) of each identifier.
   // Every variable must be followed by a superscript letter (its scope)
   // and a subscript numeral (its index). They are replaced by spaces, the
@@ -527,6 +764,13 @@
                 + "₀₁₂₃₄₅₆₇₈₉".charAt(id.getBinding().getIndex()) // 10 is plenty
                 + out[0].substring(id.getEndOffset() + 2);
       }
+
+      @Override
+      public void visit(VarStatement varStatement) {
+        visit(varStatement.getIdentifier());
+        // Don't visit type expression, it isn't processed.
+        // TODO: #27370 - Include the type expression in these tests.
+      }
     }.visit(file);
     assertThat(out[0]).isEqualTo(src);
   }