Object, Companion Object, and Singleton in Kotlin

1. Overview

Kotlin doesn’t have a static keyword. Instead, the language provides the object keyword with three use cases: object declaration, object expression, and companion object.

This article explains each use case, how Kotlin compiles them to JVM bytecode, how to call them from Java, and when not to use them. After reading, you’ll know how to create thread-safe singletons, write clean factory methods, and avoid common pitfalls when testing.

2. Setup

The examples use Kotlin 2.x on JVM 21. To run tests, add JUnit 5 and AssertJ to your pom.xml:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Your project needs the kotlin-maven-plugin to compile Kotlin code.

3. Object Declaration – Built-in Singleton Support

When you declare an object at the top level or nested inside a class, you get a singleton. Kotlin automatically creates exactly one instance of it. You don’t need a private constructor, no getInstance() method, and no double-checked locking.

import java.util.concurrent.atomic.AtomicLong

object OrderIdGenerator {
    private val counter = AtomicLong(0)

    fun next(): String = "ORD-${counter.incrementAndGet()}"
}

fun main() {
    println(OrderIdGenerator.next())
    println(OrderIdGenerator.next())
}

You call functions directly via the object’s name: OrderIdGenerator.next(). There’s no new keyword here and no constructor call.

The counter variable uses AtomicLong because multiple threads might call next() simultaneously. A singleton only guarantees one instance exists. It doesn’t automatically make the internal state thread-safe.

ORD-1
ORD-2

3.1. Lazy and Thread-Safe Initialization

An object is initialized on first access, not at program startup:

object AppConfig {
    init {
        println("AppConfig initialized")
    }

    val currency = "VND"
}

fun main() {
    println("Starting")
    println(AppConfig.currency)
    println(AppConfig.currency)
}
Starting
AppConfig initialized
VND
VND

The init block runs only once, right before the first read of currency. At the bytecode level, Kotlin compiles AppConfig to a final class with a public static final AppConfig INSTANCE field. This field is assigned in the static initializer.

The JVM guarantees that a class’s static initializer runs exactly once and is thread-safe even with concurrent access. That’s why Kotlin singletons are both lazy and thread-safe without any extra code.

3.2. Objects Can Inherit and Implement Interfaces

An object is a real type. It can implement interfaces and be passed around like any other value:

interface DiscountPolicy {
    fun apply(amount: Long): Long
}

object NoDiscount : DiscountPolicy {
    override fun apply(amount: Long): Long = amount
}

class TenPercentOff : DiscountPolicy {
    override fun apply(amount: Long): Long = amount * 90 / 100
}

fun checkout(total: Long, policy: DiscountPolicy): Long = policy.apply(total)

fun main() {
    println(checkout(200_000, NoDiscount))
    println(checkout(200_000, TenPercentOff()))
}
200000
180000

NoDiscount has no state, so creating multiple instances would be wasteful. Declaring it as an object makes the intent clear: exactly one instance is needed.

4. Object Expression – Anonymous Objects

When you place object in an expression position, you create an anonymous object. This use is similar to anonymous classes in Java:

data class Order(val id: String, val total: Long)

fun main() {
    val byTotalDesc = object : Comparator<Order> {
        override fun compare(a: Order, b: Order): Int = b.total.compareTo(a.total)
    }

    val orders = listOf(Order("A", 50), Order("B", 300), Order("C", 120))
    println(orders.sortedWith(byTotalDesc).map { it.id })
}
[B, C, A]

An important difference: object expressions are not singletons. Every time the expression is executed, a new instance is created. Also, object expressions are initialized immediately, not lazily like object declarations.

You can also create an object with no supertype to group some values temporarily in a local scope:

fun main() {
    val stats = object {
        val count = 3
        val sum = 470L
    }
    println("Average: ${stats.sum / stats.count}")
}

The type of stats is only accessible within the function or in a private declaration. If you return it outside via a public function, the type gets erased to Any and you lose access to the properties. In that case, use data class instead.

Note: For interfaces with a single abstract method, prefer lambda (SAM conversion) over object expressions, for example Comparator<Order> { a, b -> b.total.compareTo(a.total) }. Only use object expressions when you need to override multiple methods or maintain state.

5. Companion Object – The Static Replacement

Sometimes you need a function tied to a class rather than an instance, like a factory method. In Java, you’d use static. In Kotlin, you use companion object:

class Customer private constructor(val email: String, val name: String) {

    companion object {
        private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")

        fun of(email: String, name: String): Customer {
            require(EMAIL_REGEX.matches(email)) { "Invalid email: $email" }
            return Customer(email.trim().lowercase(), name.trim())
        }
    }
}

fun main() {
    val customer = Customer.of("An.Nguyen@Example.com", "  Nguyễn An ")
    println("${customer.email} | ${customer.name}")
}
an.nguyen@example.com | Nguyễn An

Here’s how the code works:

  • The constructor is private, so outside code must create Customer via Customer.of(...).
  • The companion object can access the private constructor of its enclosing class.
  • EMAIL_REGEX is compiled only once and shared across all calls.

A companion object is still a real object: a singleton nested in the class, with a default name of Companion. It’s initialized when its enclosing class is loaded, equivalent to a static initializer in Java. Each class can have at most one companion object.

5.1. Naming and Implementing Interfaces

Because it’s an object, a companion can have its own name and implement interfaces. This is something static in Java cannot do:

interface Parser<T> {
    fun parse(raw: String): T
}

data class Money(val amount: Long, val currency: String) {
    companion object Factory : Parser<Money> {
        override fun parse(raw: String): Money {
            val (amount, currency) = raw.trim().split(" ")
            return Money(amount.toLong(), currency)
        }
    }
}

fun <T> parseAll(parser: Parser<T>, lines: List<String>): List<T> = lines.map(parser::parse)

fun main() {
    println(parseAll(Money, listOf("150000 VND", "20 USD")))
}
[Money(amount=150000, currency=VND), Money(amount=20, currency=USD)]

Notice the call parseAll(Money, ...). When you use the class name Money as a value, Kotlin understands it as its companion object. Thus, the class Money itself acts as a Parser<Money>.

5.2. Extension Functions on Companions

You can add “static-like” functions to a class without modifying its source code, as long as the class declares a companion:

fun Money.Factory.zero(currency: String): Money = Money(0, currency)

fun main() {
    println(Money.zero("VND"))
}
Money(amount=0, currency=VND)

If the companion is unnamed, you write fun Money.Companion.zero(...).

6. Calling Objects and Companions from Java

By default, Java sees objects and companions as regular instances:

String id = OrderIdGenerator.INSTANCE.next();
Customer c = Customer.Companion.of("a@b.com", "An");

This syntax is verbose and hard to read. Kotlin provides three tools to make Java code look more natural:

class PaymentService {
    companion object {
        const val MAX_RETRY = 3

        @JvmField
        val SUPPORTED_CURRENCIES = listOf("VND", "USD")

        @JvmStatic
        fun create(): PaymentService = PaymentService()

        fun legacyCreate(): PaymentService = PaymentService()
    }
}

From Java, you’d call:

int retry = PaymentService.MAX_RETRY;
List<String> currencies = PaymentService.SUPPORTED_CURRENCIES;
PaymentService a = PaymentService.create();
PaymentService b = PaymentService.Companion.legacyCreate();

Here’s what each tool does:

  • const val works only with primitive types and String whose values are known at compile time. Constants are inlined at call sites and become public static final fields.
  • @JvmField generates a public static field without a getter. This annotation suits non-compile-time constant values like List.
  • @JvmStatic generates an additional static method on the PaymentService class. Functions without this annotation still require calling through Companion.

If your project only has Kotlin code, you don’t need these annotations. The exception is const val: it’s still useful because its value can be used in annotations, for example, @Retry(maxAttempts = MAX_RETRY).

7. data object for Stateless States

Starting with Kotlin 1.9, you can declare data object. This is commonly used in sealed hierarchies:

sealed interface PaymentResult {
    data class Success(val transactionId: String) : PaymentResult
    data class Failed(val reason: String) : PaymentResult
    data object Pending : PaymentResult
}

fun main() {
    println(PaymentResult.Pending)
    println(PaymentResult.Success("TX-42"))
}
Pending
Success(transactionId=TX-42)

Without data, toString() prints something like PaymentResult$Pending@1b6d3586, which is hard to read in logs. data object also generates consistent equals/hashCode. This matters when the object is deserialized as a different instance.

8. Writing Tests

Tests for a factory method in a companion object:

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test

class CustomerTest {

    @Test
    fun `of normalizes email and name`() {
        val customer = Customer.of("An.Nguyen@Example.com", "  Nguyễn An ")

        assertThat(customer.email).isEqualTo("an.nguyen@example.com")
        assertThat(customer.name).isEqualTo("Nguyễn An")
    }

    @Test
    fun `of throws when email is invalid`() {
        assertThatThrownBy { Customer.of("not-an-email", "An") }
            .isInstanceOf(IllegalArgumentException::class.java)
            .hasMessageContaining("not-an-email")
    }

    @Test
    fun `companion parse handles multiple lines`() {
        val result = parseAll(Money, listOf("100 VND"))

        assertThat(result).containsExactly(Money(100, "VND"))
    }

    @Test
    fun `object declaration always returns the same instance`() {
        val first: DiscountPolicy = NoDiscount
        val second: DiscountPolicy = NoDiscount

        assertThat(first).isSameAs(second)
    }
}

These tests run independently because Customer.of and Money.parse maintain no state. With OrderIdGenerator it’s different: the counter lives for the entire JVM lifetime. A test checking next() == "ORD-1" will pass or fail depending on test execution order. This is the first pitfall in the next section.

9. Common Pitfalls and When Not to Use

Objects with mutable state. Global state makes tests depend on each other and causes issues with parallel execution. If an object needs state, extract it into a class and pass the instance where needed (dependency injection).

Note: Object declarations cannot have parameterized constructors. If a singleton depends on configuration or other services, like DataSource or a payment gateway URL, then object is not the right choice.

Using object in Spring applications. Beans in Spring are singletons by default within the ApplicationContext. Writing object PaymentService means losing constructor injection, making mocks difficult in tests, and creating two competing lifecycle management mechanisms. In this case, use @Service class PaymentService(...). Use object only for pure, dependency-free code: constants, utility functions, stateless policies.

Confusing companion object with static. A companion is a real instance. Calling Customer.of(...) is actually calling Customer.Companion.of(...). The overhead is negligible, but it explains why Java needs @JvmStatic.

Putting all utility functions in a companion. For functions not directly related to the class, a top-level function like fun formatVnd(amount: Long): String is cleaner and easier to find.

Circular dependencies during initialization. If the init block of object A reads object B, while B reads A, you might end up with null for a non-nullable property. Keep object initialization blocks simple.

10. Conclusion

The object keyword in Kotlin has three roles:

  • Object declaration creates lazy, thread-safe singletons thanks to JVM’s class initialization mechanism.
  • Object expression creates anonymous objects. Each execution of the expression creates a new instance.
  • Companion object replaces static. It can be named, implement interfaces, and receive extension functions.

When working with Java, use const val, @JvmField, and @JvmStatic to make the API look natural. The most important rule is keeping objects free of mutable state. When you need dependencies or mocking in tests, a regular class, or a Spring bean, is always the better choice.

kotlinsingletoncompanion objectobjectjvm