Lombok compatibility layer
The compiler's built-in Lombok compatibility layer: annotations are expanded into ordinary Teyru members during semantic analysis, with no annotation processor required.
Teyru's compiler has a built-in Lombok compatibility layer: annotations are parsed into a syntax tree with arguments, then expanded into ordinary Teyru members during semantic analysis, after which they take exactly the same type-checking and code-generation path as hand-written code. No annotation processor is required, no javac, and there is no AST injection.
import lombok.*
@Data
@AllArgsConstructor
@Builder
class Person {
private String name
private int age
}
Person p = Person.builder().name("ada").age(36).build()
System.out.println(p.getName()) // ada
System.out.println(p) // Person(name=ada, age=36)How to use it: import lombok.X (or write @lombok.X directly). The import is only
for readability; the compiler matches on the annotation's simple name, so @Data,
@lombok.Data and @lombok.experimental.UtilityClass are all recognised.
1. Support status overview
| Annotation | Status | Notes |
|---|---|---|
@Getter | ⚠️ Partial | Includes AccessLevel (positional-only form), @Accessors affects the naming; lazy = true computes the value once on the first read and caches it (primitive types included), but without Lombok's thread safety |
@Setter | ✅ Complete | Includes AccessLevel (positional-only form), @Accessors(chain), checks for @NonNull fields; not generated when the name is already taken (same as Lombok) |
@ToString | ⚠️ Partial | of / exclude / callSuper / includeFieldNames / onlyExplicitlyIncluded (together with @ToString.Include / @ToString.Exclude on fields); the callSuper format differs from Lombok (see §2) |
@EqualsAndHashCode | ⚠️ Partial | of / exclude / callSuper / onlyExplicitlyIncluded (@EqualsAndHashCode.Include / @EqualsAndHashCode.Exclude); the constants used by hashCode and canEqual differ from Lombok (see §2) |
@NoArgsConstructor | ⚠️ Partial | staticName generates a static factory; access is positional-only, and when the class has no hand-written constructor the one it generates is blocked by the implicit no-args constructor, so it has no effect (see §3) |
@RequiredArgsConstructor | ✅ Complete | final (without an initial value) and @NonNull fields |
@AllArgsConstructor | ✅ Complete | skips final fields that already have an initial value |
@Data | ⚠️ Partial | getter + setter + @RequiredArgsConstructor + @ToString + @EqualsAndHashCode; the implicit constructor takes @NonNull fields and inserts checks in it |
@Value | ⚠️ Partial | private final fields, getter, all-args constructor, staticConstructor; inheritance is rejected by TY-TYP-0007 |
@Builder | ⚠️ Partial | classes, constructors and methods; builderMethodName / buildMethodName / builderClassName / toBuilder / @Builder.Default / @Builder.ObtainVia / setterPrefix (the first letter is upper-cased: with plus name is withName) |
@NonNull | ⚠️ Partial | Both fields and parameters are checked: a field taken into a generated constructor gets a check inserted, the setter generated by @Setter checks as well, and the parameters of hand-written methods and constructors (marking the parameter alone is enough) are checked too. A direct field assignment is not checked (Lombok is the same); where @Builder inserts the check differs from Lombok (see §3) |
@With | ⚠️ Partial | @With on a field generates withX(T), copying with the all-args constructor; written on a class it does not generate it for every field (Lombok does) |
@Accessors | ⚠️ Partial | chain / fluent / prefix; fluent = true does not also make setters chainable the way Lombok does (you have to write chain = true separately, see §3) |
@FieldDefaults | ✅ Complete | level / makeFinal |
@UtilityClass | ⚠️ Partial | private constructor, static members; inheritance is rejected by TY-TYP-0007 |
@StandardException | ⚠️ Partial | generates the 4 standard exception constructors; E(Throwable) uses cause.getMessage() as the message. Difference: the all-args constructor is super(message, cause), Lombok is super(message) plus initCause(cause) |
@Cleanup | ✅ Complete | expands to try-with-resources, and every exit path closes |
@SneakyThrows | ✅ Complete | expands to try/catch(Throwable) and rethrows |
@Synchronized | ✅ Complete | the method body is wrapped in synchronized; static methods use a generated __lock$<class name> field |
@Log family | ✅ Complete | @Log / @Slf4j / @Log4j / @Log4j2 / @CommonsLog / @JBossLog / @Flogger / @XSlf4j all generate private static final Logger log (see §5) |
@ExtensionMethod | ✅ Complete | when the method cannot be found it is rewritten as Ext.method(receiver, ...) |
@FieldNameConstants | ⚠️ Partial | generates a nested Fields class; prefix is added to the constant's value, not to its name, the opposite of Lombok (before 1.18.4) |
@Delegate | ✅ Complete | generates delegating methods for the public methods of the field's type |
@Helper | ✅ Complete | local classes inside a method: an instance is generated, and after the declaration any unqualified call of the same name goes through it (the instance must have a no-args constructor) |
@Tolerate | ✅ Complete | the annotated member "does not exist" for the generator: @Setter private Instant date plus @Tolerate public void setDate(String) yields two overloads |
@Locked | ✅ Complete | wraps the method body with a named lock field |
@NonFinal | ⚠️ Partial | accepts the annotation, but has no effect at all: Lombok uses it to let @FieldDefaults(makeFinal = true) / @Value leave one field alone, and that is not read here; when it is written on a class, the inheritance check has already run at an earlier stage |
@PackagePrivate | ⚠️ Partial | only has an effect when written on a class, removing the access modifier from that class's fields and methods; written on a field or a method (Lombok's use: letting @FieldDefaults(level = …) / @Value leave one field alone) it has no effect |
@Var | ✅ Complete | a deprecated Lombok alias, nothing needs to be generated |
@SuperBuilder | ✅ Complete | all fields along the constructor chain are in one builder; see §4 |
@Singular | ✅ Complete | add one by one, add all at once, clear, build() gets a copy; @Singular("name") can rename; see §4 |
@Jacksonized | ❌ Not applicable | there is no Jackson, the annotation is accepted but generates nothing |
@Builder.ObtainVia | ✅ Complete | field / method / isStatic, read by toBuilder — the same as Lombok, while build() reads the builder's own field |
@onMethod_ / @onParam_ / @onConstructor_ | ✅ Complete | the annotation is copied onto the generated getter / setter parameter / constructor (see §3.5) |
@CustomLog | ✅ Complete | reads lombok.log.custom.declaration from lombok.config (see §3.5) |
Definition of "Complete": tests/programs/t16–t19 and t54 have corresponding tests,
and go test ./... verifies the output; t55_lombok_every.teyru uses each ✅ annotation in
the table above once inside a single program (only @CustomLog and the @onX family are
not in it, see the next sentence), with the output compared line by line;
t91_lombok_log.teyru covers @Log, @CustomLog (including lombok.config) and the
@onX family; t144_lombok_parity.teyru covers every @NonNull path, @Tolerate,
onlyExplicitlyIncluded, setterPrefix, @Builder on methods, @Builder.ObtainVia,
@Helper, @Getter(lazy = true) and @StandardException.
The rows marked ⚠️ are the ones that "accept the annotation, but whose behaviour differs
from Lombok".
This table was marked up row by row, by writing programs and checking them against
Lombok's documentation and source code: @Builder.ObtainVia used to be read inside
build() (Lombok only reads it in toBuilder), @Helper and @Tolerate used to merely
accept the annotation, the initial value of @Getter(lazy = true) was computed twice and
primitive types would not compile, the constructor of @Data did not take @NonNull
fields, the setter generated by @Setter did not insert a check, the final of
@Value / @UtilityClass could not stop inheritance, and E(Throwable) of
@StandardException carried no message — all of these have been fixed to match Lombok's
behaviour, and each one has its own test.
2. What the generated members look like
Taking @Data class Person { private String name; private int age } as an example,
the expansion is equivalent to:
class Person {
private String name
private int age
public Person() {
}
public String getName() {
return this.name
}
public int getAge() {
return this.age
}
public void setName(String value) {
this.name = value
}
public void setAge(int value) {
this.age = value
}
public String toString() {
return "Person(name=" + this.name + ", age=" + this.age + ")"
}
public boolean equals(Object o) {
if (this == o) {
return true
}
if (o == null || !(o instanceof Person)) {
return false
}
Person other = (Person) o
if (this.name == null) {
if (other.name != null) {
return false
}
} else if (!this.name.equals(other.name)) {
return false
}
if (this.age != other.age) {
return false
}
return true
}
public int hashCode() {
int result = 1
result = 31 * result + (this.name == null ? 0 : this.name.hashCode())
result = 31 * result + this.age
return result
}
}Notes on the differences:
@Getter/@Setteronly cover all fields when combined with@Data,@Valueor a class-level annotation; written on a single field they affect only that field.- The constructor generated by
@Datais@RequiredArgsConstructor(final fields without an initial value, plus fields marked@NonNull, the same as Lombok). If the class has no such fields, it is a no-args constructor; for an all-args constructor add@AllArgsConstructoras well. @Builderdoes not generate getters, the same as Lombok.@Getter(lazy = true)moves the field's initial value into the getter: the constructor no longer computes it, and it is computed once on the first read and cached after that (the same as Lombok). The held value is boxed, so primitive types work too. The difference is that the generated getter does not lock (Lombok's does); it keeps only a holder field.- The
hashCodeof@EqualsAndHashCodeuses 31 and 0 (Lombok uses 59 and 43), the field order follows declaration order (Lombok sorts), and nocanEqualis generated — so a parent class and a child class are equal as long as their fields are the same, whereas Lombok would call them unequal. - The string generated by
@ToString(callSuper = true)isChild(c=2; super=Base(b=1)), whereas Lombok isChild(super=Base(b=1), c=2): its own fields are written first, and the super part is last. E(Throwable)of@StandardExceptionissuper(cause == null ? null : cause.getMessage(), cause), sonew E(new RuntimeException("c")).getMessage()isc(the same as Lombok). The all-args constructor goes throughsuper(message, cause); Lombok doessuper(message)followed byinitCause(cause), and the difference is only the detail of "stating explicitly that there is no cause first, while still being able to call initCause afterwards".
3. Differences from Lombok (important)
- There is no annotation processor. The expansion happens inside the compiler, and
javactakes no part in it at all. - Where
@NonNullchecks. When a field is marked@NonNulland is taken into a generated constructor, a check is inserted, and the setter generated by@Setterplus the parameters of hand-written methods and constructors (marking the parameter alone is enough) all get checks inserted too; a direct field assignment is not checked — Lombok's documentation only promises a check for the generated methods that assign a value to the field, so the two agree there. The difference is in@Builder: Lombok checks in the builder's setter, sobuilder().name(null)throws right there; here the builder setter does not check, and the check lands in the constructor thatbuild()calls. @Singularpasses a mutable copy, not aCollections.unmodifiableListwrapper (see §4).@SuperBuildergenerates a single flattened builder, not a builder inheritance chain (see §4).@onXannotations are only copied, never executed. The annotation is literally attached to the generated member, but Teyru has no runtime forjava.lang.annotation, so markers such as@Deprecatedhave no effect at all; frameworks that need to read annotations by reflection do not apply here.- Only one key of
lombok.configis read.lombok.log.custom.declaration(used by@CustomLog) is read; the remaining keys andconfig.stopBubblingare not read, and the search always goes up to the filesystem root. - The fields of
@Valueare always private final; if a field already has an initial value, the constructor no longer takes it. Thefinalis applied by the annotation only after inheritance has been checked, soclass Ext extends Vis rejected by a follow-up check (TY-TYP-0007, whose message is synonymous with Lombok'scannot inherit from final V). Thefinalof@UtilityClasstakes the same path. - The
setterPrefixof@Builderupper-cases the first letter of the name:setterPrefix = "with"plus the fieldnamegenerateswithName(the same as Lombok); without a prefix the name is the field name itself.@Builderattached to a method treats the target method's parameters as fields, andbuild()calls that method (for a static one<class>.<method>(...), for an instance method a new instance);@Builder.ObtainViais read bytoBuilder, and both themethodandisStaticforms are supported. @Helperonly recognises local classes inside a method. There an instance is generated, and after the declaration any unqualified call of the same name goes through it; written on a member class it has no effect, it merely marks the class static (Lombok reports an error outright:@Helper is legal only on method-local classes).@Tolerate, on the other hand, makes the generator "unable to see" the annotated member: with@Setter private Instant dateplus@Tolerate public void setDate(String), both overloads are present, the same as Lombok.@Accessors(fluent = true)does not turn on chaining as well. Lombok'sfluentalso changes the setter's return value to itself, sonew F().n(5).n()holds in Lombok; here the setter is stillvoid, and to get chaining you have to addchain = trueyourself.accesson constructors is positional-only.@AllArgsConstructor(AccessLevel.PRIVATE)works, whereas@AllArgsConstructor(access = AccessLevel.PRIVATE)(Lombok's idiomatic form) is ignored and produces apublicconstructor.@NoArgsConstructorgoes further: when the class has no hand-written constructor, the implicit public no-args constructor already occupies the slot, so the generated one is skipped per the rules in §6, which meansaccesshas no effect at all — to make it take effect you first have to write another constructor yourself.
3.5 The @onX family and @CustomLog
@onMethod_ / @onParam_ / @onConstructor_
These options copy one annotation onto the member generated by another annotation:
class Annotated {
@Getter(onMethod_ = @Deprecated) String name
@Getter @Setter(onParam_ = @Deprecated) int age
}
@AllArgsConstructor(onConstructor_ = @Deprecated)
class Made {
String a
int b
}onMethod_ is attached to the getter, onParam_ to the setter's parameter, and
onConstructor_ to the generated constructor. Both spellings are read: Lombok's argument
form (including the @__(...) wrapper from the javac7 era) and the bare
@onMethod_Deprecated form written right next to it.
Only a single annotation can be read back: the array form onMethod_ = {@A, @B} is
silently ignored, because the parser does not preserve annotations inside array arguments.
Annotations are only copied, never executed — Teyru has no runtime for
java.lang.annotation, so the marker itself has no effect; it is there to be read by later
compiler phases.
@CustomLog
Lombok uses lombok.config to configure this one annotation only. Teyru reads
lombok.log.custom.declaration, with the same format as Lombok:
lombok.log.custom.declaration = MyLog MyLog.of(NAME)The first word is the logger type, and what follows is the pattern that creates it; NAME
is substituted with the name of the class carrying the annotation, and in Lombok TYPE is
the class object. Teyru does not support TYPE: the argument in the pattern is passed
to a static factory, and all Teyru's X.class (see docs/language.md) can express is a
name, so passing it anyway would produce something that does not match the factory's
parameter; therefore TY-INT-0006 is reported and use of NAME is required.
The search rules are the same as Lombok: walk up from the directory containing the source
file to the nearest lombok.config, and for each key the nearest one wins. The difference
is that config.stopBubbling is not read, so the search always goes up to the root.
If the declared type cannot be found, TY-INT-0006 is reported; when the log symbol
cannot be found it is the ordinary TY-TYP-0048.
4. @Singular and @SuperBuilder
@Singular
@Builder
class Order {
@Singular private List<String> items
@Singular private Map<String, Integer> counts
@Singular("tag") private List<String> tags
}Generated (taking items as the example):
| Member | Behaviour |
|---|---|
addItems(E value) | creates an ArrayList on the first call, then adds items one by one |
addItemsAll(List<E> values) | adds the whole batch |
clearItems() | clears it (the next add creates it again) |
build() | passes a copy, and it is never null |
The add methods for a Map field use the field name itself: counts(K key, V value),
countsAll(Map<K,V>), clearCounts(). @Singular("tag") renames the add method to
tag(E).
The naming differs from Lombok: for a List field items, Lombok generates the
singularised item(E) and items(Collection), and for a Map field counts it generates
count(K,V) and counts(Map). Teyru always uses add<field name> / add<field name>All, and the
two-argument version for a Map is simply named after the field.
The difference from Lombok: Lombok generates a java.util.Collections.unmodifiableList
wrapper, Teyru does not have that API, so what is passed is a mutable copy — the object
and the builder do not share the same collection, but whoever receives it can still modify
it.
@SuperBuilder
@SuperBuilder
class Animal {
private String name
private int legs
}
@SuperBuilder
class Dog extends Animal {
private String breed
}
Dog d = Dog.builder().name("rex").legs(4).breed("lab").build()Lombok makes chained calls flow across levels by "a builder inheriting from a builder, and
returning itself through the self-referential type parameter B extends Builder<B>". Teyru
instead uses a single flattened builder: the subclass's builder covers the fields of the
whole inheritance chain, build() passes them to the subclass constructor in one go, and the
constructor then passes the parent's share upwards. The chained syntax is exactly the same,
and no generics are needed.
The cost: Animal.builder() and Dog.builder() are two independent classes, and Dog's
builder is not a subclass of Animal's builder. Code that passes a builder as an argument
between points in the inheritance chain compiles in Lombok, but not here — this style is
rare.
5. Logging annotations
Teyru does not have external packages such as SLF4J or Log4j; the standard library provides
a simple Logger:
class Logger {
public Logger(String name)
public void trace(String msg)
public void debug(String msg)
public void info(String msg)
public void warn(String msg)
public void error(String msg)
}The field generated by annotations such as @Slf4j is
private static final Logger log = new Logger("<class name>"), the output format is
LEVEL <class name> - <message>, and it is written to standard output. To connect a real logging
system, replace the log field with the corresponding implementation yourself.
6. Expansion order
- Class-level structural annotations (
@Value,@FieldDefaults,@UtilityClass,@Data) adjust the modifiers first. - Member-level annotations (
@Getter,@Setter,@NonNull,@With,@Delegate…) are processed field by field. - Class-level generators (
@ToString,@EqualsAndHashCode, constructors,@Builder) run last. - The generated members are added before vtable allocation, so they take part in overriding and polymorphism just like hand-written members.
If the same signature already exists (hand-written or generated), constructors are
skipped, while methods are a TY-TYP-0011 duplicate-definition compile error; members
marked @Tolerate are the exception — the generator treats them as non-existent, so it
generates its own copy anyway (Lombok's behaviour: two overloads, or a genuine
duplicate-definition error). The accessors generated by @Setter / @Getter additionally
check first whether the name is already taken, and if it is they are not generated, which is
also Lombok's rule.