Teyru

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

AnnotationStatusNotes
@Getter⚠️ PartialIncludes 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✅ CompleteIncludes AccessLevel (positional-only form), @Accessors(chain), checks for @NonNull fields; not generated when the name is already taken (same as Lombok)
@ToString⚠️ Partialof / exclude / callSuper / includeFieldNames / onlyExplicitlyIncluded (together with @ToString.Include / @ToString.Exclude on fields); the callSuper format differs from Lombok (see §2)
@EqualsAndHashCode⚠️ Partialof / exclude / callSuper / onlyExplicitlyIncluded (@EqualsAndHashCode.Include / @EqualsAndHashCode.Exclude); the constants used by hashCode and canEqual differ from Lombok (see §2)
@NoArgsConstructor⚠️ PartialstaticName 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✅ Completefinal (without an initial value) and @NonNull fields
@AllArgsConstructor✅ Completeskips final fields that already have an initial value
@Data⚠️ Partialgetter + setter + @RequiredArgsConstructor + @ToString + @EqualsAndHashCode; the implicit constructor takes @NonNull fields and inserts checks in it
@Value⚠️ Partialprivate final fields, getter, all-args constructor, staticConstructor; inheritance is rejected by TY-TYP-0007
@Builder⚠️ Partialclasses, constructors and methods; builderMethodName / buildMethodName / builderClassName / toBuilder / @Builder.Default / @Builder.ObtainVia / setterPrefix (the first letter is upper-cased: with plus name is withName)
@NonNull⚠️ PartialBoth 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⚠️ Partialchain / fluent / prefix; fluent = true does not also make setters chainable the way Lombok does (you have to write chain = true separately, see §3)
@FieldDefaults✅ Completelevel / makeFinal
@UtilityClass⚠️ Partialprivate constructor, static members; inheritance is rejected by TY-TYP-0007
@StandardException⚠️ Partialgenerates 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✅ Completeexpands to try-with-resources, and every exit path closes
@SneakyThrows✅ Completeexpands to try/catch(Throwable) and rethrows
@Synchronized✅ Completethe 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✅ Completewhen the method cannot be found it is rewritten as Ext.method(receiver, ...)
@FieldNameConstants⚠️ Partialgenerates 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✅ Completegenerates delegating methods for the public methods of the field's type
@Helper✅ Completelocal 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✅ Completethe annotated member "does not exist" for the generator: @Setter private Instant date plus @Tolerate public void setDate(String) yields two overloads
@Locked✅ Completewraps the method body with a named lock field
@NonFinal⚠️ Partialaccepts 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⚠️ Partialonly 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✅ Completea deprecated Lombok alias, nothing needs to be generated
@SuperBuilder✅ Completeall fields along the constructor chain are in one builder; see §4
@Singular✅ Completeadd one by one, add all at once, clear, build() gets a copy; @Singular("name") can rename; see §4
@Jacksonized❌ Not applicablethere is no Jackson, the annotation is accepted but generates nothing
@Builder.ObtainVia✅ Completefield / method / isStatic, read by toBuilder — the same as Lombok, while build() reads the builder's own field
@onMethod_ / @onParam_ / @onConstructor_✅ Completethe annotation is copied onto the generated getter / setter parameter / constructor (see §3.5)
@CustomLog✅ Completereads lombok.log.custom.declaration from lombok.config (see §3.5)

Definition of "Complete": tests/programs/t16t19 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 / @Setter only cover all fields when combined with @Data, @Value or a class-level annotation; written on a single field they affect only that field.
  • The constructor generated by @Data is @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 @AllArgsConstructor as well.
  • @Builder does 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 hashCode of @EqualsAndHashCode uses 31 and 0 (Lombok uses 59 and 43), the field order follows declaration order (Lombok sorts), and no canEqual is 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) is Child(c=2; super=Base(b=1)), whereas Lombok is Child(super=Base(b=1), c=2): its own fields are written first, and the super part is last.
  • E(Throwable) of @StandardException is super(cause == null ? null : cause.getMessage(), cause), so new E(new RuntimeException("c")).getMessage() is c (the same as Lombok). The all-args constructor goes through super(message, cause); Lombok does super(message) followed by initCause(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)

  1. There is no annotation processor. The expansion happens inside the compiler, and javac takes no part in it at all.
  2. Where @NonNull checks. When a field is marked @NonNull and is taken into a generated constructor, a check is inserted, and the setter generated by @Setter plus 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, so builder().name(null) throws right there; here the builder setter does not check, and the check lands in the constructor that build() calls.
  3. @Singular passes a mutable copy, not a Collections.unmodifiableList wrapper (see §4).
  4. @SuperBuilder generates a single flattened builder, not a builder inheritance chain (see §4).
  5. @onX annotations are only copied, never executed. The annotation is literally attached to the generated member, but Teyru has no runtime for java.lang.annotation, so markers such as @Deprecated have no effect at all; frameworks that need to read annotations by reflection do not apply here.
  6. Only one key of lombok.config is read. lombok.log.custom.declaration (used by @CustomLog) is read; the remaining keys and config.stopBubbling are not read, and the search always goes up to the filesystem root.
  7. The fields of @Value are always private final; if a field already has an initial value, the constructor no longer takes it. The final is applied by the annotation only after inheritance has been checked, so class Ext extends V is rejected by a follow-up check (TY-TYP-0007, whose message is synonymous with Lombok's cannot inherit from final V). The final of @UtilityClass takes the same path.
  8. The setterPrefix of @Builder upper-cases the first letter of the name: setterPrefix = "with" plus the field name generates withName (the same as Lombok); without a prefix the name is the field name itself. @Builder attached to a method treats the target method's parameters as fields, and build() calls that method (for a static one <class>.<method>(...), for an instance method a new instance); @Builder.ObtainVia is read by toBuilder, and both the method and isStatic forms are supported.
  9. @Helper only 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 date plus @Tolerate public void setDate(String), both overloads are present, the same as Lombok.
  10. @Accessors(fluent = true) does not turn on chaining as well. Lombok's fluent also changes the setter's return value to itself, so new F().n(5).n() holds in Lombok; here the setter is still void, and to get chaining you have to add chain = true yourself.
  11. access on constructors is positional-only. @AllArgsConstructor(AccessLevel.PRIVATE) works, whereas @AllArgsConstructor(access = AccessLevel.PRIVATE) (Lombok's idiomatic form) is ignored and produces a public constructor. @NoArgsConstructor goes 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 means access has 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):

MemberBehaviour
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

  1. Class-level structural annotations (@Value, @FieldDefaults, @UtilityClass, @Data) adjust the modifiers first.
  2. Member-level annotations (@Getter, @Setter, @NonNull, @With, @Delegate …) are processed field by field.
  3. Class-level generators (@ToString, @EqualsAndHashCode, constructors, @Builder) run last.
  4. 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.

On this page