
Write Angular apps with pleasure on Kotlin π
Write Angular apps with pleasure on Kotlin
βΊοΈ
Benefits:
;, no this, real null safety@Component/@Injectable/@NgModule/@Directive/@Pipe)@angular/compiler stays out of the runtime bundle and tracking a new Angular release is a one-line version bump (no ng update, no TypeScript to touch)valueChanges, signals bridgeHttpClient boilerplate - or opt into Angular's HttpClient)Three artifacts: the Gradle plugin (Gradle Plugin Portal) plus the runtime and the
KSP processor (Maven Central, group io.github.vladkalyuzhny).
settings.gradle.kts:
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}Your Kotlin/JS app's build.gradle.kts:
plugins {
id("io.github.vladkalyuzhny.angularkt") version "0.1.0" // applies the Kotlin/JS + KSP plugins for you
}
kotlin {
sourceSets {
val jsMain by getting {
dependencies {
implementation("io.github.vladkalyuzhny:angularkt:0.1.0")
}
}
}
}The plugin registers the KSP processor and configures the Kotlin/JS target; you only add
the angularkt runtime dependency. See demo/ for a full setup (the @angular/*
npm versions, Material, JIT vs AOT)
@Injectable(providedIn = "root")
class GreetingService {
fun greet(): String = "AngularKt"
}
@JsExport
@Component(
selector = "app-root",
template = """
<p>Welcome to {{title}}!</p>
"""
)
class AppComponent(private val greeting: GreetingService) : OnInit {
var title = ""
override fun ngOnInit() {
title = greeting.greet()
}
}
@NgModule(
declarations = [AppComponent::class],
imports = [BrowserModule::class],
bootstrap = [AppComponent::class]
)
class AppModule
fun main() {
registerAngularKt() // generated by the KSP processor from the annotations
platformBrowserDynamic(undefined).bootstrapModule(AppModule::class.js)
}Isn't that awesome? Metadata lives on the annotations, 1:1 with Angular TS
(@JsExport is the Kotlin analog of TS export class, so Angular can reach
members by name). The :processor KSP module reads the annotations and generates
registerAngularKt() - the runtime decorator wiring and constructor-DI metadata.
Routing has its own annotation. The root route set (the one nothing references as
children) compiles to whatever the bootstrap style calls for. Under a standalone
bootstrap it becomes provideRouter(routes) at the environment injector - the idiomatic
standalone target, no shim. Under a classic-NgModule bootstrap the processor emits a runtime
@NgModule({ imports: [RouterModule.forRoot(routes)] }) decoration on the route class,
making it an importable forRoot module. (Bootstrap style is independent of the compile mode,
the demo just pairs standalone with AOT and classic with JIT to showcase both.)
@RoutingModule(routes = [
Route(path = "", component = HomeComponent::class),
Route(path = "user/:id", component = UserComponent::class),
Route(path = "product/:id", component = ProductComponent::class,
children = ProductRoutes::class),
Route(path = "admin", loadChildren = AdminModule::class),
])
class AppRoutingModuleThe generated bridge wires whichever the bootstrap uses: a standalone app gets provideRouter;
a classic app has registerAngularKt() decorate AppRoutingModule with RouterModule.forRoot(routes)
and your root @NgModule (e.g. BootstrapModule) import it. The rest of the providers ride along the
same way β appProviders() for the standalone app, the root @NgModule's imports for the classic one.
Router features carry over to either style β e.g. @RoutingModule(useHash = true) (#/path URLs)
becomes provideRouter(routes, withHashLocation()) under a standalone bootstrap and
RouterModule.forRoot(routes, { useHash: true }) under a classic one.
Nested (second-level) routes use children = β¦::class, pointing at another class
annotated with @RoutingModule β Kotlin/Java forbid an annotation referencing its
own type, so children: Array<Route> is impossible; the by-class indirection is how
a Route gains a children: [...] array, and it nests to any depth. The processor
inlines the referenced class's routes into the parent and emits no separate provider
for it. The parent component hosts a nested <router-outlet>:
@RoutingModule(routes = [
Route(path = "", redirectTo = "info", pathMatch = "full"),
Route(path = "info", component = ProductInfoComponent::class),
Route(path = "reviews", component = ProductReviewsComponent::class),
])
class ProductRoutesloadChildren = X::class lazy-loads X, and β exactly like Angular's loadChildren,
which resolves to either an NgModule or a Routes array β what X is picks the form:
Modern (standalone) β point at a @RoutingModule. No @NgModule, no forChild:
@RoutingModule(routes = [
Route(path = "", component = AdminComponent::class), // AdminComponent is standalone
])
class AdminRoutes
// root: Route(path = "admin", loadChildren = AdminRoutes::class)The processor emits a bare routes bundle and
loadChildren: () => import('./AdminRoutes').then(m => m.AdminRoutes) β Angular's
() => ROUTES idiom, today's ng new default.
Classic β point at an @NgModule that imports a plain @RoutingModule. Listing a routing
module in a feature (non-bootstrap) @NgModule's imports is enough β the processor infers
RouterModule.forChild(routes) from that placement (a feature module uses forChild, never
forRoot); there is no flag, just like Angular derives the call from where you write it:
@RoutingModule(routes = [
Route(path = "", component = AdminComponent::class),
])
class AdminRoutingModule
@NgModule(declarations = [AdminComponent::class], imports = [AdminRoutingModule::class])
class AdminModule // imports AdminRoutingModule β it compiles to forChild
// root: Route(path = "admin", loadChildren = AdminModule::class)Either way, keep X out of AppModule's imports so nothing pulls it in eagerly. What
"lazy" means then differs by compilation mode:
() => import(...).then(...) with
no static import, so Angular's toolchain splits the target into a chunk fetched only
on the first visit to /admin.loadChildren
resolves to it with no separate download. Routing behaves lazily (the component is
instantiated on navigation); the code is not split.See app.routing.kt and the two lazy demos
under app/router/lazy (classic LazyFeatureModule
LazyStandaloneRoutes).AngularKt supports two compilation modes, and the task you run picks the mode β any
aot* task builds AOT, everything else builds JIT. There is no mode property to set or
keep in sync.
JIT (the default β any non-aot task) - the Kotlin/JS code is webpack-bundled
together with zone.js + @angular/compiler, and the KSP processor generates
registerAngularKt(), which applies the Angular decorators at runtime. Simplest to
run; ships the compiler in the bundle.
$ ./gradlew :demo:jsBrowserProductionWebpack # optimized production bundle (minified)
$ ./gradlew :demo:jsBrowserDevelopmentRun -t # ...or serve with live reload (.kt/.html/.css) on http://localhost:8080
$ ./gradlew :demo:jsBrowserDevelopmentRun -t -PangularKt.port=9000 # ...on a custom portAOT (any aot* task) - true ahead-of-time compilation by Angular's own
toolchain, so @angular/compiler is not in the runtime bundle. How it works:
:demo compiles to an importable ESM library (binaries.library()) plus
TypeScript definitions; component/service logic stays 100% Kotlin.@Component /
@Directive / @Injectable / @NgModule into demo/build/generated/ksp/.../*.ts.
Each bridge carries the real public Angular decorator and extends the
Kotlin-compiled class; constructor DI becomes super(inject(Dep), ...).demo/build/ng-aot (ng new, pinned to
the target Angular major - nothing is checked in), wires the Kotlin library in as
a local file: npm dependency, and AOT-compiles the bridges with @angular/build.The generated main.ts's bootstrap style is decided by aotConfig.bootstrapComponent:
name a standalone root @Component and the entry becomes bootstrapApplication(root, β¦);
leave it unset and the processor falls back to a classic bootstrapModule of your root
@NgModule (the one with a bootstrap = [...] array). This knob is AOT-only β the AOT entry
is generated, so the root's identity has to live somewhere KSP can read it. The JIT entry is
your own hand-written main.kt, where you call bootstrapApplication or
platformBrowserDynamic().bootstrapModule(...) directly, so it needs no equivalent. (Compile
mode and bootstrap style are independent axes; the demo just pairs AOT with standalone and JIT
with classic to showcase both idioms.)
One task drives the whole pipeline - build the library, emit + copy the bridges,
npm install, AOT build:
$ ./gradlew :demo:aotBuild # optimized production bundle, no @angular/compiler
$ ./gradlew :demo:aotServe # ...or serve with live reload on http://localhost:4200
$ ./gradlew :demo:aotServe -PangularKt.port=4300 # ...on a custom portaotBuild chains every stage, but the sub-tasks (aotInit, copyAotBridges,
aotNpmInstall, β¦) can be run on their own β they all carry aot in the name, so the mode
resolves the same way; delete demo/build/ng-aot to force a clean re-scaffold.
Upgrading Angular is one knob - -PangularKt.angularVersion=22 (default 22, floor 16;
a bare major resolves to ~22.0.0). It pins the workspace, npm deps, and bridges together,
so bump-and-rebuild is the whole upgrade.
All issues and pull requests are welcome.
Thank you in advance
This project is licensed under the Apache License, Version 2.0
See the LICENSE.txt file for more details
Copyright 2020-2026 Vlad Kalyuzhnyu
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Write Angular apps with pleasure on Kotlin
βΊοΈ
Benefits:
;, no this, real null safety@Component/@Injectable/@NgModule/@Directive/@Pipe)@angular/compiler stays out of the runtime bundle and tracking a new Angular release is a one-line version bump (no ng update, no TypeScript to touch)valueChanges, signals bridgeHttpClient boilerplate - or opt into Angular's HttpClient)Three artifacts: the Gradle plugin (Gradle Plugin Portal) plus the runtime and the
KSP processor (Maven Central, group io.github.vladkalyuzhny).
settings.gradle.kts:
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}Your Kotlin/JS app's build.gradle.kts:
plugins {
id("io.github.vladkalyuzhny.angularkt") version "0.1.0" // applies the Kotlin/JS + KSP plugins for you
}
kotlin {
sourceSets {
val jsMain by getting {
dependencies {
implementation("io.github.vladkalyuzhny:angularkt:0.1.0")
}
}
}
}The plugin registers the KSP processor and configures the Kotlin/JS target; you only add
the angularkt runtime dependency. See demo/ for a full setup (the @angular/*
npm versions, Material, JIT vs AOT)
@Injectable(providedIn = "root")
class GreetingService {
fun greet(): String = "AngularKt"
}
@JsExport
@Component(
selector = "app-root",
template = """
<p>Welcome to {{title}}!</p>
"""
)
class AppComponent(private val greeting: GreetingService) : OnInit {
var title = ""
override fun ngOnInit() {
title = greeting.greet()
}
}
@NgModule(
declarations = [AppComponent::class],
imports = [BrowserModule::class],
bootstrap = [AppComponent::class]
)
class AppModule
fun main() {
registerAngularKt() // generated by the KSP processor from the annotations
platformBrowserDynamic(undefined).bootstrapModule(AppModule::class.js)
}Isn't that awesome? Metadata lives on the annotations, 1:1 with Angular TS
(@JsExport is the Kotlin analog of TS export class, so Angular can reach
members by name). The :processor KSP module reads the annotations and generates
registerAngularKt() - the runtime decorator wiring and constructor-DI metadata.
Routing has its own annotation. The root route set (the one nothing references as
children) compiles to whatever the bootstrap style calls for. Under a standalone
bootstrap it becomes provideRouter(routes) at the environment injector - the idiomatic
standalone target, no shim. Under a classic-NgModule bootstrap the processor emits a runtime
@NgModule({ imports: [RouterModule.forRoot(routes)] }) decoration on the route class,
making it an importable forRoot module. (Bootstrap style is independent of the compile mode,
the demo just pairs standalone with AOT and classic with JIT to showcase both.)
@RoutingModule(routes = [
Route(path = "", component = HomeComponent::class),
Route(path = "user/:id", component = UserComponent::class),
Route(path = "product/:id", component = ProductComponent::class,
children = ProductRoutes::class),
Route(path = "admin", loadChildren = AdminModule::class),
])
class AppRoutingModuleThe generated bridge wires whichever the bootstrap uses: a standalone app gets provideRouter;
a classic app has registerAngularKt() decorate AppRoutingModule with RouterModule.forRoot(routes)
and your root @NgModule (e.g. BootstrapModule) import it. The rest of the providers ride along the
same way β appProviders() for the standalone app, the root @NgModule's imports for the classic one.
Router features carry over to either style β e.g. @RoutingModule(useHash = true) (#/path URLs)
becomes provideRouter(routes, withHashLocation()) under a standalone bootstrap and
RouterModule.forRoot(routes, { useHash: true }) under a classic one.
Nested (second-level) routes use children = β¦::class, pointing at another class
annotated with @RoutingModule β Kotlin/Java forbid an annotation referencing its
own type, so children: Array<Route> is impossible; the by-class indirection is how
a Route gains a children: [...] array, and it nests to any depth. The processor
inlines the referenced class's routes into the parent and emits no separate provider
for it. The parent component hosts a nested <router-outlet>:
@RoutingModule(routes = [
Route(path = "", redirectTo = "info", pathMatch = "full"),
Route(path = "info", component = ProductInfoComponent::class),
Route(path = "reviews", component = ProductReviewsComponent::class),
])
class ProductRoutesloadChildren = X::class lazy-loads X, and β exactly like Angular's loadChildren,
which resolves to either an NgModule or a Routes array β what X is picks the form:
Modern (standalone) β point at a @RoutingModule. No @NgModule, no forChild:
@RoutingModule(routes = [
Route(path = "", component = AdminComponent::class), // AdminComponent is standalone
])
class AdminRoutes
// root: Route(path = "admin", loadChildren = AdminRoutes::class)The processor emits a bare routes bundle and
loadChildren: () => import('./AdminRoutes').then(m => m.AdminRoutes) β Angular's
() => ROUTES idiom, today's ng new default.
Classic β point at an @NgModule that imports a plain @RoutingModule. Listing a routing
module in a feature (non-bootstrap) @NgModule's imports is enough β the processor infers
RouterModule.forChild(routes) from that placement (a feature module uses forChild, never
forRoot); there is no flag, just like Angular derives the call from where you write it:
@RoutingModule(routes = [
Route(path = "", component = AdminComponent::class),
])
class AdminRoutingModule
@NgModule(declarations = [AdminComponent::class], imports = [AdminRoutingModule::class])
class AdminModule // imports AdminRoutingModule β it compiles to forChild
// root: Route(path = "admin", loadChildren = AdminModule::class)Either way, keep X out of AppModule's imports so nothing pulls it in eagerly. What
"lazy" means then differs by compilation mode:
() => import(...).then(...) with
no static import, so Angular's toolchain splits the target into a chunk fetched only
on the first visit to /admin.loadChildren
resolves to it with no separate download. Routing behaves lazily (the component is
instantiated on navigation); the code is not split.See app.routing.kt and the two lazy demos
under app/router/lazy (classic LazyFeatureModule
LazyStandaloneRoutes).AngularKt supports two compilation modes, and the task you run picks the mode β any
aot* task builds AOT, everything else builds JIT. There is no mode property to set or
keep in sync.
JIT (the default β any non-aot task) - the Kotlin/JS code is webpack-bundled
together with zone.js + @angular/compiler, and the KSP processor generates
registerAngularKt(), which applies the Angular decorators at runtime. Simplest to
run; ships the compiler in the bundle.
$ ./gradlew :demo:jsBrowserProductionWebpack # optimized production bundle (minified)
$ ./gradlew :demo:jsBrowserDevelopmentRun -t # ...or serve with live reload (.kt/.html/.css) on http://localhost:8080
$ ./gradlew :demo:jsBrowserDevelopmentRun -t -PangularKt.port=9000 # ...on a custom portAOT (any aot* task) - true ahead-of-time compilation by Angular's own
toolchain, so @angular/compiler is not in the runtime bundle. How it works:
:demo compiles to an importable ESM library (binaries.library()) plus
TypeScript definitions; component/service logic stays 100% Kotlin.@Component /
@Directive / @Injectable / @NgModule into demo/build/generated/ksp/.../*.ts.
Each bridge carries the real public Angular decorator and extends the
Kotlin-compiled class; constructor DI becomes super(inject(Dep), ...).demo/build/ng-aot (ng new, pinned to
the target Angular major - nothing is checked in), wires the Kotlin library in as
a local file: npm dependency, and AOT-compiles the bridges with @angular/build.The generated main.ts's bootstrap style is decided by aotConfig.bootstrapComponent:
name a standalone root @Component and the entry becomes bootstrapApplication(root, β¦);
leave it unset and the processor falls back to a classic bootstrapModule of your root
@NgModule (the one with a bootstrap = [...] array). This knob is AOT-only β the AOT entry
is generated, so the root's identity has to live somewhere KSP can read it. The JIT entry is
your own hand-written main.kt, where you call bootstrapApplication or
platformBrowserDynamic().bootstrapModule(...) directly, so it needs no equivalent. (Compile
mode and bootstrap style are independent axes; the demo just pairs AOT with standalone and JIT
with classic to showcase both idioms.)
One task drives the whole pipeline - build the library, emit + copy the bridges,
npm install, AOT build:
$ ./gradlew :demo:aotBuild # optimized production bundle, no @angular/compiler
$ ./gradlew :demo:aotServe # ...or serve with live reload on http://localhost:4200
$ ./gradlew :demo:aotServe -PangularKt.port=4300 # ...on a custom portaotBuild chains every stage, but the sub-tasks (aotInit, copyAotBridges,
aotNpmInstall, β¦) can be run on their own β they all carry aot in the name, so the mode
resolves the same way; delete demo/build/ng-aot to force a clean re-scaffold.
Upgrading Angular is one knob - -PangularKt.angularVersion=22 (default 22, floor 16;
a bare major resolves to ~22.0.0). It pins the workspace, npm deps, and bridges together,
so bump-and-rebuild is the whole upgrade.
All issues and pull requests are welcome.
Thank you in advance
This project is licensed under the Apache License, Version 2.0
See the LICENSE.txt file for more details
Copyright 2020-2026 Vlad Kalyuzhnyu
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.