Repository navigation
Java SDK generation fails on discriminator value _ #17431
Description
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
ChoMinGi commentedon Aug 17, 2026
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 (__Valuedefined twice in the same scope).Reproduced end-to-end: a union with
"-"and"@"variants generates viafern generate --localwithout error, but the emittedFilter.javahas twopublic boolean is_()methods and twoprivate static final class __Valuedeclarations — 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) →__40Inputs 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 --localagainst 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.