JdkOptionalTest.java
/*
* Copyright (C) 2026 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.gson.functional;
import static com.google.common.truth.Truth.assertThat;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.Strictness;
import com.google.gson.TypeAdapter;
import com.google.gson.internal.bind.ReflectiveTypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import java.io.IOException;
import java.io.StringWriter;
import java.lang.reflect.Field;
import java.lang.reflect.InaccessibleObjectException;
import java.util.List;
import java.util.Optional;
import java.util.OptionalDouble;
import java.util.OptionalInt;
import java.util.OptionalLong;
import org.junit.Test;
public final class JdkOptionalTest {
private final Gson gson = new Gson();
@Test
public void testPresentOptional() {
roundTrip(Optional.of("hello"), new TypeToken<Optional<String>>() {}, "{\"value\":\"hello\"}");
}
@Test
public void testEmptyOptional() {
roundTrip(Optional.empty(), new TypeToken<Optional<String>>() {}, "{}");
}
@Test
public void testEmptyOptionalSerializeNulls() {
Gson gsonWithNulls = new GsonBuilder().serializeNulls().create();
var typeToken = new TypeToken<Optional<String>>() {};
assertThat(gsonWithNulls.toJson(Optional.empty(), typeToken.getType()))
.isEqualTo("{\"value\":null}");
assertThat(gsonWithNulls.fromJson("{\"value\":null}", typeToken)).isEmpty();
}
@Test
public void testNullOptional() {
var typeToken = new TypeToken<Optional<String>>() {};
assertThat(gson.toJson(null, typeToken.getType())).isEqualTo("null");
assertThat(gson.fromJson("null", typeToken)).isNull();
}
@Test
public void testOptionalWithUnknownFields() {
var typeToken = new TypeToken<Optional<String>>() {};
String json = "{\"tiddly\":\"pom\",\"value\":\"hello\",\"wibble\":\"wobble\"}";
assertThat(gson.fromJson(json, typeToken)).hasValue("hello");
}
@Test
public void testRawOptional() {
roundTrip(Optional.of("hello"), TypeToken.get(Optional.class), "{\"value\":\"hello\"}");
roundTrip(Optional.empty(), TypeToken.get(Optional.class), "{}");
}
@Test
public void testNestedOptional() {
var typeToken = new TypeToken<Optional<Optional<String>>>() {};
roundTrip(Optional.of(Optional.of("hello")), typeToken, "{\"value\":{\"value\":\"hello\"}}");
roundTrip(Optional.of(Optional.empty()), typeToken, "{\"value\":{}}");
roundTrip(Optional.empty(), typeToken, "{}");
}
@Test
public void testOptionalPolymorphicValue() {
var typeToken = new TypeToken<Optional<SuperClass>>() {};
assertThat(gson.toJson(Optional.of(new SubClass("abc", "def")), typeToken.getType()))
.isEqualTo("{\"value\":{\"sub\":\"def\",\"sup\":\"abc\"}}");
}
@Test
public void testOptionalFieldSerialization_serializeNulls() {
Gson gsonWithNulls = new GsonBuilder().serializeNulls().create();
TestClass testClass = new TestClass(Optional.of("hello"), Optional.empty());
String json = gsonWithNulls.toJson(testClass);
assertThat(json).isEqualTo("{\"present\":{\"value\":\"hello\"},\"empty\":{\"value\":null}}");
TestClass deserialized = gsonWithNulls.fromJson(json, TestClass.class);
assertThat(deserialized.present).hasValue("hello");
assertThat(deserialized.empty).isEmpty();
}
@Test
public void testOptionalFieldSerialization_skipNulls() {
TestClass testClass = new TestClass(Optional.of("hello"), Optional.empty());
String json = gson.toJson(testClass);
assertThat(json).isEqualTo("{\"present\":{\"value\":\"hello\"},\"empty\":{}}");
TestClass deserialized = gson.fromJson(json, TestClass.class);
assertThat(deserialized.present).hasValue("hello");
assertThat(deserialized.empty).isEmpty();
}
@Test
public void testOptionalInt() {
roundTrip(
OptionalInt.of(123),
TypeToken.get(OptionalInt.class),
"{\"isPresent\":true,\"value\":123}");
roundTrip(
OptionalInt.empty(), TypeToken.get(OptionalInt.class), "{\"isPresent\":false,\"value\":0}");
assertThat(gson.toJson(null, OptionalInt.class)).isEqualTo("null");
assertThat(gson.fromJson("null", OptionalInt.class)).isNull();
assertThat(
gson.fromJson(
"{\"extra\":1,\"isPresent\":true,\"value\":123,\"more\":2}", OptionalInt.class))
.isEqualTo(OptionalInt.of(123));
}
@Test
public void testOptionalLong() {
roundTrip(
OptionalLong.of(1234567890123L),
TypeToken.get(OptionalLong.class),
"{\"isPresent\":true,\"value\":1234567890123}");
roundTrip(
OptionalLong.empty(),
TypeToken.get(OptionalLong.class),
"{\"isPresent\":false,\"value\":0}");
assertThat(gson.toJson(null, OptionalLong.class)).isEqualTo("null");
assertThat(gson.fromJson("null", OptionalLong.class)).isNull();
assertThat(
gson.fromJson(
"{\"extra\":1,\"isPresent\":true,\"value\":1234567890123,\"more\":2}",
OptionalLong.class))
.isEqualTo(OptionalLong.of(1234567890123L));
}
@Test
public void testOptionalDouble() {
roundTrip(
OptionalDouble.of(12.5),
TypeToken.get(OptionalDouble.class),
"{\"isPresent\":true,\"value\":12.5}");
roundTrip(
OptionalDouble.empty(),
TypeToken.get(OptionalDouble.class),
"{\"isPresent\":false,\"value\":0.0}");
assertThat(gson.toJson(null, OptionalDouble.class)).isEqualTo("null");
assertThat(gson.fromJson("null", OptionalDouble.class)).isNull();
assertThat(
gson.fromJson(
"{\"extra\":1,\"isPresent\":true,\"value\":12.5,\"more\":2}", OptionalDouble.class))
.isEqualTo(OptionalDouble.of(12.5));
Gson lenientGson = new GsonBuilder().setStrictness(Strictness.LENIENT).create();
// Empty OptionalDouble internally has value NaN; reflective adapter would have written that,
// verify that it can be deserialized again
assertThat(lenientGson.fromJson("{\"isPresent\":false,\"value\":NaN}", OptionalDouble.class))
.isEqualTo(OptionalDouble.empty());
}
@Test
public void testCustomAdapterPrecedence() {
TypeAdapter<OptionalInt> customAdapter =
new TypeAdapter<OptionalInt>() {
@Override
public OptionalInt read(JsonReader in) throws IOException {
int value = in.nextInt();
return value == -1 ? OptionalInt.empty() : OptionalInt.of(value);
}
@Override
public void write(JsonWriter out, OptionalInt value) throws IOException {
out.value(value.orElse(-1));
}
}.nullSafe();
Gson customGson =
new GsonBuilder().registerTypeAdapter(OptionalInt.class, customAdapter).create();
assertThat(customGson.toJson(OptionalInt.of(42))).isEqualTo("42");
assertThat(customGson.toJson(OptionalInt.empty())).isEqualTo("-1");
assertThat(customGson.fromJson("42", OptionalInt.class)).isEqualTo(OptionalInt.of(42));
assertThat(customGson.fromJson("-1", OptionalInt.class)).isEqualTo(OptionalInt.empty());
}
private static final boolean OPTIONAL_FIELDS_ARE_ACCESSIBLE;
static {
boolean accessible = false;
try {
Optional.class.getDeclaredField("value").setAccessible(true);
accessible = true;
} catch (InaccessibleObjectException e) {
// OK: we can't reflect on java.util.Optional fields
} catch (NoSuchFieldException e) {
throw new AssertionError(e);
}
OPTIONAL_FIELDS_ARE_ACCESSIBLE = accessible;
}
private <T> void roundTrip(T value, TypeToken<T> typeToken, String expectedJson) {
assertThat(gson.getAdapter(typeToken).toString()).contains("JdkOptionalTypeAdapters");
assertThat(gson.toJson(value, typeToken.getType())).isEqualTo(expectedJson);
assertThat(gson.fromJson(expectedJson, typeToken)).isEqualTo(value);
if (OPTIONAL_FIELDS_ARE_ACCESSIBLE) {
checkReflectiveTypeAdapterFactory(value, typeToken, expectedJson);
}
}
// Assuming we have reflective access to the fields of java.util.Optional* classes, check that
// ReflectiveTypeAdapterFactory would produce the same JSON. This ensures that we are preserving
// a compatible JSON format for those classes even though we no longer use reflection.
private void checkReflectiveTypeAdapterFactory(
Object value, TypeToken<?> typeToken, String expectedJson) {
if (value.equals(OptionalDouble.empty())) {
// OptionalDouble.EMPTY has value = Double.NaN in the JDK, which causes
// ReflectiveTypeAdapterFactory to throw IllegalArgumentException by default.
return;
}
List<?> factories;
try {
Field factoriesField = gson.getClass().getDeclaredField("factories");
factoriesField.setAccessible(true);
factories = (List<?>) factoriesField.get(gson);
} catch (ReflectiveOperationException e) {
throw new LinkageError(e.getMessage(), e);
}
ReflectiveTypeAdapterFactory adapterFactory =
factories.stream()
.filter(f -> f instanceof ReflectiveTypeAdapterFactory)
.map(f -> (ReflectiveTypeAdapterFactory) f)
.findFirst()
.get();
// Safe for test serialization
@SuppressWarnings("unchecked")
TypeAdapter<Object> adapter = (TypeAdapter<Object>) adapterFactory.create(gson, typeToken);
StringWriter stringWriter = new StringWriter();
try {
adapter.write(gson.newJsonWriter(stringWriter), value);
} catch (IOException e) {
throw new AssertionError(e);
}
assertThat(stringWriter.toString()).isEqualTo(expectedJson);
}
private static class SuperClass {
// Read reflectively by Gson
@SuppressWarnings("unused")
final String sup;
SuperClass(String sup) {
this.sup = sup;
}
}
private static class SubClass extends SuperClass {
// Read reflectively by Gson
@SuppressWarnings("unused")
final String sub;
SubClass(String sup, String sub) {
super(sup);
this.sub = sub;
}
}
private static class TestClass {
Optional<String> present;
Optional<String> empty;
TestClass() {}
TestClass(Optional<String> present, Optional<String> empty) {
this.present = present;
this.empty = empty;
}
}
}