Exploring the Kotlin Type System and understanding Null Safety

Exploring the Kotlin Type System and understanding Null Safety

Learn about Kotlin type hierarchy, type checks and type casts

Welcome to the fourth article in this series on Android Development. In the previous articles, we covered some basics of Kotlin and OOPS. In this article, we're going to dive deep into Kotlin's type system and learn all about null safety.

First, let's define what a type system is. In programming, a type system is a set of rules that determine how data is stored, modified, and used. Coming to the second part of our article, we'll discuss why we need to be concerned with null safety. Accessing a type that can be null can result in a null reference exception, commonly known as a NullPointerException. According to a survey, NullPointerException is the most common exception in java applications. But don't worry, Kotlin offers a solution for this.

The type system of Kotlin is designed to provide an effective way to manage null references. It makes a clear distinction between references that can hold null values (nullable references) and those that cannot (non-null references). Hence, the compiler can track values which can be null and warn you if you're trying to access nullable value, preventing an NullPointerException from happening.

With the introduction out of the way, let's get started!

Basic Types

The data type is one of the most fundamental concepts in any programming language. Simply put, a type is a category of data that a variable or expression can hold. In Kotlin, several basic types are available to use when declaring variables and constants. These basic types include numbers, characters, and booleans. These different types are very neatly organized in a hierarchy that we'll take a look at after we understand nullable types.

Numbers

Numbers in Kotlin come in two forms: Integers and Floating-point numbers. Integers are represented by types such as Byte, Short, Int, and Long. For example,

val age: Int = 25
val weight: Long = 150

Floating-point numbers include types such as Float and Double. For example,

val price: Double = 12.99
val temperature: Float = 32.5f

Also, you can use _ to make increase the readability of code.

val largeNum: Long = 1_000_000_000

Characters

Characters in Kotlin are represented by the Char type. They are enclosed in single quotes, for example:

val letter: Char = 'A'

Booleans

Booleans in Kotlin is represented by the Boolean type. They can be either true or false. For example,

val isStudent: Boolean = true
val isTired: Boolean = false

In addition to these basic types, Kotlin also supports strings, arrays and enum types.

Strings

Kotlin's string type is similar to the string type in most programming languages. It is used to represent a sequence of characters. For example,

val name: String = "John Doe"

Arrays

Arrays in Kotlin are similar to arrays in most programming languages. They are used to store a fixed-size sequential collection of elements of the same type. For example,

val numbers: IntArray = intArrayOf(1,2,3,4,5)
val names: Array<String> = arrayOf("John", "Doe", "Jane", "Smith")

Let's also discuss three special types in Kotlin, Any, Unit and Nothing.

Any

As I mentioned above, all types in Kotlin are organized into a hierarchy, Any is at the root of that hierarchy. For instance, Int and Boolean are subtype of Any. Also, all the classes that you create are subtypes of Any

Unit

Unit is a pre-defined type in Kotlin. It is a singleton, meaning there is only one instance of it. The unit type is used to represent the absence of a value. For example, if you have a function that does not need to return a value, you'd use Unit as its return type.

fun greet(): Unit {
    println("Hello, World!")
}

Also, if you don't specifically mention a return type for the function, it's assumed to return Unit. Hence, the above code can be rewritten as:

fun greet() {
   println("Hello, World!")
}

Nothing

Nothing is a special type in Kotlin. It is at the very bottom of the type hierarchy. This means that Nothing is a subtype of every type. Also, the type Nothing can not be initialized and hence, can not have any instance. Please note the difference between Unit and Nothing. Evaluation of an expression type Unit results in the singleton value Unit. Evaluation of an expression of type Nothing never returns at all.

fun throwsException(): Nothing {
    throw Exception()
}

Please note that Kotlin is a type-safe language, which means that the compiler checks the types of variables and constants at compile time to ensure that they match the expected types.

Null Safety

As we previously discussed, accessing a type that can be null can result in a null reference exception, commonly known as a NullPointerException. Let's see how we can deal with null references in Kotlin.

Kotlin's type system is designed in such a way that it distinguishes between variables that can hold a null value(nullable references) and those that cannot(non-nullable references).

For example, a regular variable of type String cannot hold null:

// Regular initialization means non-null by default
var a: String = "abc" 

a = null // compilation error

To declare a variable that can be set to null, append ? at the end of the type. For example, a nullable String should be declared as String?

var b: String? = "abc" // can be set to null
b = null               // ok

In this way, if you call a method or access a property on a, it's guaranteed not to cause throw a NullPointerException, so it is safe to say:

val l = a.length

However, if you want to access the same property on b, that would not be safe and the compiler will report an error:

val l = b.length // error: variable 'b' can be null

But don't worry, there are ways to safely access properties on nullable variables, which we will discuss later in the article. But first, let's take a look at the type hierarchy in Kotlin.

Type Hierarchy in Kotlin

As we discussed above, Any type is the supertype for all the types, hence it is placed at the "top" of the hierarchy

All the custom types (classes, interfaces, etc) that don't have an explicit type are also a sub-type of Any type. For instance,

class Box {
    fun render() {
        // function body
    }
}

The type Box is a subtype of Any.

Now, the types that inherit other types form a tree structure in the hierarchy. A type can have multiple parent types. For instance,

interface IClickable
interface IDragable

class Card: Box, IClickable, IDragable {
    // ...
}

Coming to the nullable references, each type has its nullable counterpart which is a supertype for the type. For example, Unit is a subtype of Unit? .

Expanding the complete graph to include nullable types results in the following

Now, at the "bottom" of the type hierarchy is the Nothing type. So here's the complete type hierarchy in Kotlin.

Working with Nullable Types

As we saw that we cannot directly access the properties and methods of nullable types, hence it's a bit tricky to work with them. There are two very useful operators to work with nullable types - safe call operator ? and Elvis operator ?:.

Let's first see how the safe-call operator works. This operator returns null if the operand is null. Hence, it allows you to access properties and methods of nullable variables without the risk of Null Pointer Exception.

var name: String? = "John"
var firstChar = name?.get(0) // Evaluates to J

name = null
firstChar = name?.get(0) // Evaluates to null

In this example, if the name variable is null, null will be assigned to the firstChar variable.

Now, let's take a look at the Elvis operator (?:). It is similar to an if-else statement, like so:

val name: String? = "John"
val firstChar = name?.get(0) ?: '?'

This is the same as writing:

val name: String? = "John"
val firstChar = if(name != null) name.get(0) else '?'

One more way to handle nullable variables is the not-null assertion operator (!!). It is used to explicitly assert that a nullable type is not null, and this will throw a null pointer exception if it is. We can use this operator when we are sure that a nullable variable holds a non-null value. For example:

val name: String? = "John"
val upperCaseName = name!!.toUpperCase()

Type checks and casts

is operator

In Kotlin, we can check the type of an object using the is operator and its negated form !is. These operators perform a runtime check that identifies whether an object conforms to a given type.

For example, if we have an object obj and want to check if it's of type String, we can do the following:

if (obj is String) {
    print(obj.length)
}

// OR

if (obj !is String) { // same as !(obj is String)
    print("Not a String")
} else {
    print(obj.length)
}

as and as?operator

Kotlin offers two types of cast operators that can be used to change the type of the object. The first is the unsafe cast operator, denoted by the keyword "as". This operator will throw an exception if the cast is not possible. For example, if we try to cast a variable of type Any to a String using the unsafe cast operator, the code will throw a ClassCastException if the variable is not a String.

val x: Any = "hello"
val y: String = x as String // This will work
val z: Any = 123
val a: String = z as String // This will throw a ClassCastException

The second type of cast operator is the safe cast operator, denoted by the keyword "as?". This operator will return null if the cast is not possible, instead of throwing an exception. This can be useful in situations where we are not sure if the variable is of the type we expect it to be.

val x: Any = "hello"
val y: String? = x as? String // This will return "hello"
val z: Any = 123
val a: String? = z as? String // This will return null

Smart Cast in Kotlin

In addition to these explicit cast operators, Kotlin also has a feature called smart casts. Smart casts allow the compiler to automatically insert safe casts when necessary, without the need for explicit cast operators. The compiler can track the type of a variable and insert a cast when it is used in a context where a different type is expected.

For example, consider the following code:

if (x is String) {
    print(x.length) // x is automatically cast to String
}

Here, the compiler is smart enough to know that x will be a String in the body of if block, hence, it automatically casts the variable x to String.

However, it's important to note that smart casts work only when the compiler can guarantee that the variable won't change between the check and the usage. More specifically, smart casts can be used under the following conditions:

  • val local variables - always, except local delegated properties

  • val properties - if the property is private or internal or if the check is performed in the same module where the property is declared. Smart casts cannot be used on open properties or properties that have custom getters.

  • var local variables - if the variable is not modified between the check and the usage, is not captured in a lambda that modifies it, and is not a local delegated property.

  • var properties - never, because the variable can be modified at any time by other code.

Conclusion

In conclusion, the Kotlin-type system is an important aspect of the language that provides many features to ensure safety and reliability in your code. We have discussed the basic types available in Kotlin, the concept of null safety, and the various type casts and checks that can be used to ensure that your code is working as expected.

It is important to understand how to use these features to prevent common errors such as null pointer exceptions and type mismatches. By making use of nullable types, the safe call operator, and the Elvis operator, you can write code that is safe from null references and is more readable.

We have also discussed how to use the is and !is operators and the safe and unsafe cast operators to perform runtime type checks and casts. Smart casts, which are automatically inserted by the compiler when necessary, provide an even more convenient way to handle types.

In the next article in this series, we will delve into data classes. Data classes are a special type of class in Kotlin that are designed to hold data. They are typically used to represent the data model in an app, such as a user or a product.

Keep Coding!