Basic Types
Every value in a Kotlin program has a type: whole numbers, decimal numbers, true/false flags, single characters, and text all have their own dedicated type. Kotlin’s basic types look similar to Java’s primitives at first glance, but they behave differently in important ways — they are full objects with member functions, they never convert into each other implicitly, and every one of them is non-null by default. Understanding these types precisely is the foundation for everything else you’ll write in Kotlin, from simple arithmetic to null-safe APIs.
Overview: How Kotlin’s Type System Works
In Java, int, double, and boolean are primitive types that live separately from their boxed wrapper classes (Integer, Double, Boolean). Kotlin removes that split at the language level: Int, Double, and Boolean are ordinary types with methods you can call on them, like 5.toString() or 3.14.roundToInt(). Under the hood, the Kotlin compiler still emits efficient JVM primitives (int, double, boolean) wherever it safely can — it only boxes a value into an object (using classes like java.lang.Integer) when it must, for example when the type is nullable (Int?) or stored in a generic collection like List<Int>. You get object-oriented convenience without paying a performance tax in the common case.
Every basic type also comes with a nullable counterpart, written with a trailing ?: Int can never hold null, but Int? can. This split is the basis of Kotlin’s compile-time null safety, covered in depth in its own lesson — for now, just know that every type shown here is non-null unless you explicitly add ?.
Kotlin is also strongly and statically typed with excellent type inference. You rarely need to write out a type explicitly, because the compiler figures it out from the value on the right-hand side of =. val age = 30 infers Int; val price = 19.99 infers Double. Crucially, once a type is inferred or declared, it is fixed — there is no automatic (“implicit”) widening between numeric types the way Java silently promotes an int to a long. Every numeric conversion in Kotlin must be written out explicitly, which the compiler enforces and which prevents a whole class of silent precision-loss bugs.
Syntax
The general form for declaring a typed value is:
val name: Type = value
var name: Type = value // only when the value needs to change
val name = value // type inferred from value
| Type | Size | Range / Notes |
|---|---|---|
Byte |
8-bit | -128 to 127 |
Short |
16-bit | -32768 to 32767 |
Int |
32-bit | -2147483648 to 2147483647; the default type for whole-number literals |
Long |
64-bit | Roughly ±9.2 quintillion; literals need an L suffix, e.g. 10_000_000_000L |
Float |
32-bit | ~6–7 significant decimal digits; literals need an f suffix, e.g. 3.14f |
Double |
64-bit | ~15–16 significant decimal digits; the default type for decimal literals |
Boolean |
logical | true or false, no other values allowed |
Char |
16-bit | A single UTF-16 character, written with single quotes: 'A' |
String |
— | An immutable sequence of Char, written with double quotes: "text" |
Large numeric literals can use underscores as visual separators, which the compiler simply ignores: 1_000_000 means the same thing as 1000000.
Examples
Example 1: Declaring values with inferred and explicit types
fun main() {
val age: Int = 30
val price = 19.99 // inferred as Double
val isActive = true // inferred as Boolean
val grade: Char = 'A'
val name = "Kotlin" // inferred as String
println("age = $age (Int)")
println("price = $price (Double)")
println("isActive = $isActive (Boolean)")
println("grade = $grade (Char)")
println("name = $name (String)")
}
Output:
age = 30 (Int)
price = 19.99 (Double)
isActive = true (Boolean)
grade = A (Char)
name = Kotlin (String)
Two values here use an explicit type annotation (age: Int and grade: Char), while the rest let the compiler infer the type from the literal on the right. Both styles produce the exact same compiled code — the annotation is purely for the compiler (and for readers), it has no runtime cost.
Example 2: Explicit numeric conversions
fun main() {
val smallNumber: Int = 42
val bigNumber: Long = smallNumber.toLong()
val asDouble: Double = smallNumber.toDouble()
val asByte: Byte = smallNumber.toByte()
println("Int: $smallNumber")
println("Long: $bigNumber")
println("Double: $asDouble")
println("Byte: $asByte")
val a = 10
val b = 3
println("Integer division: ${a / b}")
println("Double division: ${a.toDouble() / b}")
}
Output:
Int: 42
Long: 42
Double: 42.0
Byte: 42
Integer division: 3
Double division: 3.3333333333333335
Every numeric type provides conversion functions like toLong(), toDouble(), and toByte() — there is no automatic widening, so you always convert explicitly. Notice also the classic division gotcha: a / b with two Int values performs integer division and truncates to 3, while converting one operand to Double first produces the precise result 3.3333333333333335.
Example 3: Char and String basics
fun main() {
val initial: Char = 'K'
val language = "Kotlin"
val version = 2.0
println("Language: $language, version $version")
println("First letter: $initial")
println("Length of name: ${language.length}")
println("Uppercase: ${language.uppercase()}")
val multiline = """
Line one
Line two
""".trimIndent()
println(multiline)
}
Output:
Language: Kotlin, version 2.0
First letter: K
Length of name: 6
Uppercase: KOTLIN
Line one
Line two
String templates ($name and ${expression}) let you interpolate values directly into a string without concatenation. Triple-quoted strings ("""...""") preserve line breaks for multi-line text, and trimIndent() strips the common leading whitespace so the output isn’t indented to match your source code.
How It Works Step by Step
When the compiler processes a line like val price = 19.99, it first evaluates the literal 19.99 and determines its type from context: a decimal literal defaults to Double unless it carries an f/F suffix (which makes it Float) or the target type is already known to be Float from a declared type annotation. Whole-number literals default to Int, are promoted to Long automatically only if they don’t fit in 32 bits, or become Long if they carry an L suffix or the target type calls for it. Once that type is fixed, it is baked into the variable for its entire lifetime — there is no step at which Kotlin will later coerce an Int into a Long for you; every later use that needs a different type must call an explicit conversion function such as .toLong(). This single design decision is why Kotlin code almost never suffers the silent precision-loss bugs that plague languages with implicit numeric coercion.
Common Mistakes
Mistake 1: Assuming Int converts to Long automatically
val i: Int = 42
val l: Long = i // Type mismatch: inferred type is Int but Long was expected
Unlike Java, Kotlin never widens an Int into a Long implicitly, even though every Int value fits inside a Long. You must convert explicitly:
val i: Int = 42
val l: Long = i.toLong()
Mistake 2: Not expecting Int to overflow silently
fun main() {
val max = Int.MAX_VALUE
val overflowed = max + 1
println(overflowed)
}
Output:
-2147483648
The compiler does not catch integer overflow — arithmetic on Int simply wraps around to the minimum value once it exceeds the maximum, exactly like Java’s int. If a computation might exceed roughly 2.1 billion, use Long from the start rather than discovering the wraparound at runtime.
Mistake 3: Mixing up Char and String quoting
val letter: Char = "A" // error: a String literal cannot be assigned to Char
Char literals always use single quotes and hold exactly one character; String literals always use double quotes. The two are not interchangeable, even for one-character text:
val letter: Char = 'A'
Best Practices
- Prefer
valovervarfor every basic-type value unless it genuinely needs to be reassigned. - Let the compiler infer obvious types (
val count = 0); add an explicit type annotation when it improves clarity for a reader, such as on public function signatures or class properties. - Default to
Intfor whole numbers andDoublefor decimals — only reach forLong,Short,Byte, orFloatwhen you have a specific reason (range, memory footprint, or interop requirement). - Use underscores in long numeric literals for readability:
1_000_000instead of1000000. - Always convert numeric types explicitly with functions like
toInt(),toDouble(), andtoLong()— never rely on implicit coercion, because Kotlin doesn’t have any. - Watch for integer overflow in arithmetic on
Int; switch toLongproactively if a sum or product could exceed roughly 2.1 billion. - Use string templates (
"$name","${expr}") instead of string concatenation with+for both clarity and performance.
Practice Exercises
- Write a program that declares a
Floatand aDoubleboth holding the value1f / 3fand1.0 / 3.0respectively, then prints both. Compare how many digits of precision each one shows. - Declare a
Doubleequal to9.99, convert it toIntwith.toInt(), and print the result. Predict the output before you run it — does it round or truncate? - Write a program that computes the average of three
Intexam scores (85,90,78) as aDouble, avoiding the integer-division bug shown in Example 2. Expected output:84.33333333333333.
Summary
- Kotlin’s basic types —
Byte,Short,Int,Long,Float,Double,Boolean,Char, andString— are full objects, not bare primitives, though the compiler still uses efficient JVM primitives under the hood when possible. - Every basic type is non-null by default; a trailing
?(likeInt?) is required to allownull. - Type inference fills in the type from a literal or expression, but once fixed, a type never changes on its own — there is no implicit numeric widening in Kotlin.
- Numeric conversions must be explicit, using functions like
toInt(),toLong(), andtoDouble(). Intarithmetic can silently overflow and wrap around; useLongwhen values might exceed the 32-bit range.Charuses single quotes for exactly one character;Stringuses double quotes (or triple quotes for multi-line text) for text of any length.
