Type attribution for recipe authors
Type attribution is the process of associating semantic type information with syntactic elements in the Lossless Semantic Tree (LST). This metadata enables OpenRewrite recipes to make informed decisions when transforming code, such as determining assignment compatibility or differentiating between syntactically similar but semantically distinct code.
For example, type attribution lets us differentiate between an SLF4J log.info() and a LOG4J log.info() – which is extremely difficult to do if you are working with raw text or an unattributed syntax tree.
In OpenRewrite's LST, recipes interact with type attribution primarily through:
- LST Elements with Type Information - Each tree node implements
TypedTreeand carries aJavaTypereference - JavaType - An interface whose implementations collectively model Java's type system
- Utilities like MethodMatcher and TypeUtils - Which are classes that provide convenient access to common type-related operations.
While these classes originate in the Java type system, OpenRewrite re-uses these classes for multiple languages. That means these types appear in LSTs representing languages like Kotlin and TypeScript as well.
The JavaType hierarchy
JavaType is the root interface implemented by all type information in OpenRewrite. The interface itself is mostly empty – with the important data and behavior belonging to its subclasses.
JavaType.FullyQualified
Many JavaType implementations share a common interface: JavaType.FullyQualified. This interface is implemented by Annotation, Class, Parameterized, ShallowClass, and Unknown.
All implementations of this interface provide a fully qualified name of the class (e.g., "java.util.List"). Also, except for ShallowClass and Unknown, the other implementations further provide access to the type's supertype, interfaces, methods, fields, and other metadata.
When working with types it is common to check if a type is a FullyQualified instance before accessing its fully qualified name or other structural information.
JavaType implementations
-
JavaType.Class- Represents a fully-qualified class or interface with complete metadata:- Fully qualified name (e.g.,
"java.util.List") - Supertype and interfaces
- Type parameters (e.g.,
EinList<E>) - Fields (as
JavaType.Variable) - Methods (as
JavaType.Method) - Annotations
- Flags (public, static, final, etc.)
- Fully qualified name (e.g.,
-
JavaType.Parameterized- Represents a parameterized type application:- The raw type (a
FullyQualified) - Type arguments (e.g.,
StringinList<String>) - Example:
List<String>vs. rawList
- The raw type (a
-
JavaType.ShallowClass- A lightweightClassvariant containing only the fully qualified name:- Used when a recipe needs to manually construct type information but only knows the fully qualified name
- Lacks the deep metadata (methods, fields, supertype) that a full
JavaType.Classprovides - Useful as a fallback when full type information is unavailable
- Best Practice: Prefer obtaining complete type information from
JavaTemplateorJavaParserwhen possible, but useShallowClass.build("com.example.MyClass")if a more robust approach is unavailable.
-
JavaType.GenericTypeVariable- Represents type parameters and wildcards:- Name (e.g.,
"T","E", or"?"for wildcards) - Bounds (e.g.,
StringinT extends String) - Variance (
INVARIANT,COVARIANTfor? extends,CONTRAVARIANTfor? super)
- Name (e.g.,
-
JavaType.Array- Represents array types:- Element type (which may itself be an array)
- Example:
String[],int[][]
-
JavaType.Primitive- Enum representing primitive types:- Values:
Boolean,Byte,Char,Short,Int,Long,Float,Double,Void,String,Null,None
- Values:
-
JavaType.Method- Represents a method signature:- Declaring type
- Method name
- Return type
- Parameter types and names
- Thrown exceptions
- Modifiers
- Type parameters declared by the method
-
JavaType.Variable- Represents a field or variable:- Owner (the class or method declaring the variable)
- Variable type
- Variable name
-
JavaType.Unknown- Singleton sentinel value for unresolved types -
JavaType.Intersection- Used when doing casts like (Function & Serializable). -
JavaType.MultiCatch- Represents multi-catch exception types (e.g.,IOException | SQLException)
Accessing type attribution in LST Elements
Typed LST elements implement the TypedTree interface, which allows retrieving a JavaType from the LST element with getType() and updating it with withType():
public interface TypedTree {
@Nullable JavaType getType();
<T extends Tree> T withType(@Nullable JavaType type);
}
Common typed elements
J.Identifier- Simple name referencesJ.FieldAccess- Qualified field access (e.g.,obj.field)J.MethodInvocation- Method invocationsJ.NewClass- Constructor invocationsJ.Binary- Binary operations (e.g.,a + b)J.Assignment- Assignments (e.g.,x = y)J.VariableDeclarations.NamedVariable- Variable declarations (e.g.: thesinString s)J.ClassDeclaration- Class declarations (type isJavaType.FullyQualified)
Example: Method invocation type attribution
List<String> list = new ArrayList<>();
String first = list.get(0);
In the LST for list.get(0):
J.MethodInvocation.getType()will provide the return-type ofStringJ.MethodInvocation.getMethodType()provides access to aJavaType.Methodwith:- Name:
"get" - Declaring type:
java.util.List<E> - Return type:
E(resolved toStringin this context) - Parameter types:
[int]
- Name:
- The select expression
listhas typeJavaType.Parameterizedwith:- Raw type:
java.util.List - Type parameters:
[String]
- Raw type:
TypeUtils: Working with types
The org.openrewrite.java.tree.TypeUtils provides utilities for reading, comparing, and working with types.
Type comparison
These methods provide different levels of type compatibility checking:
JavaType intType = JavaType.Primitive.Int;
JavaType longType = JavaType.Primitive.Long;
// Check if two types are exactly the same
boolean same = TypeUtils.isOfType(intType, longType);
// Returns: false (int and long are different types)
// Check if the second type is assignable to the first (considers widening and boxing conversions)
boolean assignable = TypeUtils.isAssignableTo(longType, intType);
// Returns: true (int can be assigned to long via primitive widening)
// Check if a type matches a fully qualified name or primitive keyword
boolean matches = TypeUtils.isOfClassType(intType, "int");
// Returns: true (intType matches the primitive keyword "int")
Key differences:
isOfType()requires exact type equality (same class, same type parameters)isAssignableTo()considers type compatibility including primitive widening, boxing/unboxing, and subtype relationshipsisOfClassType()matches against a string representation (FQN for classes, keyword for primitives)
Type casting helpers
Each of these methods return null if it would not be valid to interpret the argument as of that type:
JavaType.Class clazz = TypeUtils.asClass(type);
JavaType.Parameterized parameterized = TypeUtils.asParameterized(type);
JavaType.GenericTypeVariable generic = TypeUtils.asGeneric(type);
JavaType.Array array = TypeUtils.asArray(type);
JavaType.Primitive primitive = TypeUtils.asPrimitive(type);
Type validation
// Check if a type is fully resolved (no Unknown or null parts)
boolean wellFormed = TypeUtils.isWellFormedType(type);
FQN comparison
// Compare fully qualified names (handles $ vs . for inner classes)
boolean equal = TypeUtils.fullyQualifiedNamesAreEqual(
"com.example.Outer.Inner",
"com.example.Outer$Inner"
); // returns true
Practical patterns
Checking method signatures
When you need to identify specific method invocations, use MethodMatcher for a declarative approach or manually inspect the method's type information.
Using MethodMatcher (Recommended):
MethodMatcher matcher = new MethodMatcher("java.util.List get(int)");
@Override
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) {
if (matcher.matches(method)) {
// This is a call to List.get(int)
}
return method;
}
Manual type inspection:
@Override
public J.MethodInvocation visitMethodInvocation(J.MethodInvocation method, ExecutionContext ctx) {
JavaType.Method methodType = method.getMethodType();
if (methodType != null &&
"get".equals(methodType.getName()) &&
methodType.getDeclaringType().isAssignableTo("java.util.List") &&
methodType.getParameterTypes().size() == 1 &&
TypeUtils.isOfTypePrimitive(JavaType.Primitive.Int, methodType.getParameterTypes().get(0))) {
// This is a call to List.get(int)
}
return method;
}
The MethodMatcher approach is generally preferred because it:
- Handles method signature matching with a concise pattern string
- Automatically considers inheritance (matches on supertypes/interfaces)
- Supports wildcards and overloaded method matching
- Is more readable
Finding method overrides
Given a method type, searches the declaring type's parent and interfaces for a method with the same name and signature.
Returns an empty optional if the method, its declaring type, or generic signature is null.
This is useful when you need to determine whether a given method overrides a superclass method or need to inspect something about the superclass' method signature.
JavaType.Method overridden = TypeUtils.findOverriddenMethod(method)
.orElse(null);
Common problems
Import manipulation failing
New recipe authors often wonder why an import isn't being added or removed when they call JavaVisitor.maybeAddImport() or maybeRemoveImport(). These methods take type attribution into account:
maybeAddImport()won't add an import if nothing within the LST is type attributed with the type of the requested importmaybeRemoveImport()won't remove an import if anything remains with the old type
These methods have parameters which allow you to force the removal/addition of an import regardless of type attribution, but using the override is rarely necessary in a well-formed recipe.
Tests failing with "type is missing or malformed"
This error typically occurs in one of two scenarios:
-
The parser cannot fully type-attribute the "before" code
- The parser provided to the test cannot successfully parse the "before" text into a fully type-attributed LST. This happens when types referenced in the code aren't available to the parser.
-
The recipe inserts LST elements missing type attribution
- When a recipe creates new LST elements (e.g., via
JavaTemplate) without proper type information, validation will fail.
- When a recipe creates new LST elements (e.g., via
Provide type information via Classpath
In both cases, the resolution is to provide the parser a classpath with definitions for all types that appear within the code being parsed.
Recommended Approach: TypeTables with classpathFromResources()
@Test
void myRecipeTest() {
rewriteRun(
spec -> spec
.recipe(new RenameLogToLogger())
.parser(JavaParser.fromJavaVersion()
.classpathFromResources(ctx, "slf4j-api-2.1")),
java(
// Without providing slf4j-api on the parser classpath this "before" text would trigger the "type is missing or malformed" error
"""
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class MyClass {
private static final Logger log = LoggerFactory.getLogger(MyClass.class);
void doSomething() {
log.info("Hello, world!");
}
}
""",
// Without providing slf4j-api on the classpath of JavaParser/JavaTemplate inside the recipe implementation
// this "after" text can trigger the "type is missing or malformed" error
"""
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class MyClass {
private static final Logger logger = LoggerFactory.getLogger(MyClass.class);
void doSomething() {
log.info("Hello, world!");
}
}
"""
)
);
}
Without the classpathFromResources() call, the parser wouldn't be able to resolve Logger, LoggerFactory, or the method getLogger(), resulting in type attribution failures.
TypeTables are lightweight serialized type information files that provide just what's needed for type attribution without requiring full JAR files.
Alternative: Using classpath() with runtime dependencies
Types can also be provided via the classpath() method, which looks up dependencies from the runtime classpath of the recipe module:
JavaParser.fromJavaVersion().classpath("slf4j-api");
This approach has downsides that make classpathFromResources() preferred:
- Version conflicts: Only one version of a JAR can be loaded from the runtime classpath. A recipe module that migrates between multiple versions of a dependency can only have one version on its runtime classpath.
- Security scanner false positives: Taking a runtime dependency on a JAR can cause security scanners to emit false-positive warnings. A recipe module whose purpose is to fix a vulnerability may be flagged as having that vulnerability.
Opting out of type validation
It is possible to opt out of the well-formed-types safeguard in RewriteTest, though this should be done sparingly:
@Test
void myRecipeTest() {
rewriteRun(
spec -> spec
.recipe(new MyRecipe())
// Disable all type validation checks
.typeValidationOptions(TypeValidation.none()),
java("code with incomplete type information")
);
}
For more granular control, use the builder to disable specific checks:
@Test
void myRecipeTest() {
rewriteRun(
spec -> spec
.recipe(new MyRecipe())
// If type validation must be disabled, prefer granular opt-outs which preserve as much safety as feasible
.typeValidationOptions(TypeValidation.builder()
.identifiers(false)
.methodInvocations(false)
.build()),
java("code with partial type information")
);
}
You can also apply different validation to the "after" state:
@Test
void myRecipeTest() {
rewriteRun(
spec -> spec
.recipe(new MyRecipe())
// Allow the "before" state to have incomplete types
.typeValidationOptions(TypeValidation.none())
// But require the "after" state to be fully type-attributed
.afterTypeValidationOptions(TypeValidation.all()),
java("before with incomplete types", "after with complete types")
);
}
A recipe which manipulates an LST to include missing or invalid type information can produce valid diffs, but will compose poorly with other recipes that want to read and act upon the now-invalid type information.
If a recipe which produces invalid type information is used in a large composite recipe it can be very difficult to debug why expected changes were not made.