Vertispan / jsinterop-ts-defs

Ingests jsinterop-annotated Java and generates TypeScript definitions
Apache License 2.0
7 stars 3 forks source link

Provide a mechanism to express TS type unions from Java #61

Closed niloc132 closed 1 year ago

niloc132 commented 1 year ago

Unlike many of the other ideas in JS, closure js, and typescript, Java doesn't have a type union feature, with the exception of in catch() expressions. JsInterop handles this case by generating new types which express each of the possible unioned types, with 'isFoo' methods that return boolean, and 'asFoo' that return the value cast to that type. The @JsType for those union types incorrectly states that they map to ? in closure JS, so there is no way from jsinterop alone to work backwards to closure JS here.

Instead, I propose that we author union types in Java, with specific annotations to explain that this is a union between several other types. We can't simply annotate the type with @TsUnion(String.class, Number.class) or the like, since it wouldn't be possible to pass JsArray<String> in there as well, so actual members that match the possible types are required.

Example of jsinterop and elemental2, using fetch(resource) (omitting options for the sake of simplicity):

  public static native Promise<Response> fetch(DomGlobal.FetchInputUnionType input);

  @JsOverlay
  public static final Promise<Response> fetch(Request input) {
    return fetch(Js.<DomGlobal.FetchInputUnionType>uncheckedCast(input), init);
  }

  @JsOverlay
  public static final Promise<Response> fetch(String input) {
    return fetch(Js.<DomGlobal.FetchInputUnionType>uncheckedCast(input), init);
  }

  @JsType(isNative = true, name = "?", namespace = JsPackage.GLOBAL)
  public interface FetchInputUnionType {
    @JsOverlay
    static DomGlobal.FetchInputUnionType of(Object o) {
      return Js.cast(o);
    }

    @JsOverlay
    default Request asRequest() {
      return Js.cast(this);
    }

    @JsOverlay
    default String asString() {
      return Js.asString(this);
    }

    @JsOverlay
    default boolean isRequest() {
      return (Object) this instanceof Request;
    }

    @JsOverlay
    default boolean isString() {
      return (Object) this instanceof String;
    }
  }

As can be seen from the overloads, either a String or a Request can be specified to this method, but the actual underlying JS method is only defined once - the implementation will have to test which object is passed. In the same way, if a Java method were defined that takes some union type, JS could call it with either type, and the Java code would have to test the type of the parameter.

In closure JS, this is defined as (again, omitting the options/init):

/**
 * @typedef {!Request|!URL|string}
 * @see https://fetch.spec.whatwg.org/#requestinfo
 */
var RequestInfo;

/**
 * @param {!RequestInfo} input
 * @return {!Promise<!Response>}
 * @see https://fetch.spec.whatwg.org/#fetch-method
 */
function fetch(input) {}

In typescript, we might define this instead as

fetch(input:string|Request):Promise<Response>

Since this tool starts only from java and we can control the TS output, I suggest we use a union type in this way to describe an API that is flexible in what types can be used. The example input Java here creates a native type mapped to ?, and provides only the required convenience methods to read the values (writing them is optional, and need only exist if other Java would call this code), but still has to behave in the jsinterop type system as a native type. Unioning with null is still supported via @JsNullable. This means all methods on the union type are @JsOverlay.

@JsType
public interface Api {
  ResultUnion<String> someFunction(ParamUnion param1, @JsNullable ParamUnion param2);
}

@JsType(isNative=true, name="?", namespace=JsPackage.GLOBAL)
@TsUnion// Proposed annotation on the type itself 
public interface ResultUnion<T> {//generics can be supported, but need not be used on each constituent type
   // one optional way for java to be able to directly create these, has no effect on ts output
  @JsOverlay
  static <T> ResultUnion<T>  of(Object o) { return Js.cast(o); }

  @JsOverlay
  @TsUnionMember// Proposed annotation for methods to indicate that this return type is only of the possible types
  default Double asNumber() {...}

  @JsOverlay
  @TsUnionMember
  default JsArray<T> asArray() {...}
}

@JsType(isNative=true, name="?", namespace=JsPackage.GLOBAL)
@TsUnion// Proposed annotation on the type itself 
public interface ParamUnion {
  // since this is a param type, java has no real need for creator methods

  @JsOverlay
  @TsUnionMember
  default Double asNumber() {...}

  // another demonstration of a complex type
  @JsOverlay
  @TsUnionMember
  default JsArray<@JsNullable String> asNullableStringArray() {...}

}

Note that both TsUnion and TsUnionMember are probably not both needed, but might make for added clarity.

There is some flexibility in what we can generate here, but at least for now I suggest emitting the union inline, without a named typedef:

interface Api {
  someFunction(param1:Array<string | null>|number, param2:Array<string | null>|number|null|undefined):number|Array<string>; 
}