Skip to content

fix: declare each method once, with a return type its supertypes accept - #90

Open
edusperoni wants to merge 2 commits into
mainfrom
fix/merge-duplicate-methods
Open

edusperoni wants to merge 2 commits into
mainfrom
fix/merge-duplicate-methods

Conversation

@edusperoni

@edusperoni edusperoni commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #89. It fixes the TS2416 errors that remained there: an override whose return type doesn't match the one its base class declares.

The problem

A class's typings list the same Java method several times: the class's own declaration, plus the declaration from every interface the class, or a class it extends, implements. These were de-duplicated by their rendered text, so copies that disagreed on nullability or parameter names all survived:

class Dialog {
    public onCreatePanelView(featureId: number): View;        // Dialog's own method, no annotation
    public onCreatePanelView(param0: number): View | null;    // Window.Callback's, @Nullable
}
class AlertDialog extends Dialog {
    public onCreatePanelView(param0: number): View | null;    // only the interface copy
}

AlertDialog isn't assignable to Dialog, because View | null doesn't fit Dialog's View signature (TS2416). Also, every class carried a paramN copy of each interface method it implements.

The fix

  • One declaration per JVM method (name + descriptor):
    • The return and each parameter are nullable if any of the declarations says so.
    • Types come from the class's own declaration, whose type variables are the ones the class declares. Parameter names come from the first declaration that has them, since abstract methods have none.
    • Different methods that still render the same TypeScript (foo(int) and foo(long)) are merged as before, together with their API-level tags.
  • An override can't widen its supertype's return type. TypeScript only lets an override narrow its return type. So when a supertype declares a method non-null, which includes leaving it unannotated, an override annotated @Nullable is declared non-null too. An example is AppCompatEditText.getText() against EditText.getText(). Supertypes are resolved through the input and --super jars, and their declared nullability is modelled from what their own typings list:
    • the type's own methods;
    • the interfaces' methods;
    • the superclass methods a class declares again when it declares another of the same name. These copies now carry the superclass's declared nullability, not its raw annotation.
    • with --merge-class-versions, the members only other versions of the class declare.

Dropping a | null is always valid TypeScript, so every direct supertype is consulted, not only the ones named in extends/implements.

Parameters don't need the second rule: TypeScript compares method parameters in both directions.

Results

tsc 5.9, strict, skipLibCheck: false:

main this PR
android-37 platform typings 17 TS2416, 2 TS2417 0 TS2416, 2 TS2417 (unrelated: the static class field)
kotlinx-coroutines, okhttp, okio, androidx core/activity/fragment/lifecycle, material, gson 8 TS2416 0
com.mapbox.maps:android:11.15.3 + dependencies 24 TS2416 4: obfuscated Play Services classes where a field and a method share a name (zaa)
AndroidX expected output (test/expected-output) 3 type errors 1: CommandProcessor.onCancelled vs AsyncTask; the test runs without android.jar, so the generator can't see the other overload

The library rows were checked against platform typings generated by the same build. Other checks:

  • The AndroidX expected output shrinks by 894 lines (964 removed, 70 changed). A per-class comparison of the declared methods (name, parameter count, static) shows no method lost or added in any of the 1,608 classes.
  • Generating the platform jar takes 3.9 s instead of 5.1 s.
  • test/kotlin-fixture gains Overrides.java covering both patterns, plus the two cases an independent review found. main fails on it.
  • make test-typecheck now checks every error in the AndroidX output, not only syntax. The one known AsyncTask error is listed in check.mjs with the reason.

Output changes

  • Methods that an interface declares @Nullable become | null on implementing classes too: Dialog.onCreatePanelView(...) now returns View | null. That matches the interface's contract, but calling code may need a null check.
  • Where an override was annotated @Nullable but its supertype isn't, it's now declared non-null (e.g. AppCompatEditText.getText()). TypeScript has no way to express it otherwise.
  • Interface copies with paramN names disappear wherever the class declares the method itself.

Known limits (all already present on main)

  • Overrides that specialise a generic interface method (Iterator<String>.next() vs Iterator.next()) have a different JVM descriptor from the method they override, so the rule doesn't connect them. Today these don't error, because the interface's any-typed copy stays alongside as a separate overload.
  • Static methods aren't constrained. A @Nullable static that hides an unannotated one in a superclass can still produce TS2417.
  • Interface implementation objects: the implementation: {...} parameter of an interface's constructor still lists each declaration as written, so its nullability can differ from the class body's.
  • Obfuscated classes: a few have a field and a method with the same name (zaa, TS2425), or a raw generic type left behind by a stripped signature (Spliterator, TS2314).

A class lists the same JVM method several times: its own declaration, and
that of every interface it or a class it extends implements. They were
de-duplicated on their rendered text, so declarations that disagreed on
nullability or parameter names all survived. Dialog declared both
onCreatePanelView(featureId: number): View, its own unannotated method, and
onCreatePanelView(param0: number): View | null, Window.Callback's @nullable
one. AlertDialog, which only lists the interface's, was then not assignable
to Dialog (TS2416), and every class carried a paramN copy of each interface
method it implements.

Declarations are now collected per JVM method. The return and each parameter
are nullable if any declaration says so, and the declaration with parameter
names is the one written. Methods that still render the same TypeScript are
merged as before.

TypeScript also lets an override narrow its return type but not widen it, so
an override Java annotates @nullable cannot say so where a supertype declares
the method non-null, which includes an unannotated one:
AppCompatEditText.getText() against EditText.getText(). Its return is now
declared non-null in that case. Supertypes are resolved through the input and
--super jars, by the same rules as the supertype's own typings.

On the android-37 platform jar this removes all 17 TS2416 errors (2 unrelated
TS2417 remain) and 963 redundant lines from the AndroidX expected output, with
no method lost from any class. Typings for kotlinx-coroutines, okhttp, okio,
androidx and material go from 8 TS2416 errors to none, and Mapbox's from 24
to the 4 left between obfuscated Play Services classes, where a field and a
method share a name. The AndroidX output now type-checks except for one
error caused by the test running without android.jar, so the type check
covers all of its errors instead of only syntax.
@coderabbitai

coderabbitai Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9078d09b-e2e6-4f57-9e87-c02b08f4fe22

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

…nherits

When the declarations of one JVM method were merged, the one with parameter
names replaced the rest outright, so a class whose own method has no
LocalVariableTable (an abstract method, or bytecode compiled without -g) was
rendered from an interface's default method, type variables included:
Pipe<E>'s make(E, String) came out as make(fallback: any, tag: string): any.
The class's own declaration now supplies the types and only the parameter
names are borrowed.

processMethod declares a superclass's method again in a class that declares
another method of the same name. Those copies took the superclass method's
raw annotation, which can differ from what the superclass's own typings
declare once an interface's @nullable is merged in, so a subclass's override
could still widen the copy's return type. The copies now take the
superclass's declared nullability, and the supertype model counts them.
With --merge-class-versions the model also counts the members only other
versions of a class declare.

Both cases are in the Kotlin fixture.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant