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).
Java SDK generation fails on discriminator value
_When a discriminated union's discriminator value is a single underscore (
"_"), the Java generator aborts with:(empty identifier — note the trailing space, nothing after the colon)
Reproduction
Running
fern irconfirms the IR correctly preserves"discriminantValue": "_".Running
fern generate --localagainstfernapi/fern-java-sdk:4.19.0fails with: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 callingtoCamelCase(), which splits the input into words using this regex (splitWords):That pattern doesn't match underscores at all, so
splitWords("_")returns an empty list, andtoCamelCase("_")returns"". The reserved-keyword check insanitizeName()runs after this transformation, so there's no string left to compare against the keyword list by that point —computeName("_").camelSafecomes 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
"_"toCasingConfiguration.JAVA_RESERVED_KEYWORDSon 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 theCasingConfigurationword-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 inJAVA_RESERVED_KEYWORDSso the preserved value gets escaped to__. I've implemented and verified this locally: unit tests against the real class confirmcomputeName("_").camelSafe == "__"(with"__"/"___"and ordinary names like"user_id"unaffected), and rebuilding thefernapi/fern-java-sdkimage from source with the fix and re-running the reproduction above now generates successfully, withpublic static Filter __(UnderscoreFilter value)and@JsonTypeName("_")— a valid identifier, unchanged wire value. Happy to open a PR.Note on parity: the canonical TypeScript
CasingsGeneratorhas the same default behavior (_also collapses to""via lodash'scamelCase()unless itspreserveUnderscoresoption is set, which isn't currently invoked anywhere in the generator pipeline), and_is likewise absent fromreserved.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).