Skip to content

vladkalyuzhny/AngularKt

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

14 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

@AngularKt

Write Angular apps with pleasure on Kotlin ☺️

Benefits:

  • Kotlin itself ✨ - no ;, no this, real null safety
  • Annotations 1:1 with Angular TS (@Component/@Injectable/@NgModule/@Directive/@Pipe)
  • JIT and AOT compilation - AOT runs through Angular's own compiler, so @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)
  • Coroutines instead of RxJS in your app code - kotlinx.coroutines Flow for Router params, Forms valueChanges, signals bridge
  • Ktor + kotlinx.serialization as a Kotlin-native HTTP stack (typed JSON, no HttpClient boilerplate - or opt into Angular's HttpClient)
  • Support of Multiplatform Projects - share code with JVM/Native
  • Gradle plugin drives the build - applies the Kotlin/JS + KSP plugins, serves the JIT app, and runs the whole AOT pipeline from one task

Installation

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)

Basic example

@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

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 AppRoutingModule

The 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 routes

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 ProductRoutes

Lazy loading

loadChildren = 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:

  • AOT β€” true code-splitting. The processor emits the () => import(...).then(...) with no static import, so Angular's toolchain splits the target into a chunk fetched only on the first visit to /admin.
  • JIT β€” one webpack bundle, so the target is registered eagerly and 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

  • standalone LazyStandaloneRoutes).

Compilation modes: JIT and AOT

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 port

AOT (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:

  1. :demo compiles to an importable ESM library (binaries.library()) plus TypeScript definitions; component/service logic stays 100% Kotlin.
  2. The KSP processor emits one thin TypeScript bridge per @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), ...).
  3. An Angular workspace is generated into 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 port

aotBuild 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.

Contribute

All issues and pull requests are welcome.
Thank you in advance

License

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.

About

Write Angular apps with pleasure on Kotlin 😌

Topics

Resources

License

Stars

1 star

Watchers

1 watching

Forks

Packages

 
 
 

Contributors