Stream: beginners

Topic: ✔ I can't figure out why using `foo = []` doesn't work


view this post on Zulip Lachlan O'Dea (May 11 2024 at 06:43):

:wave: This works:

module []

expect [] |> List.append 42 == [42]

expect [] |> List.append "A" == ["A"]

but not this:

module []

foo = []

expect foo |> List.append 42 == [42]

expect foo |> List.append "A" == ["A"]

gives:

This 2nd argument to |> has an unexpected type:

5│  expect foo |> List.append 42 == [42]
                              ^^

The argument is a number of type:

    Num *

But |> needs its 2nd argument to be:

    Str

why the difference?

view this post on Zulip Luke Boswell (May 11 2024 at 06:48):

I'm not sure if it is behaving correctly or if this is a bug. But my guess is that both of those expects are in the same scope as foo. I wonder if it works if you make a separate function for the different usages of foo, and then use in an expect? Maybe the that would prevent the typesfrom being the same for both?

view this post on Zulip Hristo (May 11 2024 at 08:11):

I don't know much about type system design, but this kind of usage (in the second example) feels like "cheating" the type system a bit.

I could be wrong, but conceptually, I imagine that the type of foo will be resolved by the compiler to just one of these (where the question concerning which one will be implementation-specific), but can't be both.
Basically, what the compiler does for you is letting you optionally not type-annotate foo by yourself.

In your first example, the two instances of [] are separate objects in memory (e.g., could be thought of as foo and bar) and their types are inferred accordingly.

view this post on Zulip Luke Boswell (May 11 2024 at 08:30):

Yeah, I'm not sure if this is a bug though because I think the foo function can be inferred both ways and given two different annotations, but maybe it's not working here for some reason?

view this post on Zulip Anton (May 11 2024 at 09:31):

I imagine that the type of foo will be resolved by the compiler to just one of these (where the question concerning which one will be implementation-specific), but can't be both.

I would also expect this to be the case, I don't think it's a bug

view this post on Zulip Brendan Hansknecht (May 11 2024 at 10:53):

Yeah, this is intentional

view this post on Zulip Brendan Hansknecht (May 11 2024 at 10:53):

That use of foo is a type mismatch

view this post on Zulip Brendan Hansknecht (May 11 2024 at 10:54):

If you want it to be polymorphic like that you would need to:

foo = \{} -> []

expect foo {} |> List.append 42 == [42]
expect foo {} |> List.append "A" == ["A"]

view this post on Zulip Brendan Hansknecht (May 11 2024 at 11:08):

If you were to type the original program:

foo : List a
foo = []

# List.append is List a -> List a
# So this is List (Num *) -> List (Num *)
# So `a` is Num *
expect foo |> List.append 42 == [42]

# here, we are requesting that `a` also be Str.
# So type mismatch.
expect foo |> List.append "A" == ["A"]

view this post on Zulip Brendan Hansknecht (May 11 2024 at 11:25):

Oh, one final note on using [], each [] gets its own type variable.

So

# [] is `List a` is `List (Num *)`
expect [] |> List.append 42 == [42]

# [] is `List b` is `List Str`
expect [] |> List.append "A" == ["A"]

view this post on Zulip Lachlan O'Dea (May 11 2024 at 13:19):

Cool, thanks for explaining, that's making sense. I think what threw me is the idea that [] has type List *. But I think you're saying it's actually polymorphic, and List * is just what we get if there's no specific type required. Because adding foo : List * didn't help, it just changed the error message slightly.

view this post on Zulip Brendan Hansknecht (May 11 2024 at 16:07):

Yeah, List * can be a bit confusing. Only the empty list can truly be List * as a defined variable. foo : List * can't actually merge with anything. It is a useless list that can't be appended to.

view this post on Zulip Richard Feldman (May 11 2024 at 16:12):

yeah we should really special-case that error message!

view this post on Zulip Richard Feldman (May 11 2024 at 16:12):

do we have an issue for that already?

view this post on Zulip Brendan Hansknecht (May 11 2024 at 16:15):

Not that I know of

view this post on Zulip Jasper Woudenberg (May 11 2024 at 20:23):

This surprises me! In Elm the equivalent code compiles:

import Html

main = Html.text "Hello!"

foo : List a
foo = []

bar = List.append foo [3]

baz = List.append foo ["hi"]

If we go back to the Roc example:

foo : List a
foo = []

Should I consider the List a in the Roc code above a not-fully-resolved type signature, given that using foo elsewhere in the code narrows the type signature further?

It feels a bit unintuitive to me that a type signature I add to a top level definition is not the final word on what the type of a value is (If I don't explicitly leave holes in the signature using _).

view this post on Zulip Jasper Woudenberg (May 11 2024 at 21:11):

I realize that the equivalent of List a in Elm is List * in Roc, so I guess my intuition related to List *.

I'll try a bit harder to put into words below why the behavior Lachlan raises feels unintuive to me.


Argument 1

In Lachlan's examples above both [] and foo have the same type (List *) and value. What's _different_ about these that makes one type-check but not the other?


Argument 2

Suppose I have a package A that exports foo = []
Suppose I have a package B that defines bar = List.append foo 3
Suppose I have a package C that defines bar = List.append foo "hi"

What should happen in a project with a direct dependency on both package B and C?

view this post on Zulip Richard Feldman (May 11 2024 at 21:12):

@Jasper Woudenberg important context on this: https://rwx.notion.site/Let-generalization-Let-s-not-742a3ab23ff742619129dcc848a271cf#0929c77b98ab47b0be4f534d7ec4dc04

view this post on Zulip Brendan Hansknecht (May 11 2024 at 21:26):

One extra note, this is only a special case for empty containers (which maybe we could open let generalization to support). Cause empty containers don't yet know what they might contain.

Definitely a discussion that could be worth having at some point, but not clearly worthwhile.

I think elm is innately able to be more flexible due to being built on JS.

view this post on Zulip Brendan Hansknecht (May 11 2024 at 21:27):

That said, at least for containers based on lists, there is no difference until an allocation is made. So a truly empty list is identical. But the moment you add a capacity of some sort (even without elements) there are differences.

view this post on Zulip Brendan Hansknecht (May 11 2024 at 21:28):

For any polymorphic type based on tags or records, there are always differences from the very beginning (due to space requirements on stack/heap).

view this post on Zulip Richard Feldman (May 11 2024 at 21:33):

yeah the fundamental thing that makes this trickier for Roc than it is for Elm is that Roc monomorphizes instead of keeping type information around at runtime (which JS does whether you want it to or not)

view this post on Zulip Jasper Woudenberg (May 11 2024 at 21:41):

Ah, thanks for sharing, super interesting, and very cool that Roc is able to catch me doing something that would lead to performance difficulties under the hood.

Also, I appreciate how the error messages in that doc would reject an annotation foo : List *, and so it would stop looking like Roc is "overruling" your manual type signatures.

view this post on Zulip Richard Feldman (May 11 2024 at 21:51):

well, a perhaps surprising thing is that it would only reject the annotation if you used the value in multiple places where the * would become different types

view this post on Zulip Richard Feldman (May 11 2024 at 21:51):

or if you exposed it from the module

view this post on Zulip timotree (May 12 2024 at 01:04):

It's worth noting that foo : List _ is the more accurate type annotation. foo is a list of some specific type of elements which will be inferred later. In general *s become _s when you refer to a polymorphic definition.

view this post on Zulip Jasper Woudenberg (May 12 2024 at 12:18):

timotree said:

It's worth noting that foo : List _ is the more accurate type annotation. foo is a list of some specific type of elements which will be inferred later. In general *s become _s when you refer to a polymorphic definition.

Yeah, I like this. The Notion doc Richard links suggests something similar, that certain type annotations should not be allowed. For instance, it suggests the following:

piApprox : Frac *
piApprox = 22 / 7

Should fail to of compile with this error:

-- NUMBER IS NOT POLYMORPHIC --

The type annotation on `piApprox` suggests that it can be used polymorphically:

1 | piApprox : Frac *
2 | piApprox = 22 / 7

Unfortunately, I can't use `piApprox` as any fractional type! I can only use it
as exactly one of `Dec`, `F32`, or `F64`.

If you want me to infer the fractional type that should be used, you can use an
inference annotation instead of `*`:

  piApprox : Frac _

If you explicitly want `piApprox` to be able to be used polymorphically, consider
making it a thunk:

  piApprox : {} -> Frac *
  piApprox = \{} -> 22 / 7

(taken from below the 'programs that are no longer allowed section' in this doc)

I think if we have a similar compiler error as soon as the programs contains the annotation foo : List *, rather than only when the value is used in incompatible ways in two places, that removes the confusion you describe, Richard.

view this post on Zulip timotree (May 12 2024 at 18:47):

Note that this is tracked here https://github.com/roc-lang/roc/issues/5536

view this post on Zulip Notification Bot (May 14 2024 at 09:01):

Lachlan O'Dea has marked this topic as resolved.


Last updated: Jul 06 2025 at 12:14 UTC