From 7fe501e4f5166f56923ab8964e4141430dc9ae4e Mon Sep 17 00:00:00 2001 From: noti0na1 Date: Mon, 25 Aug 2025 15:04:53 +0200 Subject: [PATCH 1/5] Initial proposal for Flexible Types in TASTy format --- content/flexible-types.md | 163 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 content/flexible-types.md diff --git a/content/flexible-types.md b/content/flexible-types.md new file mode 100644 index 00000000..ca561fa7 --- /dev/null +++ b/content/flexible-types.md @@ -0,0 +1,163 @@ +--- +layout: sip +permalink: /sips/:title.html +stage: implementation +status: waiting-for-implementation +title: SIP-XX - Flexible Types for TASTy Format +--- + +**By: Scala 3 Compiler Team** + +## History + +| Date | Version | +|---------------|---------------| +| Aug 22nd 2025 | Initial SIP | + +## Summary + +This proposal specifies the encoding of Flexible Types in the TASTy (Typed Abstract Syntax Tree) format. Flexible Types are a compiler-internal mechanism introduced to improve Java and legacy Scala code (compiled without explicit nulls) interoperability under explicit nulls (`-Yexplicit-nulls`). They allow reference types from Java libraries and legacy Scala codeto be treated as either nullable or non-nullable depending on the usage. + +Flexible Types provide a type-safe bridge between implicit nullability and Scala's explicit null system, enabling smoother interoperation while maintaining safety guarantees where possible. This SIP formalizes their representation in TASTy to ensure consistent serialization and deserialization across compiler versions. + +This is not a SIP for explicit nulls itself, but only for standardizing the representation of flexible types in the TASTy format. + +## Motivation + +### Background: Explicit Nulls and Java/Legacy Scala Interoperability + +When explicit nulls are enabled (`-Yexplicit-nulls`), Scala's type system changes so that `Null` is no longer a subtype of reference types. Instead, nullable types must be explicitly declared as union types like `String | Null`. This creates a safer type system but introduces friction when interoperating with: + +1. **Java libraries**, where all reference types are implicitly nullable +2. **Legacy Scala code**, compiled without explicit nulls, where reference types could historically contain `null` values + +### The Problem + +Consider a Java method with signature `String getName()` or a legacy Scala method `def getName(): String` compiled without explicit nulls. Under explicit nulls, + +1. **If we type it as `String`**: We lose safety because the Java method or legacy Scala method might actually return `null` +2. **If we type it as `String | Null`**: We burden users with constant null checks even when the method is known to never return null in practice + +### Current Workarounds and Their Limitations + +Before Flexible Types, the compiler would either: +- Force all Java and legacy Scala reference types to be nullable (`String | Null`), leading to excessive null-checking +- Provide unsafe nulls mode (`-language:unsafeNulls`) which disables safety checks entirely + +Both approaches are suboptimal for large codebases that want gradual migration to explicit nulls. + +## Proposed solution + +### High-level overview + +We introduce Flexible Types as a compiler-internal representation that allows a type to be treated as both nullable and non-nullable depending on the context. A Flexible Type `T?` (notation borrowed from Kotlin's platform types) has bounds `T | Null <: T? <: T`, meaning: + +- It can accept both `T` and `T | Null` values +- It can be used where either `T` or `T | Null` is expected +- It can be called with member functions of `T`, but may throw `NullPointerException` at runtime if the value is actually null + +Flexible Types are **non-denotable** - users cannot write them explicitly in source code. Only the compiler creates them during Java interoperability and when consuming legacy Scala code. + +They may appear in type signatures because of type inference. +Due to their non-denotable nature, we do not recommend exposing Flexible Types in public APIs or library interfaces. +We will implement a mechanism to warn users when Flexible Types are exposed at field or method boundaries. + +### Specification + +#### TASTy Format Extension + +We extend the TASTy format with a new type tag (`193`): + +``` +FLEXIBLEtype Length underlying_Type -- (underlying)? +``` + +The tag is followed by the length of the underlying type and the underlying type itself. + +The underlying type `T` represents the upper bound of the flexible type, and the lower bound is implicitly `T | Null`. + +#### Subtyping Rules + +Flexible Types are designed to introduce a controlled soundness hole to enable practical interoperability. Their subtyping rules differ from regular types: + +The subtyping relationships for Flexible Type `T?` are: + +1. **Lower bound**: `T | Null <: T?` +2. **Upper bound**: `T? <: T` + +Implementation in `TypeComparer.scala`: + +```scala +// In firstTry method (line ~901) +case tp2: FlexibleType => + recur(tp1, tp2.lo) // tp1 <: FlexibleType.lo (which is T | Null) + +// In thirdTry method (line ~1098) +case tp1: FlexibleType => + recur(tp1.hi, tp2) // FlexibleType.hi (which is T) <: tp2 +``` + +#### Member Selection + +All members of the underlying type `T` are considered to be members of the flexible type `T?`. +Selecting a member from `T?` may throw `NullPointerException` at runtime if the actual value is `null`. + +#### Erasure + +The erased type of `T?` is the erased type of the underlying type `T`. + +### Compatibility + +Flexible Types preserve binary compatibility because: + +1. **Erasure compatibility**: Flexible Types erase to their underlying types, producing identical bytecode +2. **Forward compatibility**: Compilers not using explicit nulls will treat flexible types as their underlying types +3. **Backward compatibility**: Older compilers that don't recognize the `FLEXIBLEtype` tag will treat it as the underlying type + +## Implementation + +Flexible Types have already been implemented in the latest Scala 3 compiler. The current implementation includes: + +### Core Implementation Status + +1. **Type System Integration**: The `FlexibleType` case class and its core subtyping rules have been implemented in `Types.scala` +2. **Subtyping Logic**: The subtyping algorithms in `TypeComparer.scala` handle flexible types according to the specification +3. **TASTy Serialization**: The `FLEXIBLEtype` tag (`193`) is fully implemented in `TastyFormat.scala` and supports serialization/deserialization +4. **Nullification Rules**: Both Java classes and legacy Scala code are processed with flexible type nullification when `-Yexplicit-nulls` is enabled + +### Planned Improvements + +The following enhancements are planned for upcoming releases: + +1. Refined nullification rules for edge cases. + +2. Stronger TASTy forward/backward compatibility guarantees, including updating tasty-mima and tasty-query. + +3. A compiler warning when flexible types appear in public API boundaries. + +## Related information + +- [**Explicit Nulls**](https://docs.scala-lang.org/scala3/reference/experimental/explicit-nulls.html): The experimental explicit nulls feature that motivated the need for flexible types. +- [**Kotlin Platform Types**](https://kotlinlang.org/docs/java-interop.html#null-safety-and-platform-types): Direct inspiration for the flexible types concept, providing similar interoperability between Kotlin's null safety and Java's implicit nullability. + +## FAQ + +### Why are Flexible Types not user-denotable? + +Making flexible types non-denotable prevents users from depending on them in API boundaries. This ensures: + +1. APIs remain clean and explicit about nullability +2. Flexible types serve only as an interop mechanism, not a permanent type system feature +3. Migration path remains clear - eventually all types should be either `T` or `T | Null` + +### Can Flexible Types be nested? + +No, flexible types cannot be nested. `FlexibleType(FlexibleType(T))` is normalized to `FlexibleType(T)`. +This prevents unnecessarily complex type representations. + +### What happens with generic wildcards? + +Generic wildcards are handled specially: +- `List` becomes `List[?]?` (flexible type with a wildcard bounds) +- `List` becomes `List[? <: String?]?` +- The outer container is made flexible to handle implicit nullability From f24d9fd2e3b58fd5504c2172f7d14ea056a8b625 Mon Sep 17 00:00:00 2001 From: noti0na1 Date: Mon, 25 Aug 2025 15:12:44 +0200 Subject: [PATCH 2/5] Small adjustment --- content/flexible-types.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/content/flexible-types.md b/content/flexible-types.md index ca561fa7..666a852b 100644 --- a/content/flexible-types.md +++ b/content/flexible-types.md @@ -2,12 +2,10 @@ layout: sip permalink: /sips/:title.html stage: implementation -status: waiting-for-implementation +status: under-review title: SIP-XX - Flexible Types for TASTy Format --- -**By: Scala 3 Compiler Team** - ## History | Date | Version | @@ -130,9 +128,7 @@ Flexible Types have already been implemented in the latest Scala 3 compiler. The The following enhancements are planned for upcoming releases: 1. Refined nullification rules for edge cases. - 2. Stronger TASTy forward/backward compatibility guarantees, including updating tasty-mima and tasty-query. - 3. A compiler warning when flexible types appear in public API boundaries. ## Related information From 685fe1985e95784778b68856afe6315acafd74c1 Mon Sep 17 00:00:00 2001 From: noti0na1 Date: Thu, 11 Sep 2025 14:15:15 +0200 Subject: [PATCH 3/5] Polish according to review --- content/flexible-types.md | 95 ++++++++++++++++++--------------------- 1 file changed, 43 insertions(+), 52 deletions(-) diff --git a/content/flexible-types.md b/content/flexible-types.md index 666a852b..1198f90e 100644 --- a/content/flexible-types.md +++ b/content/flexible-types.md @@ -14,7 +14,7 @@ title: SIP-XX - Flexible Types for TASTy Format ## Summary -This proposal specifies the encoding of Flexible Types in the TASTy (Typed Abstract Syntax Tree) format. Flexible Types are a compiler-internal mechanism introduced to improve Java and legacy Scala code (compiled without explicit nulls) interoperability under explicit nulls (`-Yexplicit-nulls`). They allow reference types from Java libraries and legacy Scala codeto be treated as either nullable or non-nullable depending on the usage. +This proposal specifies the encoding of Flexible Types in the TASTy (Typed Abstract Syntax Tree) format. Flexible Types are an Internal Type (see §3.1 of the Scala 3 language specification) introduced to improve interoperability with Java and legacy Scala code (compiled without explicit nulls) under explicit nulls (`-Yexplicit-nulls`). They allow reference types from Java libraries and legacy Scala code to be treated as either nullable or non-nullable depending on the usage. Flexible Types provide a type-safe bridge between implicit nullability and Scala's explicit null system, enabling smoother interoperation while maintaining safety guarantees where possible. This SIP formalizes their representation in TASTy to ensure consistent serialization and deserialization across compiler versions. @@ -48,61 +48,75 @@ Both approaches are suboptimal for large codebases that want gradual migration t ### High-level overview -We introduce Flexible Types as a compiler-internal representation that allows a type to be treated as both nullable and non-nullable depending on the context. A Flexible Type `T?` (notation borrowed from Kotlin's platform types) has bounds `T | Null <: T? <: T`, meaning: +We introduce Flexible Types as an Internal Type that allows a type to be treated as both nullable and non-nullable depending on the context. Informally, we write a flexible type as `T?` (notation inspired by Kotlin platform types). The following subtyping relationships hold: `T | Null <: T?` and `T? <: T`, meaning: - It can accept both `T` and `T | Null` values - It can be used where either `T` or `T | Null` is expected -- It can be called with member functions of `T`, but may throw `NullPointerException` at runtime if the value is actually null +- It can be used as the prefix in accesses to members of `T`, but may throw `NullPointerException` at runtime if the value is actually null Flexible Types are **non-denotable** - users cannot write them explicitly in source code. Only the compiler creates them during Java interoperability and when consuming legacy Scala code. They may appear in type signatures because of type inference. Due to their non-denotable nature, we do not recommend exposing Flexible Types in public APIs or library interfaces. -We will implement a mechanism to warn users when Flexible Types are exposed at field or method boundaries. +We have implemented a mechanism to warn users when Flexible Types are exposed at public (or protected) field or method boundaries. ### Specification -#### TASTy Format Extension +#### Abstract Syntax (Spec Addendum) -We extend the TASTy format with a new type tag (`193`): +We extend the abstract syntax of (internal) types with a new form: ``` -FLEXIBLEtype Length underlying_Type -- (underlying)? +InternalType ::= ... | FlexibleType +FlexibleType ::= Type ‘?’ ``` -The tag is followed by the length of the underlying type and the underlying type itself. +`T?` (rendered informally in spec; there is no concrete syntax) designates a flexible type whose underlying type is `T`. + +Normalization: `(T?)? = T?` (flexible types do not nest). + +#### Conformance (Extension to §3.6.1) + +We extend the conformance relation (<:) with the following two derivation rules: + +1. `S = U` and `T = U?` +2. `S = Null` and `T = U?` +3. `S = U?` and `T = U` + +We can also equivalence: `U =:= U?` and `U | Null =:= U?`, +even though `U | Null` and `U` may be not equivalent under explicit nulls. -The underlying type `T` represents the upper bound of the flexible type, and the lower bound is implicitly `T | Null`. +#### Member Selection + +Member selection is treated as if `T?` were `T` (so `memberType(T?, m, p)` delegates to `memberType(T, m, p)`). + +#### TASTy Format Extension -#### Subtyping Rules +We reserve a new TASTy type tag (`193`) to encode flexible types: -Flexible Types are designed to introduce a controlled soundness hole to enable practical interoperability. Their subtyping rules differ from regular types: +``` +FLEXIBLEtype Length underlying_Type +``` -The subtyping relationships for Flexible Type `T?` are: +Decoders that do not recognize `FLEXIBLEtype` may safely treat it as its underlying type `T` (erasure compatibility is preserved). -1. **Lower bound**: `T | Null <: T?` -2. **Upper bound**: `T? <: T` +#### Subtyping Rules in Compiler -Implementation in `TypeComparer.scala`: +For implementors: the two conformance rules above are implemented in `TypeComparer.scala` as follows: ```scala -// In firstTry method (line ~901) +// In firstTry method case tp2: FlexibleType => recur(tp1, tp2.lo) // tp1 <: FlexibleType.lo (which is T | Null) -// In thirdTry method (line ~1098) +// In thirdTry method case tp1: FlexibleType => recur(tp1.hi, tp2) // FlexibleType.hi (which is T) <: tp2 ``` -#### Member Selection - -All members of the underlying type `T` are considered to be members of the flexible type `T?`. -Selecting a member from `T?` may throw `NullPointerException` at runtime if the actual value is `null`. - -#### Erasure +#### Type Erasure (Extension to §3.8) -The erased type of `T?` is the erased type of the underlying type `T`. +Erasure is extended with: `|T?| = |T|` (i.e., identical to the erasure of its underlying type). ### Compatibility @@ -118,10 +132,10 @@ Flexible Types have already been implemented in the latest Scala 3 compiler. The ### Core Implementation Status -1. **Type System Integration**: The `FlexibleType` case class and its core subtyping rules have been implemented in `Types.scala` -2. **Subtyping Logic**: The subtyping algorithms in `TypeComparer.scala` handle flexible types according to the specification -3. **TASTy Serialization**: The `FLEXIBLEtype` tag (`193`) is fully implemented in `TastyFormat.scala` and supports serialization/deserialization -4. **Nullification Rules**: Both Java classes and legacy Scala code are processed with flexible type nullification when `-Yexplicit-nulls` is enabled +1. **New Type and Subtyping Rules**: The `FlexibleType` case class and its subtyping rules have been implemented +2. **TASTy Serialization**: The `FLEXIBLEtype` tag (`193`) is fully implemented in `TastyFormat.scala` and supports serialization/deserialization +3. **Nullification Rules**: Both Java classes and legacy Scala code are processed with flexible type nullification when `-Yexplicit-nulls` is enabled +4. **Public API Warnings**: A warning mechanism is in place to alert users when flexible types appear in public or protected API boundaries ### Planned Improvements @@ -129,31 +143,8 @@ The following enhancements are planned for upcoming releases: 1. Refined nullification rules for edge cases. 2. Stronger TASTy forward/backward compatibility guarantees, including updating tasty-mima and tasty-query. -3. A compiler warning when flexible types appear in public API boundaries. ## Related information - [**Explicit Nulls**](https://docs.scala-lang.org/scala3/reference/experimental/explicit-nulls.html): The experimental explicit nulls feature that motivated the need for flexible types. -- [**Kotlin Platform Types**](https://kotlinlang.org/docs/java-interop.html#null-safety-and-platform-types): Direct inspiration for the flexible types concept, providing similar interoperability between Kotlin's null safety and Java's implicit nullability. - -## FAQ - -### Why are Flexible Types not user-denotable? - -Making flexible types non-denotable prevents users from depending on them in API boundaries. This ensures: - -1. APIs remain clean and explicit about nullability -2. Flexible types serve only as an interop mechanism, not a permanent type system feature -3. Migration path remains clear - eventually all types should be either `T` or `T | Null` - -### Can Flexible Types be nested? - -No, flexible types cannot be nested. `FlexibleType(FlexibleType(T))` is normalized to `FlexibleType(T)`. -This prevents unnecessarily complex type representations. - -### What happens with generic wildcards? - -Generic wildcards are handled specially: -- `List` becomes `List[?]?` (flexible type with a wildcard bounds) -- `List` becomes `List[? <: String?]?` -- The outer container is made flexible to handle implicit nullability +- [**Kotlin Platform Types**](https://kotlinlang.org/docs/java-interop.html#null-safety-and-platform-types): Direct inspiration for the flexible types concept, providing similar interoperability between Kotlin's null safety and Java's implicit nullability. \ No newline at end of file From 6f7f2a77a3a434d6f34d37b269642e2cbd65cf2e Mon Sep 17 00:00:00 2001 From: noti0na1 Date: Thu, 11 Sep 2025 14:31:38 +0200 Subject: [PATCH 4/5] Update title --- content/flexible-types.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/flexible-types.md b/content/flexible-types.md index 1198f90e..c7bea5e7 100644 --- a/content/flexible-types.md +++ b/content/flexible-types.md @@ -3,7 +3,7 @@ layout: sip permalink: /sips/:title.html stage: implementation status: under-review -title: SIP-XX - Flexible Types for TASTy Format +title: SIP-XX - Adding Flexible Types as Internal Type to Scala 3 Spec --- ## History @@ -14,7 +14,7 @@ title: SIP-XX - Flexible Types for TASTy Format ## Summary -This proposal specifies the encoding of Flexible Types in the TASTy (Typed Abstract Syntax Tree) format. Flexible Types are an Internal Type (see §3.1 of the Scala 3 language specification) introduced to improve interoperability with Java and legacy Scala code (compiled without explicit nulls) under explicit nulls (`-Yexplicit-nulls`). They allow reference types from Java libraries and legacy Scala code to be treated as either nullable or non-nullable depending on the usage. +This proposal specifies the representation of Flexible Types and encoding in the TASTy (Typed Abstract Syntax Tree) format. Flexible Types are an Internal Type (see §3.1 of the Scala 3 language specification) introduced to improve interoperability with Java and legacy Scala code (compiled without explicit nulls) under explicit nulls (`-Yexplicit-nulls`). They allow reference types from Java libraries and legacy Scala code to be treated as either nullable or non-nullable depending on the usage. Flexible Types provide a type-safe bridge between implicit nullability and Scala's explicit null system, enabling smoother interoperation while maintaining safety guarantees where possible. This SIP formalizes their representation in TASTy to ensure consistent serialization and deserialization across compiler versions. From 50eae024af73941ee659d96422cee71d41181b83 Mon Sep 17 00:00:00 2001 From: noti0na1 Date: Wed, 17 Sep 2025 01:53:33 +0200 Subject: [PATCH 5/5] Explain type erasure rule --- content/flexible-types.md | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/content/flexible-types.md b/content/flexible-types.md index c7bea5e7..a8583ce0 100644 --- a/content/flexible-types.md +++ b/content/flexible-types.md @@ -77,13 +77,13 @@ Normalization: `(T?)? = T?` (flexible types do not nest). #### Conformance (Extension to §3.6.1) -We extend the conformance relation (<:) with the following two derivation rules: +We extend the conformance relation (<:) with the following three derivation rules: 1. `S = U` and `T = U?` 2. `S = Null` and `T = U?` 3. `S = U?` and `T = U` -We can also equivalence: `U =:= U?` and `U | Null =:= U?`, +We can also equivalence: `U =:= U?` and `U | Null =:= U?`, even though `U | Null` and `U` may be not equivalent under explicit nulls. #### Member Selection @@ -102,10 +102,10 @@ Decoders that do not recognize `FLEXIBLEtype` may safely treat it as its underly #### Subtyping Rules in Compiler -For implementors: the two conformance rules above are implemented in `TypeComparer.scala` as follows: +The conformance cases above are implemented in `TypeComparer.scala` as follows: ```scala -// In firstTry method +// In firstTry method case tp2: FlexibleType => recur(tp1, tp2.lo) // tp1 <: FlexibleType.lo (which is T | Null) @@ -118,6 +118,35 @@ case tp1: FlexibleType => Erasure is extended with: `|T?| = |T|` (i.e., identical to the erasure of its underlying type). +This choice preserves both performance and runtime semantics compared to without explicit nulls: + +We only introduce flexible types for concrete reference types and for Java type parameters (i.e., when a generic `T` may be instantiated to a primitive value type). We do not create flexible types for raw primitive types in source. +The only way a flexible type whose underlying type is a primitive type can arise is via passing a primitive type argument to a Java generic method. + +Example: + +```java +// Java +public class Jtest { + public static T id(T t) { return t; } +} +``` + +```scala +// Scala (with explicit nulls enabled) +val i = Jtest.id(1) // i is inferred as Int? +val j = i + 1 // addition on Int + +// After erasure this behaves as if there were no flexible types: +val i: Int = Int.unbox(Jtest.id(Int.box(i))) +val j: Int = i + 1 +``` + +The key point is that erasure does not introduce extra/less boxing beyond what would happen without flexible types. + +One mutable variable inference corner case may be considered: if we kept `i` as `Int?` at typer, then `i = null` would typecheck (since `Null <: Int?`) but cannot be type checked without explicit nulls. +The post-processing step can be: stripping flexibility for inferred types whose underlying type is a primitive type, making `i`’s type `Int`, rejecting `i = null` statically. + ### Compatibility Flexible Types preserve binary compatibility because: