Implement nullable shorthand for params

This commit is contained in:
Sergey Chernov 2026-02-05 19:14:03 +03:00
parent 7e538ed8aa
commit 9db5b12c31
3 changed files with 205 additions and 5 deletions

View File

@ -224,6 +224,9 @@ This also prevents chain assignments so use parentheses:
## Nullability
Nullability is part of the type. `String` is non-null, `String?` is nullable. Use `!!` to assert non-null and throw
`NullReferenceException` if the value is `null`.
When the value is `null`, it might throws `NullReferenceException`, the name is somewhat a tradition. To avoid it
one can check it against null or use _null coalescing_. The null coalescing means, if the operand (left) is null,
the operation won't be performed and the result will be null. Here is the difference:
@ -242,6 +245,9 @@ the operation won't be performed and the result will be null. Here is the differ
assert( ref?() == null )
>>> void
Note: `?.` is still a typed operation. The receiver must have a compile-time type that declares the member; if the
receiver is `Object`, cast it first or declare a more specific type.
There is also "elvis operator", null-coalesce infix operator '?:' that returns rvalue if lvalue is `null`:
null ?: "nothing"
@ -425,8 +431,6 @@ Almost the same, using `val`:
val foo = 1
foo += 1 // this will throw exception
# Constants
Same as in kotlin:
val HalfPi = π / 2
@ -434,6 +438,133 @@ Same as in kotlin:
Note using greek characters in identifiers! All letters allowed, but remember who might try to read your script, most
likely will know some English, the rest is the pure uncertainty.
# Types and inference
Lyng uses Kotlin-style static types with inference. You can always write explicit types, but in most places the compiler
can infer them from literals, defaults, and flow analysis.
## Type annotations
Use `:` to specify a type:
var x: Int = 10
val label: String = "count"
fun clamp(x: Int, min: Int, max: Int): Int { ... }
`Object` is the top type. If you omit a type and there is no default value, the parameter is `Object` by default:
fun show(x) { println(x) } // x is Object
For nullable types, add `?`:
fun showMaybe(x: Object?) { ... }
fun parseInt(s: String?): Int? { ... }
There is also a nullable shorthand for untyped parameters and constructor args: `x?` means `x: Object?`.
It cannot be combined with an explicit type annotation.
class A(x?) { ... } // x: Object?
fun f(x?) { x == null } // x: Object?
`void` is a singleton value of the class `Void`. `Void` can be used as an explicit return type:
fun log(msg): Void { println(msg); void }
`Null` is the class of `null`. It is a singleton type and mostly useful for type inference results.
## Type inference
The compiler infers types from:
- literals: `1` is `Int`, `1.0` is `Real`, `"s"` is `String`, `'c'` is `Char`
- defaults: `fun f(x=1, name="n")` infers `x: Int`, `name: String`
- assignments: `val x = call()` uses the return type of `call`
- returns and branches: the result type of a block is the last expression, and if any branch is nullable,
the inferred type becomes nullable
- numeric ops: `Int` and `Real` stay `Int` when both sides are `Int`, and promote to `Real` on mixed arithmetic
Examples:
fun inc(x=0) = x + 1 // (Int)->Int
fun maybe(flag) { if(flag) 1 else null } // ()->Int?
Untyped locals are allowed, but their type is fixed on the first assignment:
var x
x = 1 // x becomes Int
x = "one" // compile-time error
var y = null // y is Object?
val z = null // z is Null
Empty list/map literals default to `List<Object>` and `Map<Object,Object>` until a more specific type is known:
val xs = [] // List<Object>
val ys: List<Int> = [] // List<Int>
## Flow analysis
Lyng uses flow analysis to narrow types inside branches:
fun len(x: String?): Int {
if( x == null ) return 0
// x is String (non-null) in this branch
return x.length
}
`is` checks and `when` branches also narrow types:
fun kind(x: Object) {
if( x is Int ) return "int"
if( x is String ) return "string"
return "other"
}
Narrowing is local to the branch; after the branch, the original type is restored.
## Casts and unknown types
Use `as` for explicit casts. The compiler inserts casts only when it can be valid and necessary. If a cast fails at
runtime, it throws `ClassCastException`. If the value is nullable, `as T` implies a non-null assertion.
Member access is resolved at compile time. Only members of `Object` are available on unknown types; non-Object members
require an explicit cast:
fun f(x) { // x is Object
x.toString() // ok (Object member)
x.size() // compile-time error
(x as List).size() // ok
}
This avoids runtime name-resolution fallbacks; all symbols must be known at compile time.
## Generics and bounds
Generic parameters are declared with `<...>`:
fun id<T>(x: T): T = x
class Box<T>(val value: T)
Bounds use `:` and can combine with `&` (intersection) and `|` (union):
fun sum<T: Int | Real>(x: T, y: T) = x + y
class Named<T: Iterable & Comparable>(val data: T)
Type arguments are usually inferred from call sites:
val b = Box(10) // Box<Int>
val s = id("ok") // T is String
## Variance
Generic types are invariant by default, so `List<Int>` is not a `List<Object>`.
Use declaration-site variance when you need it:
class Source<out T>(val value: T)
class Sink<in T> { fun accept(x: T) { ... } }
`out` makes the type covariant (only produced), `in` makes it contravariant (only consumed).
# Defining functions
fun check(amount) {
@ -1330,7 +1461,7 @@ than enum arrays, until `Lynon.encodeTyped` will be implemented.
var result = null // here we will store the result
>>> null
# Integral data types
# Built-in types
| type | description | literal samples |
|--------|---------------------------------|---------------------|
@ -1340,6 +1471,7 @@ than enum arrays, until `Lynon.encodeTyped` will be implemented.
| Char | single unicode character | `'S'`, `'\n'` |
| String | unicode string, no limits | "hello" (see below) |
| List | mutable list | [1, "two", 3] |
| Object | top type for all values | |
| Void | no value could exist, singleton | void |
| Null | missing value, singleton | null |
| Fn | callable type | |

View File

@ -2641,8 +2641,31 @@ class Compiler(
access
}
// Nullable shorthand: "x?" means Object? (or makes explicit type nullable)
var nullableHint = false
val nullableHintPos = cc.savePos()
cc.skipWsTokens()
if (cc.peekNextNonWhitespace().type == Token.Type.QUESTION) {
cc.nextNonWhitespace()
nullableHint = true
} else {
cc.restorePos(nullableHintPos)
}
// type information (semantic + mini syntax)
val (typeInfo, miniType) = parseTypeDeclarationWithMini()
if (nullableHint) {
val afterHint = cc.savePos()
cc.skipWsTokens()
if (cc.peekNextNonWhitespace().type == Token.Type.COLON) {
throw ScriptError(t.pos, "nullable shorthand '?' cannot be combined with explicit type")
}
cc.restorePos(afterHint)
}
var (typeInfo, miniType) = parseTypeDeclarationWithMini()
if (nullableHint) {
typeInfo = makeTypeDeclNullable(typeInfo)
miniType = miniType?.let { makeMiniTypeNullable(it) }
}
var defaultValue: Statement? = null
cc.ifNextIs(Token.Type.ASSIGN) {
@ -2713,6 +2736,31 @@ class Compiler(
return parseTypeUnionWithMini()
}
private fun makeTypeDeclNullable(type: TypeDecl): TypeDecl {
if (type.isNullable) return type
return when (type) {
TypeDecl.TypeAny -> TypeDecl.TypeNullableAny
TypeDecl.TypeNullableAny -> type
is TypeDecl.Function -> type.copy(nullable = true)
is TypeDecl.TypeVar -> type.copy(nullable = true)
is TypeDecl.Union -> type.copy(nullable = true)
is TypeDecl.Intersection -> type.copy(nullable = true)
is TypeDecl.Simple -> TypeDecl.Simple(type.name, true)
is TypeDecl.Generic -> TypeDecl.Generic(type.name, type.args, true)
}
}
private fun makeMiniTypeNullable(type: MiniTypeRef): MiniTypeRef {
return when (type) {
is MiniTypeName -> type.copy(nullable = true)
is MiniGenericType -> type.copy(nullable = true)
is MiniFunctionType -> type.copy(nullable = true)
is MiniTypeVar -> type.copy(nullable = true)
is MiniTypeUnion -> type.copy(nullable = true)
is MiniTypeIntersection -> type.copy(nullable = true)
}
}
private fun parseTypeUnionWithMini(): Pair<TypeDecl, MiniTypeRef> {
var left = parseTypeIntersectionWithMini()
val options = mutableListOf(left)

View File

@ -146,4 +146,24 @@ class TypesTest {
println("Result: "+limit)
""".trimIndent())
}
@Test
fun testNullableHints() = runTest {
eval("""
// nullable, without type os Object?
class N(x=null)
assertEquals(null, N().x)
assertEquals("foo", N("foo").x)
// nullable shortcut (x?)is same as (var x: Object?)
class A(x?)
assertEquals(null, A(null).x)
assertEquals("ok", A("ok").x)
// same in function: x? is a shortcut for (x: Object?)
fun f(x?) = x?.let { x + "!" }
assertEquals(null, f(null))
assertEquals("ok!", f("ok"))
""".trimIndent()
)
}
}