From 7a286f2e06b47f37d89b39b121e9dd4f04d28e32 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 5 Feb 2026 20:31:20 +0300 Subject: [PATCH] Infer map literal types --- docs/generics.md | 7 + .../kotlin/net/sergeych/lyng/Compiler.kt | 129 +++++++++++++++++- lynglib/src/commonTest/kotlin/TypesTest.kt | 16 +++ notes/new_lyng_type_system_spec.md | 1 + 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/docs/generics.md b/docs/generics.md index 2189bf5..8ab6ea4 100644 --- a/docs/generics.md +++ b/docs/generics.md @@ -37,6 +37,7 @@ Generic types are invariant by default. You can specify declaration-site varianc - Literals set obvious types (`1` is `Int`, `1.0` is `Real`, etc.). - Empty list literals default to `List` unless constrained by context. - Non-empty list literals infer element type as a union of element types. +- Map literals infer key and value types; named keys are `String`. Examples: @@ -44,6 +45,12 @@ Examples: val b = [1, "two", true] // List val c: List = [] // List + val m1 = { "a": 1, "b": 2 } // Map + val m2 = { "a": 1, "b": "x" } // Map + val m3 = { ...m1, "c": true } // Map + +Map spreads carry key/value types when possible. + Spreads propagate element type when possible: val base = [1, 2] diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index b3f1e1e..1a0773c 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -3146,6 +3146,7 @@ class Compiler( resolveReceiverTypeDecl(ref)?.let { return it } return when (ref) { is ListLiteralRef -> inferListLiteralTypeDecl(ref) + is MapLiteralRef -> inferMapLiteralTypeDecl(ref) is ConstRef -> inferTypeDeclFromConst(ref.constValue) else -> null } @@ -3168,6 +3169,11 @@ class Compiler( return TypeDecl.Generic("List", listOf(elementType), false) } + private fun inferMapLiteralTypeDecl(ref: MapLiteralRef): TypeDecl { + val (keyType, valueType) = inferMapLiteralEntryTypes(ref.entries()) + return TypeDecl.Generic("Map", listOf(keyType, valueType), false) + } + private fun inferListLiteralElementType(entries: List): TypeDecl { var nullable = false val collected = mutableListOf() @@ -3207,6 +3213,83 @@ class Compiler( return if (nullable) makeTypeDeclNullable(base) else base } + private fun inferMapLiteralEntryTypes(entries: List): Pair { + var keyNullable = false + var valueNullable = false + val keyTypes = mutableListOf() + val valueTypes = mutableListOf() + val seenKeys = mutableSetOf() + val seenValues = mutableSetOf() + + fun addKey(type: TypeDecl) { + val (base, isNullable) = stripNullable(type) + keyNullable = keyNullable || isNullable + if (base == TypeDecl.TypeAny) { + keyTypes.clear() + keyTypes += base + seenKeys.clear() + seenKeys += typeDeclKey(base) + return + } + val key = typeDeclKey(base) + if (seenKeys.add(key)) keyTypes += base + } + + fun addValue(type: TypeDecl) { + val (base, isNullable) = stripNullable(type) + valueNullable = valueNullable || isNullable + if (base == TypeDecl.TypeAny) { + valueTypes.clear() + valueTypes += base + seenValues.clear() + seenValues += typeDeclKey(base) + return + } + val key = typeDeclKey(base) + if (seenValues.add(key)) valueTypes += base + } + + for (entry in entries) { + when (entry) { + is MapLiteralEntry.Named -> { + addKey(TypeDecl.Simple("String", false)) + val vType = inferTypeDeclFromRef(entry.value) ?: return TypeDecl.TypeAny to TypeDecl.TypeAny + addValue(vType) + } + is MapLiteralEntry.Spread -> { + val mapType = inferTypeDeclFromRef(entry.ref) ?: return TypeDecl.TypeAny to TypeDecl.TypeAny + if (mapType is TypeDecl.Generic) { + val base = mapType.name.substringAfterLast('.') + if (base == "Map") { + val k = mapType.args.getOrNull(0) ?: TypeDecl.TypeAny + val v = mapType.args.getOrNull(1) ?: TypeDecl.TypeAny + addKey(k) + addValue(v) + } else { + return TypeDecl.TypeAny to TypeDecl.TypeAny + } + } else { + return TypeDecl.TypeAny to TypeDecl.TypeAny + } + } + } + } + + val keyBase = when { + keyTypes.isEmpty() -> TypeDecl.TypeAny + keyTypes.size == 1 -> keyTypes[0] + else -> TypeDecl.Union(keyTypes.toList(), nullable = false) + } + val valueBase = when { + valueTypes.isEmpty() -> TypeDecl.TypeAny + valueTypes.size == 1 -> valueTypes[0] + else -> TypeDecl.Union(valueTypes.toList(), nullable = false) + } + val finalKey = if (keyNullable) makeTypeDeclNullable(keyBase) else keyBase + val finalValue = if (valueNullable) makeTypeDeclNullable(valueBase) else valueBase + return finalKey to finalValue + } + private fun inferElementTypeFromSpread(ref: ObjRef): TypeDecl? { val listType = inferTypeDeclFromRef(ref) ?: return null if (listType == TypeDecl.TypeAny || listType == TypeDecl.TypeNullableAny) return listType @@ -3762,7 +3845,7 @@ class Compiler( is ObjChar -> TypeDecl.Simple("Char", false) is ObjNull -> TypeDecl.TypeNullableAny is ObjList -> TypeDecl.Generic("List", listOf(inferListElementTypeDecl(value)), false) - is ObjMap -> TypeDecl.Generic("Map", listOf(TypeDecl.TypeAny, TypeDecl.TypeAny), false) + is ObjMap -> TypeDecl.Generic("Map", listOf(inferMapKeyTypeDecl(value), inferMapValueTypeDecl(value)), false) is ObjClass -> TypeDecl.Simple(value.className, false) else -> TypeDecl.Simple(value.objClass.className, false) } @@ -3790,6 +3873,50 @@ class Compiler( return if (nullable) makeTypeDeclNullable(base) else base } + private fun inferMapKeyTypeDecl(map: ObjMap): TypeDecl { + var nullable = false + val options = mutableListOf() + val seen = mutableSetOf() + for (key in map.map.keys) { + if (key === ObjNull) { + nullable = true + continue + } + val keyType = inferRuntimeTypeDecl(key) + val base = stripNullable(keyType).first + val k = typeDeclKey(base) + if (seen.add(k)) options += base + } + val base = when { + options.isEmpty() -> TypeDecl.TypeAny + options.size == 1 -> options[0] + else -> TypeDecl.Union(options, nullable = false) + } + return if (nullable) makeTypeDeclNullable(base) else base + } + + private fun inferMapValueTypeDecl(map: ObjMap): TypeDecl { + var nullable = false + val options = mutableListOf() + val seen = mutableSetOf() + for (value in map.map.values) { + if (value === ObjNull) { + nullable = true + continue + } + val valueType = inferRuntimeTypeDecl(value) + val base = stripNullable(valueType).first + val k = typeDeclKey(base) + if (seen.add(k)) options += base + } + val base = when { + options.isEmpty() -> TypeDecl.TypeAny + options.size == 1 -> options[0] + else -> TypeDecl.Union(options, nullable = false) + } + return if (nullable) makeTypeDeclNullable(base) else base + } + private fun normalizeRuntimeTypeDecl(type: TypeDecl): TypeDecl { return when (type) { is TypeDecl.Union -> TypeDecl.Union(type.options.distinctBy { typeDeclKey(it) }, type.isNullable) diff --git a/lynglib/src/commonTest/kotlin/TypesTest.kt b/lynglib/src/commonTest/kotlin/TypesTest.kt index 2fa1d6e..5c8d676 100644 --- a/lynglib/src/commonTest/kotlin/TypesTest.kt +++ b/lynglib/src/commonTest/kotlin/TypesTest.kt @@ -201,6 +201,22 @@ class TypesTest { } } + @Test + fun testMapLiteralInferenceForBounds() = runTest { + eval(""" + fun acceptMap(m: Map) { } + acceptMap({ "a": 1, "b": 2 }) + val base = { "a": 1 } + acceptMap({ ...base, "b": 3 }) + """.trimIndent()) + assertFailsWith { + eval(""" + fun acceptMap(m: Map) { } + acceptMap({ "a": 1, "b": "x" }) + """.trimIndent()) + } + } + @Test fun testUnionTypeLists() = runTest { eval(""" diff --git a/notes/new_lyng_type_system_spec.md b/notes/new_lyng_type_system_spec.md index 10ac78f..be6f4e9 100644 --- a/notes/new_lyng_type_system_spec.md +++ b/notes/new_lyng_type_system_spec.md @@ -268,6 +268,7 @@ Normalize unions by removing duplicates and collapsing nullability (e.g. `Int|In Map inference: - `{ "a": 1, "b": 2 }` is `Map`. +- `{ "a": 1, "b": "x" }` is `Map`. - Empty map literal uses `{:}` (since `{}` is empty callable). - `extern class Map` so `Map()` is `Map()` unless contextual type overrides.