Literal types like in TypeScript?

Sometimes it’s convenient to force very limited type.

struct CleanJsonRequest
  clean : true
end

The clean field can have only one value - true.

Or have natural enum types.

alias Color = "red" | "blue" | "geen"

def buy_dress(size : number, color : Color)
  ...
end

I know about Enums, they also exist in TypeScript but nobody uses it there because a natural way of just using string literal types is way better

In Crystal you should use an enum.

See Reddit - Dive into anything

I know about enum, I wonder if it’s worth to provide a simpler and better way than Enum.

In what way are TypeScript’s natural enum types simpler / better than Crystal’s current enum type?

alias Color = "red" | "green" | "blue"
def buy(color : Color)
buy "red"

I find this better than

enum Color
  Red
  Green
  Blue
end
def buy(color : Color)
buy Color::Red
puts Color::Red.value

It is shorter, cleaner, closer to english, and less things to remember.

Auto casting of symbol to enum values is also a thing which is a key piece of info you’re missing I think; as that would solve most of your issues with the current syntax in your example. See my example in https://www.reddit.com/r/crystal_programming/comments/e9ak4m/can_i_create_a_literal_type/.

Still not seeing a relevant difference.

If you want a one-liner for the enum declaration you can do that:

enum Color; Red; Green; Blue; end

But having each value on a separate line is usually preferable.

As @Blacksmoke16 mentioned, method calls can make use of autocasting for symbol literals, so you can just call buy :red (equivalent to buy Color::Red).
I think that’s better than buy "red" because the symbol makes it clear that it is a special value, not an arbitrary string so it can’t be replaced by buy "RED".downcase for example.

1 Like

I rewrite the example with enums and Symbols, it works, thanks! It also checks for the values (ignoring upper or lowercase) - is that typesafe and the check done at compile time?

enum Color; Red; Green; Blue; end
def buy(color : Color)
  puts color
end
buy :red
buy :invalid # Error

Good example why literal types are better than enums. I’m trying to create API for Vega Lite plotting library, similar to JavaScript Vega Lite API or Python Altair.

The API looks like that:

plot(
  data: data,
  mark: :line,
  x:    { "dates", :temporal },
  y:    { "price", :quantitative },
)

But it doesn’t work because it can’t auto-cast Symbol to Enum.

enum FieldType
  Nominal
  Ordinal
  Quantaitive
  Temporal
end

def plot(*,
  x : String | Tuple(String, FieldType) | Nil = nil
)
end

plot x: { "dates", FieldType::Temporal } # Works
plot x: { "dates", :temporal }           # Doesn't work

In my opinion that exact API doesn’t fit the way to do things in Crystal. The solution here is not to change the language but to change the way you define APIs in Crystal.

2 Likes

Maybe. Would something like the API below be a better fit?

plot(data)
  .mark(:line)
  .x("dates", as: :temporal)
  .y("price", as: :quantitative)
  .render

That could be an option.
Or you could keep the previous API but replace Tuple with a specific data type and a matching constructor which would be capable of auto-casting symbols.

Using Tuple in an API is rarely a good idea because it’s inflexible. You should better use a struct instead which is easier to handle and modify.

I changed the API and made two versions for comparison, with Enums and imaginary with Literal Types (like TypeScript), you can check the full code.

No way to use :temporal everywhere. From time to time it won’t fit and won’t compile and you need to figure out and find where it’s defined and remember its full name Plot::FieldType::Temporal.

P.S.

I tried rewriting specs as below, but it also failed (I guess it could be fixed by overriding the == operator, but again that would mean writing more code, that would be not needed with literal types).

spec[:mark].should eq :point            # Won't compile
spec[:mark].should eq Plot::Mark::Point # Compiled

I think this is quite an interesting topic. The current status in the language is not finished. We have symbols and enums, and having both is probably not a good idea. There seems to be a common understanding that enums are strictly superior to symbols for their type safety. So there need to be some refinements, probably before 1.0 release of Crystal. So it’s a good idea to consider and discuss ideas how this could evolve.

I read your blog post comparing Crystal and Type Script where you claim that enums should be removed from the language and replaced by literal types. I understand the general concept, but I’m not familiar with literal types in TypeScript. I’ve read a short overvier about it to follow up on what you’re talking about.

First of all, it seems TypeScript also has enum types besides literal types. So it seems they can’t fully replace them? Or do you think that enum types are unnecessary in TypeScript as well?

Considering your comparison in the above comment, I think there’s actually little point to. The imaginary syntax for literal types could very well just refer to enums. The implementation doesn’t exist yet, but everything we need to make the suggested syntax work could essentially just be applied to enums. Even alias Foo = :foo | :bar could work as syntactic sugar for enum Foo; FOO; BAR; end.

I also believe that Enum#to_json should use the downcased name by default. This is probably what most users would expect, especially since it corresponds to the string literal.

Literal types don’t provide a mapping between labels and values. They can only ever be either a label (string/symbol) or a value (number or other data types).
When you only need labels, type literals might be good enough. But many use cases need to connect labels with specific values (for example for interfacing with some kind of external API). Then you have to choose to either use fixed values in code (which then also serve as labels), or you use only labels and need to map them to values in a different way. Both options are not desirable because they lack context.
Crystal’s enums can only use number types as values. Enums in Java for example are more powerful, you can declare custom properties and even override methods for individual entries. I suppose this could technically be added in Crystal as well, but maybe we can live without it. Technically, it should probably even be able to work without compiler support using macros.

In case I missed something, please correct me. Do you agree with my assessment or are there other benefits of literal types that can’t work with enums? I actually don’t see much difference between how you described literal types and how enums could work when we remove symbol as a data type.

First of all, it seems TypeScript also has enum types besides literal types. So it seems they can’t fully replace them? Or do you think that enum types are unnecessary in TypeScript as well?

I can’t say about all the TypeScript community. I personally don’t use enums. And I don’t see it when I read other people’s code. Maybe there are some rare special cases like combining flags when they could be used.

Literal types don’t provide a mapping between labels and values.

Yes, but in most use cases there’s no need for such mapping. Why introduce complexity - the mapping, when you don’t need it?

Do you agree with my assessment or are there other benefits of literal types that can’t work with enums?

In my use cases, probably in 90% or even 99% when I use literal types - I don’t care about name/value mapping or flags. I just want exactly that - a limited set of values in that exact form (like possible options in method arguments).

I think that the tool (language) should be simplified and optimised for the most frequent use cases. And while rare cases should be possible too - it’s ok if that will be harder to do.

Could enums be better for API internal/external keys mapping? Probably, but those are rare cases I don’t care about (at least in my type of work). I can use Enums or even manual mapping in those rare cases - that’s fine by me.

What annoys me is that with Enums it provides benefits I don’t care about (like key value mapping and type safety that (hopefully in some next releases) I will get anyway with exhaustive if/case checks) but takes away the simplicity I care about very much (no equality, different camel case, json mapping, no equality in specs, no equality and special naming convention .red? in case statements). It looks to me that it’s an insane price to gain those absolutely useless in 99% of cases benefits.

I.e. I see it as a tradeoff - what do you prefer - optimise for the common case and make it as simple as possible with the cost of slightly more complexity for rare cases. Or make it harder for all cases, but rare cases will be easier.

Also it would save human memory, as with literal types there would be no need to read docs about Enums (until you decide that you really need it).

P.S.

You mentioned link to docs about Enums in TypeScript - I never read that doc, although worked with TS for quite a while on different projects :slight_smile: .

I don’t really think there’s much of a tradeoff. Enums can be used with very low complexity. As I said earlier, your imaginary type literal example could probably work with enums as well.

About the benefits you describe:

  • I’m not sure what you mean about missing equality. Could you elaborate?
  • I agree that the current situation having different variants for enum names is very confusing. I’d very much like to improve that. Overhauling the enum system would be possible when symbols are removed. I think there can be some simplifications. It would probably break some things, but I’m sure it would be worth it. I think that’s the place where we should start thinking about improving what’s already there instead of considering adding yet another concept.
  • Equality in specs should probably be made to work when there’s no symbol data type and symbol literals are required to be mapped to a enum type. Depending on the situation, this can happen through autocasting or require manual type annotation which I believe is necessary in TypeScript as well in certain cases.

Also it would save human memory, as there would be no need to read docs about Enums

But you would still need to read docs about literal types, wouldn’t you?

I would assume that TypeScript doesn’t have a high demand for enums, since they don’t exist in JavaScript. A major use case in Crystal where you need mappings to values is interfacing with C APIs.

So I guess my idea is to make enums easier to use and less confusing (think FOO, .foo?, :foo for referencing the same value), which would essentially end up similar to how you would use literal types (with symbol literals). The result could probably be something that fits what you suggest for literal types. Instead that it’s not literal types but enums, only similarly easy to use.

I’m not sure what you mean about missing equality. Could you elaborate?

I hit all of them in relatively simple project I did to learn Crystal, I didn’t looked for those issues specially.

require "spec"
require "json"

enum Color
  Red
  Blue
end

record Dress, color : Color do
  include JSON::Serializable
end

def buy_dress(color : Color)
  Dress.new color
end

dress = buy_dress :red       # Aha, Red and :red are the same things
p dress.to_json              # Surprise! { color: 0 }
p Color::Red == :red         # Surprise! false
case dress.color
when :red then p "good"
else           p "not good"  # Surprise! "not good"
end
dress.color.should eq :red   # Surprise! Fail

Depending on the situation, this can happen through autocasting or require manual type annotation which I believe is necessary in TypeScript as well in certain cases.

If you suggest a specific case I would gladly discuss it. But from my personal experience in TS I can hardly find any use for enums.

The result could probably be something that fits what you suggest for literal types. Instead that it’s not literal types but enums, only similarly easy to use.

I’m an user, and from user standpoint - details of internal implementation doesn’t matter. So for me any approach resolving those issues would be fine.

I personally would prefer to keep things uniform - and doing it as literal types (at least how it looks at the surface) - as this way would require minimal learning from users as alias Color = :red | :blue declaration would naturally blend with other type unions and feels intuitive.

Yes, autocasting of symbols to enums was a bad idea. I think we should remove that from the language.

But literal types can’t be implemented in Crystal. It would mean that each symbol has its own type. For example :foo would have the type :foo, not Symbol (though it could probably be a subclass of Symbol, or just a generic Symbol(:foo)). But imagine combining these symbols in multiple ways, you get an explotion on the number of union types.

I think the language should be dumbed down a lot. Remove autocasting (completely), make types in method arguments mandatory, etc.