32

In Kotlin you can create a data class:

data class CountriesResponse(
    val count: Int,
    val countries: List<Country>,
    val error: String)

Then you can use it to parse a JSON, for instance, "{n: 10}". In this case you will have an object val countries: CountriesResponse, received from Retrofit, Fuel or Gson, that contains these values: count = 0, countries = null, error = null.

In Kotlin + Gson - How to get an emptyList when null for data class you can see another example.

When you later try to use countries, you will get an exception here: val size = countries.countries.size: "kotlin.TypeCastException: null cannot be cast to non-null type kotlin.Int". If you write a code and use ? when accessing these fields, Android Studio will highlight ?. and warn: Unnecessary safe call on a non-null receiver of type List<Country>.

So, should we use ? in data classes? Why can an application set null to non-nullable variables during runtime?

Community
  • 1
  • 1
CoolMind
  • 26,736
  • 15
  • 188
  • 224

2 Answers2

44

This happens because Gson uses an unsafe (as in java.misc.Unsafe) instance construction mechanism to create instances of classes, bypassing their constructors, and then sets their fields directly.

See this Q&A for some research: Gson Deserialization with Kotlin, Initializer block not called.

As a consequence, Gson ignores both the construction logic and the class state invariants, so it is not recommended to use it for complex classes which may be affected by this. It ignores the value checks in the setters as well.

Consider a Kotlin-aware serialization solution, such as Jackson (mentioned in the Q&A linked above) or kotlinx.serialization.

hotkey
  • 140,743
  • 39
  • 371
  • 326
  • 1
    Currently I set `?` after types in all suspecting fields, that can be `null`. Crashes disappeared. – CoolMind Mar 26 '19 at 07:11
  • 2
    I was struggling with null values in non-nullable fields as well when using Gson. I therefore wrote a wrapper for Gson that checks for such invalid null values. Check it out: https://github.com/taskbase/arson/ – jjoller Aug 26 '19 at 12:29
  • 2
    I used the MoshiConverterFactory and had the exact same problem as Gson was giving me... – Tyler Pfaff Sep 09 '19 at 23:38
  • @TylerPfaff add the `KotlinJsonAdapterFactory()` to Moshi to fix that – Blundell Nov 16 '22 at 14:23
9

A JSON parser is translating between two inherently incompatible worlds - one is Java/Kotlin, with their static typing and null correctness and the other is JSON/JavaScript, where everything can be everything, including null or even absent and the concept of "mandatory" belongs to your design, not the language.

So, gaps are bound to happen and they have to be handled somehow. One approach is to throw exception on the slightest problem (which makes lots of people angry on the spot) and the other is to fabricate values on the fly (which also makes lots of people angry, just bit later).

Gson takes the second approach. It silently swallows absent fields; sets Objects to null and primitives to 0 and false, completely masking API errors and causing cryptic errors further downstream.

For this reason, I recommend 2-stage parsing:

package com.example.transport
//this class is passed to Gson (or any other parser)
data class CountriesResponseTransport(
   val count: Int?,
   val countries: List<CountryTransport>?,
   val error: String?){
   
   fun toDomain() = CountriesResponse(
           count ?: throw MandatoryIsNullException("count"),
           countries?.map{it.toDomain()} ?: throw MandatoryIsNullException("countries"),
           error ?: throw MandatoryIsNullException("error")
       )
}

package com.example.domain
//this one is actually used in the app
data class CountriesResponse(
   val count: Int,
   val countries: Collection<Country>,
   val error: String)

Yes, it's twice as much work - but it pinpoints API errors immediately and gives you a place to handle those errors if you can't fix them, like:

   fun toDomain() = CountriesResponse(
           count ?: countries?.count ?: -1, //just to brag we can default to non-zero
           countries?.map{it.toDomain()} ?: ArrayList()
           error ?: MyApplication.INSTANCE.getDeafultErrorMessage()
       )

Yes, you can use a better parser, with more options - but you shouldn't. What you should do is abstract the parser away so you can use any. Because no matter how advanced and configurable parser you find today, eventually you'll need a feature that it doesn't support. That's why I treat Gson as the lowest common denominator.

There's an article that explains this concept used (and expanded) in a bigger context of repository pattern.

Agent_L
  • 4,960
  • 28
  • 30
  • Thanks! An interestion solution. What if in `error` field PHP-coders send `null` or `[]` if it is empty? For instance, after fixing a bug they changed from `[]` to `null`. We update an Android application, but older versions don't know about it. – CoolMind Feb 24 '20 at 20:55
  • 1
    @CoolMind You can't really handle errors retroactively, you need API versioning to support older apps. Alternative is to create an "app min version" endpoint and force users to update the app. When developing an app, you have 2 options: either enter a strict contract with API developers and when they break the contract it's their fault and they fix it (my first version with throwing exceptions) or you treat API devs as children and handle every error possible before they can even make it (my second version). – Agent_L Feb 25 '20 at 08:12
  • Agree with you. API versioning is better in this case because backend developers can again change something, and while we update and Android app, we can waste several days. So, with API versioning users won't see an exception, but in other case they can. See my solution of the problem here: https://stackoverflow.com/a/54709501/2914140. – CoolMind Feb 25 '20 at 08:47
  • @CoolMind It seems we want the opposite things. My example here was to replace nulls with empty arrays, so you can iterate over them without NPE. If you want to replace empty arrays with null you can do `countries?.let{ if(it.count>0) it else null }`. My point is that once you have your code, you can do anything you want. – Agent_L Feb 25 '20 at 09:56
  • @CoolMind I've found and linked an article that explains this concept in depth. – Agent_L Mar 01 '20 at 16:37
  • Thank you that researched this problem, I will try to use your solution in future. – CoolMind Mar 02 '20 at 08:23