Skip to content

Instantly share code, notes, and snippets.

@aSemy
Last active May 7, 2024 13:59
Show Gist options
  • Save aSemy/65b07d7b442375ea960f5724f8ec3988 to your computer and use it in GitHub Desktop.
Save aSemy/65b07d7b442375ea960f5724f8ec3988 to your computer and use it in GitHub Desktop.
Kotlin/JS TypeUnion properties

Hot new strat for defining type-union properties in Kotlin/JS

Demo

// SomeConfig is an external TypeScript interface
// it has a property with a type-union type, but don't implement it as a member...
external interface SomeConfig {
  // multiProp: number | string | Extension | ((string) => Extension | null)
}

external interface Extension // some external TypeScript interface

// instead, implement multiProp as an extension property using a custom delegate, TypeUnion
// and specify the types within the union
val SomeConfig.multiProp by TypeUnion<Int, String, Extension, (String) -> Extension?>()

// example usage
fun foo(config: SomeConfig) {
  // thanks to some assignment plugin magic, the types specified in the TypeUnion delegate can be assigned
  config.multiProp = 123
  config.multiProp = "a b c"
  config.multiProp = object : Extension {}
  config.multiProp = { arg -> if (arg == "blah") object : Extension {} else null }

  config.multiProp = true // ERROR: Boolean is not specified TypeUnion
}

Set up

  1. Apply and activate Kotlin Assigment Plugin

    (org.jetbrains.kotlin-wrappers:kotlin-js provides some helpful utils, like jso())

    // build.gradle.kts
    plugins {
      kotlin("multiplatform") version "1.9.0"
      id("org.jetbrains.kotlin.plugin.assignment") version "1.9.0"
    }
    
    kotlin {
      js(IR)
      
      sourceSets {
        val jsTest by getting {
          dependencies {
          val kotlinWrappersVersion = "1.0.0-pre.601"
          implementation(project.dependencies.platform("org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom:$kotlinWrappersVersion"))
          implementation("org.jetbrains.kotlin-wrappers:kotlin-js")
        }
      }
    }
    
    assignment {
      annotation("AssignmentOverload")
    }
  2. Define TypeUnion helpers

    //TypeUnions.kt
    
    import TypeUnionProperty.*
    import kotlin.properties.PropertyDelegateProvider
    import kotlin.properties.ReadOnlyProperty
    import kotlin.reflect.KProperty
    
    fun interface TypeUnion<out TUP : TypeUnionProperty> : ReadOnlyProperty<Any?, TUP>
    
    fun <A> TypeUnion(
      @Suppress("UNUSED_PARAMETER", "LocalVariableName")
      _marker: FnMarker1 = FnMarker1
    ) =
      TypeUnion { thisRef: Any?, prop -> TypeUnionProperty1<A>(thisRef, prop.name) }
    
    fun <A, B> TypeUnion(
      @Suppress("UNUSED_PARAMETER", "LocalVariableName")
      _marker: FnMarker2 = FnMarker2
    ) =
      TypeUnion { thisRef: Any?, prop -> TypeUnionProperty2<A, B>(thisRef, prop.name) }
    
    fun <A, B, C> TypeUnion(
      @Suppress("UNUSED_PARAMETER", "LocalVariableName")
      _marker: FnMarker3 = FnMarker3
    ) =
      TypeUnion { thisRef: Any?, prop -> TypeUnionProperty3<A, B, C>(thisRef, prop.name) }
    
    fun <A, B, C, D> TypeUnion(
      @Suppress("UNUSED_PARAMETER", "LocalVariableName")
      _marker: FnMarker4 = FnMarker4
    ) =
      TypeUnion { thisRef: Any?, prop -> TypeUnionProperty4<A, B, C, D>(thisRef, prop.name) }
    
    fun interface TypeUnionPropertyDelegateProvider<TUP : TypeUnionProperty> : PropertyDelegateProvider<Any?, TUP> {
      override fun provideDelegate(thisRef: Any?, property: KProperty<*>): TUP = provideDelegate(thisRef, property.name)
    
      fun provideDelegate(thisRef: Any?, propertyName: String): TUP
    }
    
    
    annotation class AssignmentOverload
    
    @AssignmentOverload
    sealed class TypeUnionProperty {
      protected abstract val owner: dynamic
      protected abstract val propertyName: String
    
      // create some dummy discriminators to use as args in the TypeUnion functions,
      // because Kotlin can't differentiate between generic functions unless the args are different
      object FnMarker1
      object FnMarker2
      object FnMarker3
      object FnMarker4
    
      companion object
    }
    
    abstract class TypeUnionProperty1<A> : TypeUnionProperty() {
      fun assign(value: A) {
        owner[propertyName] = value
      }
    }
    
    abstract class TypeUnionProperty2<A, B> : TypeUnionProperty1<A>() {
      fun assign(value: B) {
        owner[propertyName] = value
      }
    }
    
    abstract class TypeUnionProperty3<A, B, C> : TypeUnionProperty2<A, B>() {
      fun assign(value: C) {
        owner[propertyName] = value
      }
    }
    
    abstract class TypeUnionProperty4<A, B, C, D> : TypeUnionProperty3<A, B, C>() {
      fun assign(value: D) {
        owner[propertyName] = value
      }
    }
    
    fun <A> TypeUnionProperty1(owner: dynamic, propertyName: String) =
      object : TypeUnionProperty1<A>() {
        override val owner: dynamic = owner
        override val propertyName: String = propertyName
      }
    
    fun <A, B> TypeUnionProperty2(owner: dynamic, propertyName: String) =
      object : TypeUnionProperty2<A, B>() {
        override val owner: dynamic = owner
        override val propertyName: String = propertyName
      }
    
    fun <A, B, C> TypeUnionProperty3(owner: dynamic, propertyName: String) =
      object : TypeUnionProperty3<A, B, C>() {
        override val owner: dynamic = owner
        override val propertyName: String = propertyName
      }
    
    fun <A, B, C, D> TypeUnionProperty4(owner: dynamic, propertyName: String) =
      object : TypeUnionProperty4<A, B, C, D>() {
        override val owner: dynamic = owner
        override val propertyName: String = propertyName
      }

Basic tests

// TypeUnionTests.kt
import js.core.jso
import kotlin.test.Test
import kotlin.test.assertEquals


/**
 * ```typescript
 * interface SomeConfig {
 *   multiProp: number | string | Extension | ((string) => Extension | null)
 * }
 * ```
 */
external interface SomeConfig

val SomeConfig.multiProp by TypeUnion<Int, String, Extension, (String) -> Extension?>()

/**
 * ```typescript
 * type Extension = // ...
 * ```
 */
external interface Extension

class TypeUnionTest {
  @Test
  fun testTypeUnions() {
    assertEquals("{\"multiProp\":123}", JSON.stringify(
      jso<SomeConfig>{
        multiProp = 123
      }
    ))
    assertEquals("{\"multiProp\":\"a b c\"}", JSON.stringify(
      jso<SomeConfig>{
        multiProp = "a b c"
      }
    ))
    assertEquals("{\"multiProp\":{\"x_1\":\"123\"}}", JSON.stringify(
      jso<SomeConfig>{
        multiProp = object : Extension {
          val x = "123"
        }
      }
    ))
  }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment