Skip to content

Conversation

@DedSec256
Copy link
Contributor

@DedSec256 DedSec256 commented Aug 12, 2025

Description

Currently, formatting nullable types to strings omits parentheses, resulting in F# type strings that are

  • syntactically incorrect
image
  • change type semantics
image
  • more difficult in the sense of perception and inconsistent with algebra
image

Note:
If we take tuple * as multiplication, and | as a kind of addition, then (string | null) * int will match (a + 1) * b, but not a + 1 * b. If we assume the prioritization like * > | > , it would be also consistent with the existing syntax of nullable function types: (int → int) | null instead of int → int | null.

Intentions/Quick actions

Besides tooltips and inline hints, this type formatting logic can be used in the IDE for explicit type annotation intentions/quick fixes via FShapType.Format. Without proper parenthesis handling, user code will be broken:

example
example

PR

This PR fixes the aforementioned issues for FSharpType.Format public API and in tooltips

Checklist

  • Test cases added

@github-actions
Copy link
Contributor

github-actions bot commented Aug 12, 2025

❗ Release notes required


✅ Found changes and release notes in following paths:

Change path Release notes path Description
src/Compiler docs/release-notes/.FSharp.Compiler.Service/10.0.100.md

@DedSec256 DedSec256 marked this pull request as draft August 12, 2025 21:36
Copy link
Member

@T-Gro T-Gro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great spot @DedSec256 , you are right.
I might need a little more understanding behind the chosen prec constants.

Btw. the baselines show a drop of parens around some tuples, like:

-  "The type '('a * 'b * 'c)'
+  "The type ''a * 'b * 'c'

@auduchinok
Copy link
Member

@T-Gro Looking at these changes, I still think that we should've used the int? syntax, as it wouldn't require adding the extra parens here and there. It would also be much easier to read it non-ambiguously.

If I remember correctly the idea to use the | null syntax was to make it less desirable to use it, but it seems that we've got it everywhere in the tooltips and other features instead.

auduchinok

This comment was marked as resolved.

@github-project-automation github-project-automation bot moved this from New to In Progress in F# Compiler and Tooling Aug 13, 2025
Copy link
Member

@auduchinok auduchinok left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really good, thanks!

@DedSec256
Copy link
Contributor Author

DedSec256 commented Aug 19, 2025

@T-Gro

Problem

The main logic for type formatting in NicePrint.fs lies in the layoutTypeWithInfoAndPrec function:

and layoutTypeWithInfoAndPrec denv env prec ty =

It is called in several different FCS locations with different default prec values. Based on this value, the need for parentheses for the whole type and some of its parts is determined.

Unfortunately, different parts of the compiler pass different default values — for example, in some diagnostics and tooltips it is called with 5:

/// Layout a single type, taking TypeSimplificationInfo into account
and layoutTypeWithInfo denv env ty =
layoutTypeWithInfoAndPrec denv env 5 ty

In other diagnostics, the value 2 is used:

let minimalStringOfType denv ty =
let ty, _cxs = PrettyTypes.PrettifyType denv.g ty
let denv = suppressNullnessAnnotations denv
let denvMin = { denv with showInferenceTyparAnnotations=false; showStaticallyResolvedTyparAnnotations=false }
showL (PrintTypes.layoutTypeWithInfoAndPrec denvMin SimplifyTypes.typeSimplificationInfo0 2 ty)

Note: The above code with 2 was written in 2014.

This leads to the same type, for example, a tuple, being displayed differently in error messages, tooltips, documentation, etc:

image

In the case of tuples, this is because parentheses around the tuple are placed when prec <= 2

| TType_tuple (tupInfo, t) ->
let elsL = layoutTypesWithInfoAndPrec denv env 2 (wordL (tagPunctuation "*")) t
if evalTupInfoIsStruct tupInfo then
WordL.keywordStruct --- bracketL elsL
else
bracketIfL (prec <= 2) elsL

As I understand it, the initial idea is to separate nested tuples
image

The rule for parenthesizing null types should consider the following facts:

  • When formatting generic types in suffix form, prec = 2 is set for the generic argument
    Covers the case (string | null) list
  • For tuple elements, prec = 2 is also set
    Covers the case (string | null) * int

Therefore, we can choose a value for the condition prec <= 2 (or <=3 since it does not conflict with other checks in the logic and includes <= 2).
In the case of prec <= 2, there are problems with some existing null-diagnostics, where unnecessary parentheses like (string | null) are placed instead of string | null, because of aforementioned minimalStringOfType call with prec = 2 default value

Btw. the baselines show a drop of parens around some tuples, like:

  • "The type '('a * 'b * 'c)'
  • "The type ''a * 'b * 'c'

I tried using the same value (5), so the tests started failing because of an early inconsistency in tuple representation.

What can be done

  • Parentheses around the whole type seem redundant. Bring everything to a common form with prec = 5, update the baseline.

Pros: consistent
Cons: changes existing formatting and error messages

Note: considering that the code with 2 was written in 2014, it's possible that the existing logic is a cosmetic error from the beginning or simply doesn't take into account modernity.

  • Implement the parenthesis adding logic for null types not via prec, but a separate flag/parent prec, etc.

Pros: does not affect existing logic, corrects only null types
Cons: looks more like a workaround, an additional floating point that will need to be considered along with prec

@brianrourkeboll
Copy link
Contributor

This leads to the same type, for example, a tuple, being displayed differently in error messages, tooltips, documentation, etc:

image

This is interesting, since in some contexts int * int and (int * int) actually mean different things and have different compiled representations (e.g., a binary method versus a unary method taking a System.Tuple<_, _>, or two separate properties on a union case type versus a single property of type System.Tuple<_, _>).

@auduchinok
Copy link
Member

@brianrourkeboll The image seems broken in your comment.

@brianrourkeboll
Copy link
Contributor

@brianrourkeboll The image seems broken in your comment.

It was the image showing the different parenthesization of the same type in these two error messages:

> let x : int * int = 1;;

  let x : int * int = 1;;
  --------------------^

stdin(1,21): error FS0001: This expression was expected to have type
    'int * int'
but here has type
    'int'

> let y : int * int = null;;

  let y : int * int = null;;
  --------------------^^^^

stdin(2,21): error FS0043: The type '(int * int)' does not have 'null' as a proper value

@auduchinok
Copy link
Member

I'd expect no parens in both cases 🙂

@DedSec256 DedSec256 marked this pull request as ready for review August 26, 2025 06:48
Copy link
Member

@T-Gro T-Gro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great.

All the baseline changes are an improvement, also in the tuple case now.

Thanks a ton @DedSec256 !

I also understand the prec a little more now - I assume a file comment telling rough meaning of the values in terms of precedence rules could help (or if possible, moving from a numbered static list, e.g. via a plain enum)

@T-Gro T-Gro enabled auto-merge (squash) August 26, 2025 10:11
@T-Gro T-Gro merged commit f5e27c5 into dotnet:main Aug 26, 2025
38 checks passed
@github-project-automation github-project-automation bot moved this from In Progress to Done in F# Compiler and Tooling Aug 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

5 participants