From TypeScript to Kotlin: Building Web APIs
Introduction
If you’re building server-side (API) applications in TypeScript, switching to Kotlin for web development is a viable option. Kotlin, a modern JVM language, offers a robust type system, concise syntax, and seamless interoperability with Java. This post assumes you’re familiar with TypeScript and Node.js and want to explore Kotlin for server-side development.
This post explores the transition from TypeScript to Kotlin, highlighting key differences in typing, data structures, control flow, concurrency, dependency management, and popular server frameworks. It is not meant to be a comprehensive guide but rather a starting point for TypeScript developers looking to explore Kotlin for web backends, or just serve as a quick reference whenever you are context switching. The post is also opinionated and based on my personal experience with both languages. It will only touch the surface of most topics, so you can dive deeper into the Kotlin/relevant tooling documentation for more details.
The post can be read in its entirety or as a reference guide for specific topics. Hopefully it will help you get started with Kotlin and build your first Kotlin API!
Data modeling
TypeScript’s primary goal is to bring type safety to JavaScript. Likewise, Kotlin brings strong static typing to the JVM, but with fewer syntactic overheads and more built-in data-modeling features like data class and sealed class. To illustrate the differences, let’s compare the type systems and generics in Kotlin and TypeScript using an example.
Primitive Types
Primitive Type | TypeScript | Kotlin | Notes |
---|---|---|---|
Numeric | number | Int , Long , Float , Double | TypeScript has a single number type, whereas Kotlin splits into multiple numeric types for precision and overflow safety. |
String | string | String | Both treat strings as immutable sequences of characters, but Kotlin uses backticks or "$variable" for templating. |
Boolean | boolean | Boolean | Both languages use true or false . |
Character | No distinct type | Char | TypeScript treats characters as 1-length strings. Kotlin has a separate Char type. |
Big Integer | bigint | No direct built-in | Kotlin relies on libraries like BigInteger from the JVM. |
Null/Undefined | null , undefined | Explicit ? for nullability | TypeScript has two separate primitives; Kotlin uses String? , Int? , etc. to indicate nullability. |
Symbol | symbol | No direct equivalent | Symbol is a unique, immutable identifier in TS. In Kotlin, you’d generally rely on different design patterns. |
Variable declaration
TypeScript uses let
and const
for variable declarations, with explicit type annotations using : Type
. const variables are immutable, while let
variables can be reassigned.
const birthYear: string = '1996'
let favoriteNumber: number = 15
Kotlin uses val
for immutable variables and var
for mutable variables. Type annotations are done in the same way. val
variables are immutable, while var
variables can be reassigned.
val birthYear: String = "1996"
var favoriteNumber: Int = 15
Data structures
Data Structure | TypeScript | Kotlin |
---|---|---|
Array | Array | Array, List |
Tuple | Tuple | Pair, Triple |
Set | Set | Set |
Map | Map | Map |
Record | Record | Map or data class |
Object | Object literal | data class |
Array
TypeScript arrays are mutable by default. You can use number[]
or Array<number>
for typed arrays. TypeScript also supports immutable arrays using ReadonlyArray
.
// TypeScript Arrays (mutable by default)
const numbersArray: number[] = [1, 2, 3]
// or
const numbersArray2: Array<number> = [1, 2, 3]
// Immutable array
const immutableNumbersArray: ReadonlyArray<number> = [1, 2, 3]
Kotlin differentiates built-in arrays (mutable by nature) from high-level list types (often immutable by default).
// Kotlin Arrays (mutable by default)
val numbersArray: Array<Int> = arrayOf(1, 2, 3)
// Immutable list
val immutableList: List<Int> = listOf(1, 2, 3)
// Mutable list
val mutableList: MutableList<Int> = mutableListOf(1, 2, 3)
Tuple
TypeScript supports fixed-length, position-based tuples.
let pair: [string, number] = ['Age', 30]
Kotlin doesn’t have a dedicated tuple type but uses Pair
or Triple
for quick multi-value structures.
// Kotlin doesn't have built-in tuples,
// but Pair or Triple are commonly used
val pair = Pair("Age", 30)
// Or using 'to' infix function:
val pairUsingTo = "Age" to 30
// Triple
val (years, month, days) = Triple(30, 3, 12)
Set
TypeScript has a built-in Set
class for unique collections of values.
const uniqueNumbers: Set<number> = new Set([1, 2, 3])
uniqueNumbers.add(4)
Kotlin offers both setOf
for immutable sets and mutableSetOf
for mutable sets.
// Immutable set
val uniqueNumbers = setOf(1, 2, 3)
// Mutable set
val mutableNumbers = mutableSetOf(1, 2, 3)
mutableNumbers.add(4)
Map
TypeScript uses the built-in Map
class for key-value pairs.
const myMap: Map<string, number> = new Map([
['Alice', 1],
['Bob', 2],
])
myMap.set('Charlie', 3)
Kotlin emphasizes immutability with separate immutable and mutable map interfaces.
// Immutable map
val myMap = mapOf("Alice" to 1, "Bob" to 2)
// Mutable map
val mutableMap = mutableMapOf("Alice" to 1, "Bob" to 2)
mutableMap["Charlie"] = 3
Record
TypeScript’s Record<K, T>
is handy for typed key-value objects. It's often used when type strictness is required, or when you want easy serialization/deserialization.
type ScoreBoard = Record<string, number>
const scores: ScoreBoard = {
Alice: 10,
Bob: 15,
}
Kotlin often pairs data class
+ Map
for similar use cases. Data classes
provide built-in type strictness and serialization/deserialization.
// Kotlin often pairs data class + Map
data class ScoreBoard(val scores: Map<String, Int>)
val myScores = ScoreBoard(
mapOf("Alice" to 10, "Bob" to 15)
)
Object / Data Class
TypeScript frequently uses inline object literals.
const user = {
name: 'Alice',
age: 30,
}
// or a Class
class User {
constructor(
public name: string,
public age: number,
) {}
}
Kotlin recommends data class
for built-in equality, toString()
, hashCode()
, and easy copying.
data class User(val name: String, val age: Int)
// usage
val user = User("Alice", 30)
Interfaces, union types, enums and generics
TypeScript uses interfaces, union types, and enums (though it is recommended to avoid this built-in feature) to model data structures and define custom types. Kotlin offers similar features, but with more concise syntax and additional constructs like sealed classes. Both languages support generics for writing reusable, type-safe code across different data types. To illustrate the differences in data modeling between TypeScript and Kotlin, let’s consider a simple example of fetching data from an API. We’ll compare the use of interfaces, union types, data classes, and sealed classes in both languages.
TypeScript Example: Interfaces and Union types
export class Status {
static readonly SUCCESS = new Status('SUCCESS')
static readonly ERROR = new Status('ERROR')
private constructor(private readonly statusType: string) {}
toString() {
return this.statusType
}
}
interface ApiResponse<T> {
data: T
status: Status
}
function fetchData<T>(url: string): Promise<ApiResponse<T>> {
return new Promise((resolve) => {
const exampleData = {} as T
resolve({ data: exampleData, status: 'SUCCESS' })
})
}
- In order to provide objects in an "enum-like" way such as in
Status
, you need to create a class with static properties and it requires a little bit of a workaround. - Interface-Oriented (ApiResponse
<T>
) is typical for shaping the structure of data. - Generics:
<T>
placeholders let you keep logic flexible across different data types.
Kotlin Example: Interfaces and Union types: Data classes and Sealed classes
sealed class Status {
object SUCCESS : Status()
object ERROR : Status()
}
data class ApiResponse<T>(
val data: T,
val status: Status
)
fun <T> fetchData(url: String): ApiResponse<T> {
val exampleData: T = Any() as T
return ApiResponse(
data = exampleData,
status = Status.SUCCESS
)
}
- Sealed Classes: Provide an easy way to represent fixed hierarchies (like union types in TypeScript), but in a more structured, object-oriented manner.
- Data Classes: Automatically generate
equals()
,hashCode()
,toString()
, andcopy()
, reducing boilerplate. - Concise Constructors: You define properties
(val data: T, val status: Status)
right in the data class declaration.
Inheritance
TypeScript extends JavaScript’s prototypical inheritance with classical-like syntax. You can use class
and extends
to create subclasses and superclasses.
class Animal {
constructor(public name: string) {}
makeSound(): void {
console.log('Generic animal sound!')
}
}
class Dog extends Animal {
constructor(name: string) {
super(name)
}
makeSound(): void {
console.log('Bark! Bark!')
}
}
Kotlin classes are final by default. You must mark a class as open to allow subclasses. It uses open
for superclass and override
for subclass methods.
open class Animal(val name: String) {
open fun makeSound() {
println("Generic animal sound!")
}
}
class Dog(name: String) : Animal(name) {
override fun makeSound() {
println("Bark! Bark!")
}
}
Key differences
Feature | TypeScript | Kotlin |
---|---|---|
Single vs. Multiple Inheritance | Classes can extend exactly one base class and implement multiple interfaces. | Classes can also extend exactly one parent class. Kotlin supports multiple interfaces as well, but no multiple class inheritance—just like TypeScript. |
Open vs. Final | Open by default: All classes can be subclassed unless explicitly restricted (e.g., using private constructors or advanced patterns). | Final by default: Classes and methods must be marked with open to allow subclassing or overriding. Encourages composition over inheritance, making code more predictable and reducing accidental overrides. |
Interfaces | Define method signatures and optional properties. TypeScript supports intersection types and interface merging, but interfaces cannot contain default method implementations. | Interfaces can declare both abstract methods and default method implementations. They can also contain properties (val or var ). This allows code reuse without requiring multiple inheritance of classes. |
Abstract Classes | abstract class cannot be instantiated. Subclasses must implement abstract methods, but can also inherit concrete (non-abstract) ones. | Same concept: abstract class can’t be instantiated. It may define abstract members that subclasses must implement, along with concrete members that are inherited directly. |
Sealed Classes | Not natively supported. Developers often simulate sealed classes using union types or enums. However, these don’t provide the same compile-time exhaustiveness checks as Kotlin. | Sealed classes let you restrict a class hierarchy to one file, guaranteeing a fixed set of subclasses. This allows exhaustive when checks, removing the need for default branches when all cases are covered. |
Loops and control flow
For any server-side application, you’ll handle various conditional branches—like checking if a user is authenticated, verifying request parameters, or deciding how to respond to different error types. Understanding how TypeScript and Kotlin handle if/else, switch/when, and loops sets the stage for writing clean, maintainable API code.
Loops
Both TypeScript and Kotlin support for
loops, while
loops, and do-while
loops. The syntax is quite similar, with TypeScript using for...of
for iterating over arrays and Kotlin using for
for ranges and collections.
// ========================
// TypeScript loop examples
// ========================
// Standard for loop
for (let i = 0; i < items.length; i++) {
console.log(items[i])
}
// for...of
for (const item of items) {
console.log(item)
}
// for...in (object keys)
for (const key in obj) {
console.log(key, obj[key])
}
// Higher-order functions
items.forEach((item) => console.log(item))
// ====================
// Kotlin loop examples
// ====================
// Standard for loop
for (i in items.indices) {
println(items[i])
}
// Direct iteration
for (item in items) {
println(item)
}
// Range-based loop
for (i in 0..10) {
println(i)
}
// Higher-order functions
items.forEach { item ->
println(item)
}
Conditional Statements
If/Else
TypeScript and Kotlin have a very similar syntax for if/else
-statements. Both languages use curly braces for blocks, and you can omit braces for single-line expressions. Here’s a comparison of if/else in TypeScript and Kotlin:
// ==========================
// TypeScript if/else example
// ==========================
function greetUser(userRole: string): void {
if (userRole === 'admin') {
console.log('Welcome, Admin!')
} else if (userRole === 'user') {
console.log('Hello, User!')
} else {
console.log('Unknown role')
}
}
// ======================
// Kotlin if/else example
// ======================
fun greetUser(userRole: String) {
if (userRole == "admin") {
println("Welcome, Admin!")
} else if (userRole == "user") {
println("Hello, User!")
} else {
println("Unknown role")
}
}
Switch/When
TypeScript’s switch
statement is a simple, straightforward way to handle multiple branches based on a single value.
// =========================
// TypeScript switch example
// =========================
switch (userRole) {
case 'admin':
console.log('Welcome, Admin!')
break
case 'user':
console.log('Hello, User!')
break
default:
console.log('Unknown role')
}
Kotlin’s when
expression is a more powerful, flexible version of TypeScript’s switch
statement. You can use when
as an expression (like a ternary operator) or as a statement (like a switch-case).
// ===================
// Kotlin when example
// ===================
when (userRole) {
"admin" -> println("Welcome, Admin!")
"user" -> println("Hello, User!")
else -> println("Unknown role")
}
Functions
Functions are the building blocks of any server-side application. TypeScript and Kotlin offer a wide range of function types, including arrow functions, higher-order functions, and lambda expressions. Understanding how functions work in both languages is crucial for writing clean, maintainable code.
Function Declaration
Both TypeScript and Kotlin support named functions, anonymous functions, and arrow functions. TypeScript uses function
for named functions and arrow functions for concise, inline expressions. Kotlin uses fun
for function declarations and supports lambda expressions for functional programming.
// ============================
// TypeScript function examples
// ============================
// Named function
function greetUser(name: string): string {
return `Hello, ${name}!`
}
// Arrow function
const greetUserArrow = (name: string): string => `Hello, ${name}!`
// Single-expression function
const greetUserSingle = (name: string) => `Hello, ${name}!`
// Higher-order function
function repeatMessage(
message: string,
times: number,
callback: (msg: string) => void,
) {
for (let i = 0; i < times; i++) {
callback(message)
}
}
// ============================
// Kotlin function examples
// ============================
// Named function
fun greetUser(name: String): String {
return "Hello, $name!"
}
// Arrow function
val greetUserArrow = { name: String -> "Hello, $name!" }
// Single-expression function
fun greetUserSingle(name: String) = "Hello, $name!"
// Higher-order function
fun repeatMessage(
message: String,
times: Int,
callback: (String) -> Unit
) {
repeat(times) {
callback(message)
}
}
Asynchronous execution: Promises vs Coroutines
In TypeScript, you often use Promises for asynchronous operations. Kotlin, on the other hand, offers coroutines for lightweight, non-blocking concurrency. Here’s a comparison of asynchronous execution in TypeScript and Kotlin:
Simple fetch
Example of fetching data from a URL using Promises in TypeScript and coroutines in Kotlin, with one single asynchronous operation.
// =======================================
// TypeScript simple promise fetch example
// =======================================
function fetchData(url: string): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.2) {
resolve(`Data from ${url}`)
} else {
reject('Network error')
}
}, 500)
})
}
async function main() {
try {
const data = await fetchData('https://example.com')
console.log(data)
} catch (err) {
console.error('Error:', err)
}
}
main()
import kotlinx.coroutines.*
// =====================================
// Kotlin simple coroutine fetch example
// =====================================
suspend fun fetchData(url: String): String {
delay(500)
if (Math.random() > 0.2) {
return "Data from $url"
} else {
throw Exception("Network error")
}
}
fun main() = runBlocking {
try {
val data = fetchData("https://example.com")
println(data)
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
Parallel operations
In server-side applications, you often need to run multiple asynchronous operations concurrently without blocking the main thread. This is often the case when fetching data from multiple sources, fetching data independently or performing CPU-intensive tasks.
TypeScript uses Promise.all
to run multiple asynchronous operations concurrently. You can pass an array of promises to Promise.all
and wait for all promises to resolve. If any promise rejects, the entire operation fails.
function fetchUser(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => resolve('User data'), 300)
})
}
function fetchOrders(): Promise<string> {
return new Promise((resolve) => {
setTimeout(() => resolve('Orders data'), 500)
})
}
// Parallel with Promise.all
Promise.all([fetchUser(), fetchOrders()])
.then(([userData, ordersData]) => {
console.log('All results:', userData, ordersData)
})
.catch((err) => {
console.error('Error in parallel tasks:', err)
})
Kotlin uses async
and await
to run multiple coroutines concurrently. You can use async
to start a coroutine and await
to wait for the result. The coroutineScope
function is used to create a new coroutine scope, which allows you to run multiple coroutines concurrently.
import kotlinx.coroutines.*
suspend fun fetchUser(): String {
delay(300)
return "User data"
}
suspend fun fetchOrders(): String {
delay(500)
return "Orders data"
}
fun main() = runBlocking {
val result = doParallel()
println("All results: $result")
}
// Parallel with stored result using async await
suspend fun doParallel(): String = coroutineScope {
val userDeferred = async { fetchUser() }
val ordersDeferred = async { fetchOrders() }
val userData = userDeferred.await()
val ordersData = ordersDeferred.await()
"$userData, $ordersData"
}
Dependency management
Dependency management is a crucial aspect of server-side development. Both TypeScript and Kotlin offer package managers (npm and Gradle, respectively) to manage project dependencies. Understanding how to add, update, and remove dependencies is essential for building robust, maintainable server-side applications.
Npm vs Gradle
npm is the default package manager for JavaScript and TypeScript projects. It’s widely used for frontend and backend development, with a vast ecosystem of open-source packages. You can use npm to install, update, and remove dependencies, as well as manage project scripts and configurations.
Gradle is a build automation tool and dependency management system for JVM-based projects. It’s commonly used for Java, Kotlin, and Groovy projects, with support for building, testing, and packaging applications. Gradle uses a Groovy or Kotlin DSL to define project configurations, dependencies, and tasks.
Feature | npm (Node Package Manager) | Gradle |
---|---|---|
Installation | npm install <package> for local dependencies, -g for global. Automatically creates or updates a package.json file. | Declare dependencies in build.gradle (Groovy) or build.gradle.kts (Kotlin DSL). Run gradle build or gradle assemble . |
Configuration File | package.json stores scripts, dependencies, dev dependencies, version, etc. | build.gradle / build.gradle.kts defines plugins, dependencies, repositories, and tasks. |
Dependency Locking | package-lock.json (or npm-shrinkwrap.json) ensures reproducible installs. | Gradle’s lock files (dependency locking) can ensure consistent builds. Or you can rely on the Gradle cache for artifact version resolution. |
Repository/Registry | Fetches packages from the npm registry by default. You can configure private registries or GitHub packages. | Fetches artifacts from Maven Central, jcenter, or custom Maven/Ivy repositories. Gradle can also cache dependencies locally. |
Script/Task Management | Scripts in package.json (e.g., "start" , "test" ) to run commands. Additionally, npm run triggers defined scripts. | Gradle tasks (like test , assemble , publish ) can be extended or created. You can define custom tasks in Gradle’s DSL. |
Plugins | The npm ecosystem has a vast library of CLI tools and modules that can be installed globally or locally. | Gradle uses plugins (e.g., java , kotlin , application , spring-boot ) that automatically configure tasks and dependencies. |
Versioning & Updates | Tools like npm outdated , npm update , or npm-check-updates to manage versions. SemVer is the norm, and peer dependencies are declared for shared packages. | Use gradlew dependencies or ./gradlew dependencyUpdates (with the Gradle Versions Plugin) to see available updates. |
Build Artifacts | Typically not applicable in Node-based apps—bundlers like webpack or rollup handle packaging if needed. | JAR or WAR files (Java-based), or Fat/Shadow JAR for self-contained apps. Gradle can produce multiple build artifacts with different configurations. |
Performance | Modern npm versions have improved caching, but large monorepos may use tools like pnpm or Yarn for better disk usage and speed. | Gradle maintains a local cache of downloaded artifacts. The Gradle Daemon keeps services running to speed up subsequent builds. |
Ecosystem | Extensive JavaScript/TypeScript ecosystem in the npm registry. Tools like Yarn, pnpm, Nx for monorepos, etc. | Huge JVM ecosystem, used by Java, Kotlin, Groovy, Scala projects, etc. Integration with Docker, Kubernetes, or popular frameworks like Spring Boot, Ktor, and Micronaut. |
Example: Adding a new package
Adding a new package to your project is a common task in both TypeScript and Kotlin projects. Let’s walk through the steps to add a new package using npm and Gradle. We'll use a hypothetical package called example-package
as an example.
npm (TypeScript)
- Install the package using
npm install example-package
. - The package is added to your
package.json
file underdependencies
. - You can now import what you need from the package in your TypeScript files:
import * from example-package
.
Gradle (Kotlin)
- Add the package to your
build.gradle.kts
file underdependencies
:
dependencies {
implementation("com.example:example-package:1.0.0")
}
- Run
./gradlew build
to download the package and update your project. - You can now import what you need from the package in your Kotlin files:
import com.example.examplepackage
.
Environment variables
Environment variables are a common way to configure server-side applications. They allow you to store sensitive information, configuration settings, and API keys outside your codebase. Both TypeScript and Kotlin offer ways to access environment variables in your applications.
The way environment variables are accessed and managed can vary between TypeScript and Kotlin, depending on the runtime environment and the tools you use. Let’s explore how to work with environment variables in both languages.
Environment Variables in TypeScript
In TypeScript, you can access environment variables using the process.env
object. This object contains the user environment and can be used to access variables like NODE_ENV
, PORT
, or custom variables you define. You can set environment variables in your shell or in a .env
file and use a package like dotenv
in order to load the configuration to process.env
.
Here’s an example of accessing environment variables in TypeScript:
// Accessing environment variables in TypeScript
const port = process.env.PORT || 3000
const dbUrl = process.env.DB_URL || 'mongodb://localhost:27017/mydb'
Environment Variables in Kotlin
In Kotlin, you can access environment variables using the System.getenv()
function. This approach will probably be enough for most small applications. If you’re dealing with multiple config values, using application.conf
(will be shown in a project context later) for structured configuration is usually the cleaner approach. You can store default values in the config file and let environment variables override them at runtime.
Here’s an example of accessing environment variables in Kotlin:
// Accessing environment variables in Kotlin with System.getenv()
val port = System.getenv("PORT")?.toIntOrNull() ?: 3000
val dbUrl = System.getenv("DB_URL") ?: "mongodb://localhost:27017/mydb"
// Using application.conf for structured configuration
val config = environment.config
val port2 = config.propertyOrNull("ktor.deployment.port")?.getString()?.toIntOrNull() ?: 3000
val dbUrl = config.propertyOrNull("ktor.database.url")?.getString() ?: "mongodb://localhost:27017/mydb"
application.conf
(can be overriden during runtime)
ktor {
deployment {
port = 8080
}
database {
url = "mongodb://localhost:27017/mydb"
}
runtimevar {
canBeOverriden = ${?ENV_VAR_NAME}
}
}
Here, the runtimevar value can be overridden by setting the ENV_VAR_NAME
environment variable.
Using System.getenv
and application.conf
are the most common ways to manage environment variables in Kotlin. You can choose the approach that best fits your project requirements and runtime environment, or you can use a combination of both for more flexibility.
Creating RESTful APIs
Building RESTful APIs is a common use case for server-side applications. Both TypeScript and Kotlin offer robust frameworks and libraries for creating APIs, handling requests, and managing routes. Understanding how to create routes, handle requests, and return data is essential for building scalable, maintainable APIs. There are countless frameworks and libraries available for both languages, but we’ll focus on Express for TypeScript and Ktor for Kotlin.
Express vs Ktor
Express is a minimalist, flexible Node.js web application framework that provides a robust set of features for building APIs. It’s widely used in the Node.js ecosystem and offers middleware support, routing, and request/response handling. Express is known for its simplicity, speed, and extensibility, making it a popular choice for building RESTful APIs.
Ktor is a modern, asynchronous web framework for Kotlin that’s built on coroutines. It’s designed to be lightweight, extensible, and easy to use for building APIs and web applications. Ktor offers routing, request/response handling, and support for various features like authentication, serialization, and templating. An added bonus is that the creators of Kotlin (JetBrains) maintain Ktor, so you can expect good tooling support and documentation.
The rest of this section will consist of a high-level overview of creating RESTful APIs in TypeScript with Express and Kotlin with Ktor. We’ll cover setting up the frameworks, folder structure, creating routes, handling requests, and returning data in both frameworks in order to make it easier to transition from TypeScript to Kotlin.
Setup and Project Structure
Express is a Node.js framework, so you’ll need to have Node.js installed on your machine. You can create a new Express project using the express-generator
package and manually converting to TypeScript, use another initializer, or by manually setting up the project structure.
We'll assume you’ve manually set up the project structure and converted it to TypeScript, with the following folder structure suggested by Mingyang Li in this this article:
.
├─ docs/ # 📚 Documentations & diagrams on local development steps, deployment processes, etc,
├─ __tests__/ # 🧪 Test files
├─ src/ # 🧑🏻💻 All code files
│ ├─ routes/ # 🚦 REST API Routes (Express routes, etc)
│ ├─ controllers/ # 🎮 Receives and returns data to routes (handling requests)
│ ├─ services/ # 💼 Business logic
│ ├─ repositories/ # 🗄️ Database operations
│ ├─ models/ # 📦 Data models
│ ├─ constants/ # 🧱 Constants
│ ├─ libs/ # 🎁 Wrapper for 3rd party SDKs/APIs
│ ├─ middlewares/ # 🛡️ Authentication, error handling, logging, caching, etc
│ ├─ types/ # 🏷️ Type definitions
│ ├─ validators/ # 👮🏻 Validate incoming API payloads
│ ├─ generated/ # 🧩 Auto-generated files (e.g., codegen from OpenAPI)
│ ├─ common/ # 🪚 Shared utilities
│ ├─ logger.ts # 🪵 Logger
│ ├─ index.ts # 🚀 Main entry point
│ └─ env.ts # 🌍 Config-related logic (env variables, .properties)
├─ package.json # 📦 Node.js dependencies
├─ package.lock.json # 📦 Lock file
├─ tsconfig.json # 🏷️ TypeScript config
├─ .prettierrc # 🎨 Prettier config
├─ .prettierignore # 🎨 Prettier ignore
├─ .editorconfig # 🎨 Shared editor/format config
├─ .gitignore # 🚫 Files to ignore in version control
├─ Dockerfile # 🐳 Docker config
├─ .dockerignore # 🐳 Docker ignore
├─ .eslintrc.ts # 🚨 ESLint config
└─ .eslintignore # 🚨 ESLint ignore
As mentioned earlier, this is an opinionated structure and you can adjust it based on your project requirements. The point of outlining this structure is to have a way to compare the TypeScript project structure with the Kotlin project structure.
Ktor projects can be configured manually, using the Ktor Project Generator, or by using the Ktor IntelliJ IDEA plugin. Personally I prefer the IntelliJ IDEA plugin as it makes it easier to set up the project structure and dependencies. With the TypeScript project structure in mind, here’s a suggested folder structure for an equivalent Ktor project:
.
├─ docs/ # 📚 Documentations, diagrams, deployment process, etc.
├─ src/
│ ├─ main/
│ │ ├─ kotlin/
│ │ │ ├─ com/
│ │ │ │ └─ example/
│ │ │ │ ├─ routes/ # 🚦 REST API routes (Ktor routing modules, etc.)
│ │ │ │ ├─ controllers/ # 🎮 Receives/returns data to routes (handling requests)
│ │ │ │ ├─ services/ # 💼 Business logic
│ │ │ │ ├─ repositories/ # 🗄️ Database operations (DAO, JPA, Exposed, etc.)
│ │ │ │ ├─ models/ # 📦 Data classes for domain models
│ │ │ │ ├─ constants/ # 🧱 Constants (or companion object const vals)
│ │ │ │ ├─ libs/ # 🎁 Wrapper for 3rd party SDKs/APIs
│ │ │ │ ├─ middlewares/ # 🛡️ Interceptors, pipeline features in Ktor, etc.
│ │ │ │ ├─ validators/ # 👮🏻 Payload validation logic
│ │ │ │ ├─ generated/ # 🧩 Auto-generated files (e.g., codegen from OpenAPI)
│ │ │ │ ├─ common/ # 🪚 Shared utilities (extension functions, helpers)
│ │ │ │ ├─ logger/ # 🪵 Central logging setup (e.g., Logback, Kotlin Logging)
│ │ │ │ ├─ config/ # 🌍 Config-related logic (env variables, .properties)
│ │ │ │ └─ Application.kt # 🚀 Main entry point (Ktor `main()`)
│ │ ├─ resources/
│ │ │ ├─ application.conf # 🌍 Ktor config, env-variables (alternative) or other resource files
│ │ │ └─ logback.xml # 🪵 Logback config if needed
│ └─ test/
│ └─ kotlin/
│ └─ com/
│ └─ example/
│ ├─ routes/ # 🧪 Tests for route handlers
│ ├─ controllers/ # 🧪 Tests for controllers
│ ├─ services/ # 🧪 Tests for services
│ └─ etc.
├─ build.gradle.kts # 🏗️ Gradle build configuration (Kotlin DSL)
├─ gradle.properties # 📝 Gradle-specific properties (JVM args, version info, etc.)
├─ settings.gradle.kts # 🏗️ Gradle multi-module settings if needed
├─ Dockerfile # 🐳 Docker config
├─ .dockerignore # 🐳 Docker ignore
├─ .editorconfig # 🎨 Shared editor/format config (like Prettier in Node)
└─ .gitignore # 🚫 Files to ignore in version control (build/, .gradle/, etc.)
This structure is based on the same principles as the TypeScript structure, with a focus on separation of concerns, modularity, and maintainability. The goal is to have a clear separation of concerns between routes, controllers, services, and repositories, making it easier to manage and scale the project. The Application.kt
file serves as the main entry point for the Ktor application, similar to the index.ts
file in a TypeScript project.
The provided examples are meant to serve as "production-grade" project structures and is only meant to serve as a basis for comparison. You can adjust them based on your project requirements and may contain more or fewer folders depending on the project size and complexity.
Creating new endpoints
Express uses the express
package to create routes, handle requests, and return responses. You can define routes using the express.Router
class and attach middleware functions to handle requests. Here’s an example of creating routes in Express:
Folder structure recap:
src/
├── routes/
│ └── birds.ts # 🚦🕊️ The router that registers /birds endpoints
├── controllers/
│ └── birdsController.ts # 🎮🕊️ Receives and returns data to routes
├── services/
│ └── birdsService.ts # 💼🕊️ Contains business logic for birds
├── repositories/
├── index.ts # 🚀 Main Express entry point
src/services/birdsService.ts
export class BirdsService {
static async getAllBirds() {
// This would be fetched from a database in a real app
return [
{ id: 1, name: 'Sparrow', habitat: 'Urban' },
{ id: 2, name: 'Eagle', habitat: 'Mountains' },
{ id: 3, name: 'Penguin', habitat: 'Antarctic' },
]
}
static async getBirdById(id: number) {
const birds = await this.getAllBirds()
return birds.find((bird) => bird.id === id) || null
}
static async addBird(data: { name: string; habitat: string }) {
return { id: Date.now(), ...data } // This would be persisted in a real app
}
}
src/controllers/birdsController.ts
import { Request, Response } from 'express'
import { BirdsService } from '../services/birdsService'
export class BirdsController {
static async getAll(req: Request, res: Response) {
const birds = await BirdsService.getAllBirds()
res.json(birds)
}
static async getById(req: Request, res: Response) {
const birdId = parseInt(req.params.id, 10)
const bird = await BirdsService.getBirdById(birdId)
if (bird) {
res.json(bird)
} else {
res.status(404).send('Bird not found')
}
}
static async add(req: Request, res: Response) {
const { name, habitat } = req.body
if (!name || !habitat) {
res.status(400).send('Invalid bird data')
return
}
const newBird = await BirdsService.addBird({ name, habitat })
res.status(201).json(newBird)
}
}
src/routes/birds.ts
import { Router } from 'express'
import { BirdsController } from '../controllers/birdsController'
const router = Router()
// Middleware specific to this router
router.use((req, res, next) => {
console.log(`[BirdMiddleware] - ${req.method} request to ${req.url}`)
next()
})
// GET /birds
router.get('/', BirdsController.getAll)
// GET /birds/:id
router.get('/:id', BirdsController.getById)
// POST /birds
router.post('/', BirdsController.add)
export default router
src/index.ts
import express from 'express'
import birdsRouter from './routes/birds'
const app = express()
const PORT = process.env.PORT || 3000
app.use(express.json())
// Attach the /birds route
app.use('/birds', birdsRouter)
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
})
This is all we need in order to create the new routes in Express. The birdsRouter
is attached to the /birds
endpoint in the index.ts
file, which is the main entry point for the Express application. The BirdsController
class contains the logic for handling requests and returning responses, while the BirdsService
class contains the business logic for interacting with the data. Now let’s see how we can achieve the same functionality in Ktor.
Ktor uses the io.ktor
package to create routes, handle requests, and return responses. You can define routes using the routing
function and attach handlers to handle requests. Here’s an example of creating routes in Ktor:
Folder structure recap:
src/
├─ main/
│ ├─ kotlin/
│ │ └─ com/
│ │ └─ example/
│ │ ├─ routes/
│ │ │ └─ BirdRoutes.kt # 🚦🕊️ The router that registers /birds endpoints
│ │ ├─ controllers/
│ │ │ └─ BirdController.kt # 🎮🕊️ Receives and returns data to routes
│ │ ├─ services/
│ │ │ └─ BirdService.kt # 💼🕊️ Contains business logic for birds
│ │ ├─ Application.kt # 🚀 Main Ktor entry point
│ └─ resources/
src/main/kotlin/com/example/services/BirdService.kt
package com.example.services
object BirdService {
// This would be fetched from a database in a real app
private val birds = listOf(
mapOf("id" to 1, "name" to "Sparrow", "habitat" to "Urban"),
mapOf("id" to 2, "name" to "Eagle", "habitat" to "Mountains"),
mapOf("id" to 3, "name" to "Penguin", "habitat" to "Antarctic")
)
fun getAllBirds(): List<Map<String, Any>> = birds
fun getBirdById(id: Int): Map<String, Any>? = birds.find { it["id"] == id }
fun addBird(data: Map<String, Any>): Map<String, Any> {
val newBird = data + mapOf("id" to (birds.size + 1))
// This would be persisted in a real app
return newBird
}
}
src/main/kotlin/com/example/controllers/BirdController.kt
package com.example.controllers
import io.ktor.application.*
import io.ktor.response.*
import io.ktor.request.*
import com.example.services.BirdService
object BirdController {
suspend fun getAll(call: ApplicationCall) {
call.respond(BirdService.getAllBirds())
}
suspend fun getById(call: ApplicationCall) {
val id = call.parameters["id"]?.toIntOrNull()
val bird = id?.let { BirdService.getBirdById(it) }
if (bird != null) {
call.respond(bird)
} else {
call.respondText("Bird not found", status = io.ktor.http.HttpStatusCode.NotFound)
}
}
suspend fun add(call: ApplicationCall) {
val data = call.receive<Map<String, Any>>()
if (data["name"] == null || data["habitat"] == null) {
call.respondText("Invalid bird data", status = io.ktor.http.HttpStatusCode.BadRequest)
return
}
val newBird = BirdService.addBird(data)
call.respond(newBird)
}
}
src/main/kotlin/com/example/routes/BirdRoutes.kt
package com.example.routes
import io.ktor.routing.*
import com.example.controllers.BirdController
fun Route.birdRoutes() {
route("/birds") {
get { BirdController.getAll(call) }
get("/{id}") { BirdController.getById(call) }
post { BirdController.add(call) }
}
}
src/main/kotlin/com/example/Application.kt
package com.example
import io.ktor.application.*
import io.ktor.features.*
import io.ktor.routing.*
import io.ktor.server.netty.*
import com.example.routes.birdRoutes
fun main(args: Array<String>) = EngineMain.main(args)
fun Application.module() {
install(DefaultHeaders)
install(CallLogging)
install(ContentNegotiation) {
io.ktor.serialization.json()
}
routing {
// Attach the /birds route
birdRoutes()
}
}
This is all we need in order to create the new routes in Ktor. The birdRoutes
function is attached to the /birds
endpoint in the Application.kt
file, which is the main entry point for the Ktor application. The BirdController
object contains the logic for handling requests and returning responses, while the BirdService
object contains the business logic for interacting with the data.
As you can probably tell by now, TypeScript and Kotlin are pretty similar in terms of creating RESTful APIs. Both languages offer robust frameworks and libraries for building APIs, handling requests, and managing routes. The key difference lies in the syntax and language features, but the overall process of creating APIs is quite similar, and we can see that the folder structure and separation of concerns are consistent across both languages.
There are many other aspects to consider when building RESTful APIs, such as authentication, validation, error handling, and testing. However, the examples provided should give you a good starting point for transitioning to Kotlin with Ktor.
Containerizing your application
Containerization is a popular way to package and deploy applications, making it easier to manage dependencies, scale applications, and ensure consistency across different environments. Both TypeScript and Kotlin applications can be containerized using Docker, a popular containerization platform that simplifies the process of building, shipping, and running applications. In this section, we'll cover the basics of containerizing your application using Docker. As this is a high-level overview, we won't go into the details of Docker commands or Dockerfile configurations, but we'll provide a general guide on how to containerize your application.
Why containerize?
This isn't a post about Docker, but it's worth mentioning why containerization is beneficial for server-side applications.
Containerization offers several benefits for server-side applications (these are some of the benefits, not all):
- Consistency: Containers ensure that your application runs the same way across different environments, reducing the risk of issues due to differences in dependencies or configurations.
- Isolation: Containers provide a level of isolation for your application, making it easier to manage dependencies and avoid conflicts with other applications.
- Scalability: Containers can be easily scaled up or down based on demand, allowing you to efficiently manage resources and handle traffic spikes.
- Portability: Containers can be run on any platform that supports Docker, making it easy to deploy applications across different environments.
- Security: Containers provide a level of security by isolating applications from the underlying host system, reducing the risk of security vulnerabilities.
Creating your Dockerfile
A Dockerfile is a text file that contains instructions for building a Docker image. It specifies the base image, dependencies, environment variables, and commands needed to run your application.
Firstly let's recap the relevant parts of the project structure for TypeScript:
src/
│ └─ index.ts # 🚀 Main entry point
├── Dockerfile # 🐳 Docker config
├── package.json # 📦 Node.js dependencies
├── package.lock.json # 📦 Lock file
A simple Dockerfile for a TypeScript application might look like this (assuming you have a build
script in your package.json
that compiles TypeScript to JavaScript, the compiled files are in a dist
directory, and that the application listens on port 8080
and that you have a very standard Express application):
# Use a Node.js base image
# ====== 1) Build Stage ======
FROM node:18 AS builder
WORKDIR /app
# Copy package.json and lock files first (for better caching)
COPY package*.json ./
# Install dependencies (devDependencies included for TypeScript compiler)
RUN npm install
# Copy the entire codebase
COPY . .
# Compile TypeScript to JavaScript
RUN npm run build
# ====== 2) Production Stage ======
FROM node:18 AS production
WORKDIR /app
# Copy only the compiled output and package.json
COPY /app/dist ./dist
COPY package*.json ./
# Install only production dependencies
RUN npm install --omit=dev
# The compiled app listens on port 3000 (for example)
EXPOSE 8080
CMD ["node", "dist/index.js"]
This Dockerfile uses a multi-stage build to separate the build environment from the production environment. The first stage installs dependencies, compiles TypeScript to JavaScript, and creates a dist
directory with the compiled output. The second stage copies the compiled output and package.json
file to the production image, installs only production dependencies, and sets the command to run the application.
Now let's recap the relevant parts of the project structure for Kotlin:
src/
├─ main/
│ ├─ kotlin/
│ │ └─ com/
│ │ └─ example/
│ │ └─ Application.kt # 🚀 Main Ktor entry point
├─ build.gradle.kts # 🏗️ Gradle build configuration (Kotlin DSL)
├─ Dockerfile # 🐳 Docker config
Kotlin + Ktor applications has a Gradle Docker plugin that can be used to build and publish Docker images. You can read more about it here. In this case, we'll use a simple Dockerfile to run the application.
Before we do that, we need to add the Gradle shadow plugin to build a fat JAR that contains all dependencies (if you don't have it already). Add the following to your build.gradle.kts
file:
plugins {
id("com.github.johnrengelman.shadow") version "8.1.1"
}
This plugin allows you to build a fat JAR that contains all dependencies, making it easier to run the application in a Docker container. Here's a simple Dockerfile for a Kotlin application:
# Stage 1: Cache Gradle dependencies
FROM gradle:latest AS cache
RUN mkdir -p /home/gradle/cache_home
ENV GRADLE_USER_HOME /home/gradle/cache_home
COPY build.gradle.* gradle.properties /home/gradle/app/
COPY gradle /home/gradle/app/gradle
WORKDIR /home/gradle/app
RUN gradle clean build -i --stacktrace
# Stage 2: Build Application
FROM gradle:latest AS build
COPY /home/gradle/cache_home /home/gradle/.gradle
COPY . /usr/src/app/
WORKDIR /usr/src/app
COPY . /home/gradle/src
WORKDIR /home/gradle/src
# Build the fat JAR, Gradle also supports shadow
# and boot JAR by default.
RUN gradle buildFatJar --no-daemon
# Stage 3: Create the Runtime Image
FROM amazoncorretto:22 AS runtime
EXPOSE 8080
RUN mkdir /app
COPY /home/gradle/src/build/libs/*.jar /app/ktor-docker-sample.jar
ENTRYPOINT ["java","-jar","/app/ktor-docker-sample.jar"]
This Dockerfile uses a multi-stage build to separate the build environment from the production environment. The first stage caches Gradle dependencies to speed up the build process. The second stage builds the application using the cached dependencies. The third stage creates the runtime image with the fat JAR and sets the entry point to run the application.
These are just simple examples of Dockerfiles for TypeScript and Kotlin applications. Depending on your project requirements, you may need to customize the Dockerfile to include additional configurations, environment variables, or dependencies. You can also use Docker Compose to manage multi-container applications or Kubernetes for container orchestration. Once the Dockerfile is set up, you can build the Docker image using the docker build
command and push it to a container registry for deployment or run it locally for testing.
Deploying your container
Once you've containerized your application using Docker, you can deploy it to various platforms, such as cloud providers, container orchestration tools, or your own servers. Deploying a containerized application involves running the Docker image on a host machine, setting up networking, and managing resources. This section will provide a high-level overview of deploying your containerized application to a cloud provider, specifically Google Cloud Run.
Deploying to Google Cloud Run
There are several deployment options for containerized applications, depending on your requirements and infrastructure setup. I will not go into the details of each deployment option, but rather give reasons why you might want to choose Google Cloud Run.
Google Cloud Run is a serverless platform that allows you to run stateless containers on Google Cloud Platform. It automatically scales your containers based on traffic and charges you only for the resources you use. Cloud Run supports both HTTP and gRPC requests, making it suitable for web applications, APIs, and microservices.
It is generally easy to deploy a containerized application to Google Cloud Run, and you can use the gcloud
command-line tool or the Google Cloud Console to deploy your application.
Google has provided a guide on how to deploy a containerized application to Cloud Run here (requires you to setup a Google Cloud Project).
Conclusion
In this guide, we've covered the basics of transitioning from TypeScript to Kotlin for server-side development. We've explored the similarities and differences between TypeScript and Kotlin, discussed key concepts like asynchronous programming, dependency management, environment variables, creating RESTful APIs, containerizing applications, and deploying to the cloud. By understanding these concepts, you'll be better equipped to make the transition from TypeScript to Kotlin and build robust, scalable server-side applications. My hope is that this guide has provided you with a foundation for transitioning from TypeScript to Kotlin and has given you the confidence to explore Kotlin further for server-side development. If you have any questions or feedback, feel free to reach out. Happy coding!