Skip to content

Kotlin Language Essentials

Every concept in this module is shown the TypeScript way, the Go way, and the Kotlin way side by side. You already know how to program — this module maps your existing knowledge to Kotlin syntax and idioms.

const name: string = "Alice"; // immutable binding
let age: number = 30; // mutable binding
const items: string[] = []; // const binding, but array content is mutable!
items.push("hello"); // allowed

Key Differences:

  • val = immutable reference (like const in TS). The reference cannot be reassigned, but the object it points to might still be mutable.
  • var = mutable reference (like let in TS).
  • Go has no equivalent of const for variables — everything is mutable. Go’s const only works for compile-time constants (numbers, strings, booleans).
  • Kotlin has no const keyword for local variables. const val exists but only for compile-time constants at the top level or in companion objects.

All three languages have type inference:

const name = "Alice"; // inferred as string
const age = 30; // inferred as number
const active = true; // inferred as boolean

All three look similar. The difference: Kotlin’s type system is more powerful (null safety, generics variance, reified types) while keeping the same clean inference syntax.

// No true compile-time constants; const is just immutable binding
const MAX_RETRIES = 3; // convention: SCREAMING_CASE
TypeScriptGoKotlinNotes
numberintInt32-bit signed integer
numberint64Long64-bit signed integer
numberfloat64Double64-bit floating point
numberfloat32Float32-bit floating point
booleanboolBoolean
stringstringString
string (single char)runeCharSingle character
Uint8Array[]byteByteArray
anyany / interface{}AnyTop type
voidUnit”Returns nothing meaningful”
neverNothing”Never returns”
// TypeScript has ONE number type for everything
const count: number = 42;
const price: number = 19.99;
const big: bigint = 9007199254740993n; // BigInt for large numbers

Key Differences:

  • Kotlin has distinct Int (32-bit) and Long (64-bit) types. No ambiguity like Go’s platform-dependent int.
  • Kotlin supports _ in number literals for readability: 1_000_000.
  • No implicit type conversion: val x: Long = 42 won’t compile. You need 42L or 42.toLong().
const x: number = 42;
const s: string = String(x); // "42"
const n: number = Number("42"); // 42
const f: number = parseFloat("3.14"); // 3.14

Key Differences:

  • Kotlin has no implicit numeric conversions. val l: Long = someInt won’t compile. Use someInt.toLong().
  • Every toX() has a toXOrNull() variant for safe parsing — returns null instead of throwing.
  • Go returns (value, error) tuples. Kotlin uses nullable returns or exceptions.

This is the single biggest difference you’ll notice coming to Kotlin. Kotlin’s type system distinguishes between types that can hold null and types that cannot.

// with strictNullChecks
let name: string = "Alice"; // cannot be null or undefined
let maybeName: string | null = null; // explicitly nullable
let maybeUndefined: string | undefined;
// You check with if or optional chaining
if (maybeName !== null) {
console.log(maybeName.length); // TypeScript narrows the type
}
console.log(maybeName?.length); // optional chaining
console.log(maybeName ?? "default"); // nullish coalescing
OperationTypeScriptGoKotlin
Nullable typestring | null*stringString?
Null checkif (x !== null)if x != nilif (x != null) (smart cast)
Optional chainingx?.foomanual ifx?.foo
Nullish coalescingx ?? defaultmanual ifx ?: default
Force unwrapx! (TS non-null assertion)*ptr (panics if nil)x!! (throws if null)
Optional mapx?.map(fn)manualx?.let { fn(it) }

Kotlin’s compiler tracks null checks and automatically narrows types:

fun printLength(text: String?) {
// text is String? here
if (text == null) {
println("Text is null")
return
}
// text is automatically cast to String (non-null) here!
// No need for text!! or text?.length
println("Length: ${text.length}")
}

This is similar to TypeScript’s type narrowing but more powerful:

fun describe(obj: Any): String {
// Smart cast works with type checks too
return when (obj) {
is String -> "String of length ${obj.length}" // obj is smart-cast to String
is Int -> "Integer: ${obj + 1}" // obj is smart-cast to Int
is List<*> -> "List of size ${obj.size}" // obj is smart-cast to List
else -> "Unknown: $obj"
}
}
val maybeName: String? = getName()
// !! asserts "I guarantee this is not null"
// Throws NullPointerException if it IS null
val name: String = maybeName!!
// This is equivalent to TypeScript's ! (non-null assertion):
// const name: string = maybeName!;
//
// And Go's dereference (which panics on nil):
// name := *ptr
// Pattern 1: Default values with Elvis
fun getConfig(key: String): String {
val value: String? = System.getenv(key)
return value ?: throw IllegalStateException("Missing config: $key")
}
// Pattern 2: Chain safe calls
data class Address(val city: String?, val zip: String?)
data class Company(val address: Address?)
data class User(val company: Company?)
fun getUserCity(user: User?): String {
return user?.company?.address?.city ?: "Unknown"
}
// Pattern 3: let for scoped operations on nullable values
fun processName(name: String?) {
name?.let { nonNullName ->
// nonNullName is guaranteed non-null in this block
println("Processing: ${nonNullName.uppercase()}")
saveToDatabase(nonNullName)
}
// Or more concisely:
name?.let { println("Name: ${it.uppercase()}") }
}
// Pattern 4: require / check for validation
fun createUser(name: String?, email: String?): User {
requireNotNull(name) { "Name must not be null" }
requireNotNull(email) { "Email must not be null" }
// After requireNotNull, both are smart-cast to non-null
return User(name, email)
}
const name = "Alice";
const age = 30;
console.log(`Hello, ${name}! You are ${age} years old.`);
console.log(`Next year you'll be ${age + 1}.`);

Key Differences:

  • Kotlin uses $variable for simple references (no braces needed).
  • Kotlin uses ${expression} for anything more complex (method calls, arithmetic, etc.).
  • TypeScript always uses ${...} with backticks.
  • Go uses fmt.Printf with format verbs (%s, %d) — no interpolation.
const query = `
SELECT *
FROM users
WHERE active = true
ORDER BY name
`;

Key Differences:

  • Kotlin triple-quoted strings support $variable interpolation (Go raw strings don’t).
  • trimMargin() lets you control indentation precisely with a margin character (default |).
  • trimIndent() automatically removes common leading whitespace.
val s = "Hello, Kotlin!"
// All three languages have these, syntax varies
s.length // 14 (property, not method)
s.uppercase() // "HELLO, KOTLIN!"
s.lowercase() // "hello, kotlin!"
s.trim() // remove whitespace
s.split(", ") // ["Hello", "Kotlin!"]
s.replace("Kotlin", "World") // "Hello, World!"
s.startsWith("Hello") // true
s.endsWith("!") // true
s.contains("Kotlin") // true
s.substring(0, 5) // "Hello"
s.toCharArray() // CharArray
s.repeat(3) // "Hello, Kotlin!Hello, Kotlin!Hello, Kotlin!"
s.reversed() // "!niltoK ,olleH"
// Kotlin-specific nice things
s.take(5) // "Hello" (first N chars)
s.drop(7) // "Kotlin!" (drop first N chars)
s.padStart(20, '-') // "------Hello, Kotlin!"
s.padEnd(20, '-') // "Hello, Kotlin!------"
"".ifEmpty { "default" } // "default"
" ".ifBlank { "default" } // "default"
function add(a: number, b: number): number {
return a + b;
}
// Arrow function
const add = (a: number, b: number): number => a + b;
function createUser(name: string, role: string = "viewer", active: boolean = true): User {
return { name, role, active };
}
createUser("Alice");
createUser("Bob", "admin");
createUser("Charlie", "admin", false);
// Can't skip 'role' to set 'active' without workaround

Key Differences:

  • Kotlin supports both default parameters AND named arguments. This is strictly better than TypeScript (which has defaults but no named args) and Go (which has neither).
  • Named arguments let you skip parameters and make call sites self-documenting.
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);
const evens = numbers.filter(n => n % 2 === 0);
// Multi-line
const processed = numbers.map(n => {
const doubled = n * 2;
return doubled + 1;
});

Key Differences:

  • Kotlin lambdas use { } braces (not => or func).
  • Single parameter lambdas can use it instead of declaring a parameter name.
  • The last expression in a lambda is the return value (no return keyword needed).
  • If a lambda is the last argument to a function, it can go outside the parentheses: numbers.map { it * 2 } instead of numbers.map({ it * 2 }).
// A function that takes a function as a parameter
fun operate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
val sum = operate(3, 4) { a, b -> a + b } // 7
val product = operate(3, 4) { a, b -> a * b } // 12
// Function type syntax:
// (Int, Int) -> Int means "a function that takes two Ints and returns an Int"
// () -> Unit means "a function that takes nothing and returns nothing"
// (String) -> Boolean means "a function that takes a String and returns Boolean"

Comparison of function type syntax:

TypeScriptGoKotlin
(a: number, b: number) => numberfunc(int, int) int(Int, Int) -> Int
() => voidfunc()() -> Unit
(s: string) => booleanfunc(string) bool(String) -> Boolean
// You can even declare function types with receivers (covered more in Module 03)
fun String.isLongerThan(other: String): Boolean = this.length > other.length
"hello".isLongerThan("hi") // true

In TypeScript and Go, if is a statement. In Kotlin, if is an expression (returns a value):

const status = age >= 18 ? "adult" : "minor"; // ternary

when Expression (Kotlin’s Switch on Steroids)

Section titled “when Expression (Kotlin’s Switch on Steroids)”

when replaces both switch and complex if/else chains:

// switch statement
switch (statusCode) {
case 200:
console.log("OK");
break;
case 404:
console.log("Not Found");
break;
case 500:
case 502:
case 503:
console.log("Server Error");
break;
default:
console.log("Unknown");
}
// for...of (iterating values)
for (const item of items) {
console.log(item);
}
// for with index
for (let i = 0; i < items.length; i++) {
console.log(i, items[i]);
}
// forEach
items.forEach((item, index) => console.log(index, item));

These are the same across all three languages:

// while
var count = 0
while (count < 10) {
println(count)
count++
}
// do-while
var input: String
do {
input = readLine() ?: ""
println("You entered: $input")
} while (input != "quit")

Data classes are one of Kotlin’s best features. They replace the boilerplate of creating classes that are just containers for data.

// TypeScript interfaces are lightweight
interface User {
readonly id: number;
readonly name: string;
readonly email: string;
}
// But they're just types -- no behavior, no equals/hashCode, no copy
const user: User = { id: 1, name: "Alice", email: "alice@example.com" };
const copy = { ...user, name: "Bob" }; // spread for "copy"
// Structural equality works because JS compares object properties... wait, no.
// { a: 1 } === { a: 1 } is false in JS. You need deep-equal libraries.
// Primary constructor must have at least one parameter
// All primary constructor parameters must be val or var
data class Point(val x: Double, val y: Double)
// Properties declared in the body are NOT included in equals/hashCode/toString/copy
data class User(
val id: Long,
val name: String,
) {
var loginCount: Int = 0 // NOT part of equals/hashCode/toString
}
val a = User(1, "Alice")
val b = User(1, "Alice")
a.loginCount = 5
println(a == b) // true! loginCount is not compared
OperationTypeScriptGoKotlin
Structural equalityDeep-equal library== on structs== (calls equals())
Referential equality===== on pointers===
Custom equalityOverride prototypeN/A (struct fields)Override equals()

Sealed classes/interfaces are Kotlin’s answer to discriminated unions in TypeScript and sum types in functional languages.

The Problem: Representing a Finite Set of Types

Section titled “The Problem: Representing a Finite Set of Types”
// Discriminated Union
type Result<T> =
| { kind: "success"; value: T }
| { kind: "error"; message: string }
| { kind: "loading" };
function handleResult(result: Result<string>) {
switch (result.kind) {
case "success":
console.log(`Got: ${result.value}`);
break;
case "error":
console.log(`Error: ${result.message}`);
break;
case "loading":
console.log("Loading...");
break;
}
}
// Sealed CLASS -- can have state (constructor parameters)
sealed class NetworkResponse(val code: Int) {
class Success(code: Int, val body: String) : NetworkResponse(code)
class ClientError(code: Int, val message: String) : NetworkResponse(code)
class ServerError(code: Int) : NetworkResponse(code)
}
// Sealed INTERFACE -- no state, but a class can implement multiple sealed interfaces
sealed interface Loggable {
val logMessage: String
}
sealed interface Serializable {
fun toJson(): String
}
// A class can implement multiple sealed interfaces
data class UserEvent(val userId: String, val action: String) : Loggable, Serializable {
override val logMessage get() = "User $userId performed $action"
override fun toJson() = """{"userId":"$userId","action":"$action"}"""
}
sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class HttpError(val code: Int, val message: String) : ApiResult<Nothing>
data class NetworkError(val exception: Throwable) : ApiResult<Nothing>
}
fun <T> ApiResult<T>.getOrNull(): T? = when (this) {
is ApiResult.Success -> data
is ApiResult.HttpError -> null
is ApiResult.NetworkError -> null
}
fun <T> ApiResult<T>.getOrThrow(): T = when (this) {
is ApiResult.Success -> data
is ApiResult.HttpError -> throw RuntimeException("HTTP $code: $message")
is ApiResult.NetworkError -> throw exception
}
// Usage
fun fetchUser(id: Long): ApiResult<User> {
return try {
val user = httpClient.get("/users/$id")
ApiResult.Success(user)
} catch (e: HttpException) {
ApiResult.HttpError(e.code, e.message ?: "Unknown error")
} catch (e: Exception) {
ApiResult.NetworkError(e)
}
}
val result = fetchUser(42)
when (result) {
is ApiResult.Success -> println("User: ${result.data}")
is ApiResult.HttpError -> println("HTTP ${result.code}: ${result.message}")
is ApiResult.NetworkError -> println("Network error: ${result.exception.message}")
}
enum Direction {
North = "NORTH",
South = "SOUTH",
East = "EAST",
West = "WEST",
}
// Or as const object (preferred by many TS devs)
const Direction = {
North: "NORTH",
South: "SOUTH",
East: "EAST",
West: "WEST",
} as const;
type Direction = typeof Direction[keyof typeof Direction];
enum class HttpStatus(val code: Int, val description: String) {
OK(200, "OK"),
NOT_FOUND(404, "Not Found"),
INTERNAL_SERVER_ERROR(500, "Internal Server Error"),
BAD_GATEWAY(502, "Bad Gateway");
// Enums can have methods
fun isSuccess(): Boolean = code in 200..299
fun isClientError(): Boolean = code in 400..499
fun isServerError(): Boolean = code in 500..599
}
val status = HttpStatus.NOT_FOUND
println("${status.code}: ${status.description}") // 404: Not Found
println(status.isClientError()) // true
// Iterate all values
HttpStatus.entries.forEach { println("${it.code}: ${it.name}") }
// Parse from string
val parsed = HttpStatus.valueOf("OK") // HttpStatus.OK
// Safe parse
val maybeParsed = HttpStatus.entries.find { it.name == "UNKNOWN" } // null
Use Enum WhenUse Sealed When
Fixed set of simple constantsEach variant carries different data
All variants have same shapeVariants have different properties
Like Go iota constantsLike TS discriminated unions
Direction.NORTHResult.Success(data) vs Result.Error(msg)
// Singleton pattern
class Logger {
private static instance: Logger;
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string): void {
console.log(`[LOG] ${message}`);
}
}
Logger.getInstance().log("Hello");
// Or simpler: just export an object
export const logger = {
log(message: string) { console.log(`[LOG] ${message}`); }
};

Key Differences:

  • Kotlin’s object is a first-class language feature. Thread-safe, lazy-initialized, one line.
  • TypeScript needs the singleton pattern or module-level const.
  • Go uses package-level variables.
class User {
constructor(public name: string, public email: string) {}
// Static method
static fromJson(json: string): User {
const data = JSON.parse(json);
return new User(data.name, data.email);
}
// Static property
static readonly MAX_NAME_LENGTH = 100;
}
const user = User.fromJson('{"name":"Alice","email":"alice@example.com"}');

Key Differences:

  • Kotlin has no static keyword. Companion objects are actual objects (can implement interfaces).
  • A class can have at most one companion object.
  • You can name companion objects: companion object Factory { ... } and reference as User.Factory.
  • Companion objects can implement interfaces — this is powerful for factory patterns.
interface JsonParser<T> {
fun fromJson(json: String): T
}
data class User(val name: String, val email: String) {
companion object : JsonParser<User> {
override fun fromJson(json: String): User {
// parse logic here
return User("Alice", "alice@example.com")
}
}
}
// Now you can pass User (the companion) where JsonParser<User> is expected
fun <T> loadFromFile(path: String, parser: JsonParser<T>): T {
val json = java.io.File(path).readText()
return parser.fromJson(json)
}
val user = loadFromFile("user.json", User) // User companion IS a JsonParser<User>

Extension functions let you add methods to existing classes without modifying them.

// Declaration Merging / Prototype Extension
// Extending String prototype (generally considered bad practice in TS)
declare global {
interface String {
toSlug(): string;
}
}
String.prototype.toSlug = function(): string {
return this.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
};
"Hello World!".toSlug(); // "hello-world"

Key Differences:

  • Kotlin extensions are resolved statically (at compile time). They don’t modify the class.
  • TypeScript prototype extensions are runtime modifications — fragile and affect all instances.
  • Go simply can’t do this with external types.
  • Kotlin extensions are the idiomatic way to add utility methods. The standard library is full of them (String.trim(), List.map(), etc.).
// Extension on nullable types
fun String?.orEmpty(): String = this ?: ""
// Extension property
val String.isEmail: Boolean
get() = this.matches(Regex("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"))
// Extension on generic types
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null
// Extension on your own types
data class Money(val amount: Long, val currency: String)
operator fun Money.plus(other: Money): Money {
require(currency == other.currency) { "Cannot add $currency and ${other.currency}" }
return Money(amount + other.amount, currency)
}
val total = Money(100, "USD") + Money(200, "USD") // Money(300, "USD")
type UserId = string;
type Handler = (req: Request, res: Response) => void;
type StringMap = Record<string, string>;

Key Differences:

  • Go’s type aliases create a new distinct type (you can add methods to it).
  • Kotlin and TypeScript type aliases are transparent — they’re just shorthand.
  • Kotlin typealias is especially useful for complex generic types and function types.

Ranges are a first-class Kotlin feature with no direct equivalent in TypeScript or Go.

// Integer ranges
val oneToTen = 1..10 // 1, 2, 3, ..., 10 (inclusive)
val zeroToNine = 0 until 10 // 0, 1, 2, ..., 9 (exclusive end)
val countdown = 10 downTo 1 // 10, 9, 8, ..., 1
val evens = 0..100 step 2 // 0, 2, 4, ..., 100
// Check membership
println(5 in 1..10) // true
println(15 in 1..10) // false
println(15 !in 1..10) // true
// Character ranges
val letters = 'a'..'z'
println('g' in letters) // true
// String ranges (lexicographic)
println("kotlin" in "java".."scala") // true
// Use in when expressions
fun classify(score: Int): String = when (score) {
in 90..100 -> "A"
in 80..89 -> "B"
in 70..79 -> "C"
in 60..69 -> "D"
else -> "F"
}
// Use in for loops
for (i in 1..5) print("$i ") // 1 2 3 4 5
for (i in 5 downTo 1) print("$i ") // 5 4 3 2 1
for (i in 0 until 10 step 3) print("$i ") // 0 3 6 9

Comparison: how you’d do this in TS/Go:

// No built-in ranges. Use loops or libraries.
for (let i = 1; i <= 10; i++) { /* ... */ }
Array.from({length: 10}, (_, i) => i + 1) // [1, 2, ..., 10]
// Check membership: manual
const inRange = (n: number) => n >= 1 && n <= 10;
TypeScriptGoKotlin
Exceptions (throw/try/catch)Error values (val, err)Exceptions (throw/try/catch)
Untyped (catch (e))Typed (error interface)Typed (catch (e: Type))
Unchecked onlyNo exceptionsUnchecked only (unlike Java!)
try {
const data = JSON.parse(input);
processData(data);
} catch (error) {
if (error instanceof SyntaxError) {
console.error("Invalid JSON:", error.message);
} else {
throw error; // re-throw unexpected errors
}
}
// try/catch returns a value (like if/when)
val number: Int = try {
"42x".toInt()
} catch (e: NumberFormatException) {
0 // default value on error
}
// Common pattern: parse with fallback
val port = try { System.getenv("PORT").toInt() } catch (_: Exception) { 8080 }

Go developers are used to checking errors at every call. Kotlin uses exceptions like TypeScript, but provides safer patterns:

// Go-style: use nullable returns instead of exceptions
val number: Int? = "42x".toIntOrNull() // null instead of throwing
val file: String? = runCatching { File("config.json").readText() }.getOrNull()
// Result type (like Go's (value, error) but as a single type)
val result: Result<Int> = runCatching { riskyOperation() }
result.isSuccess // true/false
result.isFailure // true/false
result.getOrNull() // value or null
result.getOrDefault(0) // value or default
result.getOrElse { error -> handleError(error); 0 }
result.fold(
onSuccess = { value -> println("Got: $value") },
onFailure = { error -> println("Error: ${error.message}") }
)

require, check, error — Precondition Functions

Section titled “require, check, error — Precondition Functions”
// require -- throws IllegalArgumentException (for argument validation)
fun createUser(name: String, age: Int): User {
require(name.isNotBlank()) { "Name must not be blank" }
require(age in 0..150) { "Age must be between 0 and 150, got $age" }
return User(name, age)
}
// check -- throws IllegalStateException (for state validation)
fun process() {
check(isInitialized) { "System must be initialized first" }
// ... process
}
// error -- throws IllegalStateException (for "this should never happen")
fun handleStatus(code: Int): String = when (code) {
200 -> "OK"
404 -> "Not Found"
else -> error("Unexpected status code: $code")
}

Put the language features to work — model and validate real configuration with data classes, sealed types, and null-safe parsing.