Skip to content

Java SDK generation fails on discriminator value _ #17431

Open

Description

@ChoMinGi

Java SDK generation fails on discriminator value _

When a discriminated union's discriminator value is a single underscore ("_"), the Java generator aborts with:

IllegalArgumentException: not a valid name: 

(empty identifier — note the trailing space, nothing after the colon)

Reproduction

openapi: 3.0.3
info:
  title: Underscore discriminator repro
  version: 1.0.0

paths:
  /filter:
    post:
      operationId: createFilter
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Filter"
      responses:
        "204":
          description: OK

components:
  schemas:
    Filter:
      oneOf:
        - $ref: "#/components/schemas/StringFilter"
        - $ref: "#/components/schemas/UnderscoreFilter"
      discriminator:
        propertyName: type
        mapping:
          string: "#/components/schemas/StringFilter"
          "_": "#/components/schemas/UnderscoreFilter"

    StringFilter:
      type: object
      properties:
        type:
          type: string
          enum: ["string"]
        value:
          type: string
      required: [type, value]

    UnderscoreFilter:
      type: object
      properties:
        type:
          type: string
          enum: ["_"]
        field:
          type: string
      required: [type, field]

Running fern ir confirms the IR correctly preserves "discriminantValue": "_".

Running fern generate --local against fernapi/fern-java-sdk:4.19.0 fails with:

Exception in thread "main" java.lang.RuntimeException:
java.lang.IllegalArgumentException: not a valid name: 
    at com.fern.java.AbstractGeneratorCli.run(AbstractGeneratorCli.java:412)
    at com.fern.java.client.Cli.main(Cli.java:122)
Caused by: java.lang.IllegalArgumentException: not a valid name: 
    at com.squareup.javapoet.MethodSpec.methodBuilder(MethodSpec.java:188)
    at com.fern.java.generators.UnionGenerator$ModelUnionSubTypes
        .getStaticFactory(UnionGenerator.java:440)
    at com.fern.java.generators.union.UnionTypeSpecGenerator
        .getStaticConstructors(UnionTypeSpecGenerator.java:162)

Root cause

This hits the same crash site as #17429 (UnionGenerator.java:440) but for a different reason. CasingConfiguration.computeName() builds the Java identifier by first calling toCamelCase(), which splits the input into words using this regex (splitWords):

[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+

That pattern doesn't match underscores at all, so splitWords("_") returns an empty list, and toCamelCase("_") returns "". The reserved-keyword check in sanitizeName() runs after this transformation, so there's no string left to compare against the keyword list by that point — computeName("_").camelSafe comes back as "", not "_" and not "__".

This is a distinct bug from #17429: that one was a missing entry in a keyword list; this one is the word-splitting step silently discarding a legal identifier before the keyword list is ever consulted. Just adding "_" to CasingConfiguration.JAVA_RESERVED_KEYWORDS on its own is a no-op — verified directly against the real class.

Worth noting: KeyWordUtils.java (a separate reserved-word list used for object property/class names elsewhere in the same generator) already treats _ as reserved and escapes it correctly there. It's specifically the CasingConfiguration word-splitting path (used for union discriminants, among other compressed/reconstructed names) that's affected.

Expected behavior

A valid, non-empty Java identifier — _ escaped to __, matching the existing convention used for other reserved words — while the serialized discriminator value stays "_".

Suggested fix

A complete fix needs both parts: (1) preserve all-underscore input during casing conversion instead of letting it collapse to "" (e.g. short-circuit before word-splitting when the input matches ^_+$), and (2) treat _ as a reserved Java identifier in JAVA_RESERVED_KEYWORDS so the preserved value gets escaped to __. I've implemented and verified this locally: unit tests against the real class confirm computeName("_").camelSafe == "__" (with "__"/"___" and ordinary names like "user_id" unaffected), and rebuilding the fernapi/fern-java-sdk image from source with the fix and re-running the reproduction above now generates successfully, with public static Filter __(UnderscoreFilter value) and @JsonTypeName("_") — a valid identifier, unchanged wire value. Happy to open a PR.

Note on parity: the canonical TypeScript CasingsGenerator has the same default behavior (_ also collapses to "" via lodash's camelCase() unless its preserveUnderscores option is set, which isn't currently invoked anywhere in the generator pipeline), and _ is likewise absent from reserved.ts's Java keyword set. So this isn't a case of Java being out of sync with existing TypeScript behavior — neither implementation handles it today. Bringing TypeScript to parity would be a separate, broader change.

Follow-up to #17429 / #17430, found while verifying whether that fix would also cover _ (it didn't, until now).

Activity

  1. ChoMinGi commented on Aug 17, 2026

    @ChoMinGi
    Author

    Update: the initial fix (falling back to _ → __ for any input with no legal characters, e.g. -, @) had a follow-on bug — distinct wordless discriminant values all collapsed to the same fallback identifier. If two such variants exist as siblings in one union (e.g. discriminators "-" and "@"), generation reports success but silently produces invalid, non-compiling Java: duplicate method signatures (is_() defined twice) and a duplicate nested class name (__Value defined twice in the same scope).

    Reproduced end-to-end: a union with "-" and "@" variants generates via fern generate --local without error, but the emitted Filter.java has two public boolean is_() methods and two private static final class __Value declarations — this doesn't compile.

    Fixed by encoding the stripped characters' code points instead of collapsing to a shared placeholder when no legal characters remain, so distinct inputs never produce the same identifier:

    • "-" (U+002D) → __2d
    • "@" (U+0040) → __40

    Inputs that do have legal characters remaining after stripping (e.g. "_", "-_-" → "_", "$$") are unaffected by this change — they still just keep those characters (or _, escaped to __, when that's all that's legal).

    Verified with the same real end-to-end setup: fern generate --local against a union with both "-" and "@" variants now succeeds and produces distinct, compiling identifiers (__2d(...)/is__2d()/__2dValue, __40(...)/is__40()/__40Value), with wire values still "-"/"@" via @JsonTypeName.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions