Literal types like in TypeScript?

But one thing to remember is that Typescript exists as a way to model the many ugly ways people code in Javascript because that’s all they can do with a language.

It also seems you are trying to program In Crystal as if it were Typescript. It’s like wanting to. program in Crystal as if it were Haskell. and complaining that a Maybe type is missing, and that code is ugly and slow if you try to implement that.

Yes, we know there are some inconsistencies with enums and symbols. These are just a few and easy to learn. My advice is to learn them and move on. Not much is going to change in the language.

1 Like

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 understand that literal types may be too complicated and huge effort to be considered realistic. But I disagree that it’s impossible or slow, because TypeScript somehow did it (I don’t know the implementation details). There are tons of literal types everywhere in TS projects and it compiles quite fast even for huge projects. They probably use some sort of tricks, but I haven’t noticed limitations or had any problems with those tricks.

About using Enums only, don’t know, the code looks too verbose in my opinion…

store.buy_dress color: Store::Color::Red

Just realised that enums are just special case of literal types. If you want flags or mappings etc. it’s possible to just do that.

RED = 1, BLUE = 2
alias Color = RED | BLUE
def buy_dress(color : Color) ... end
buy_dress color: RED

Are you sure?

I tried this in TypeScript:

type Foo = "foo" | "bar";

function foo(x: Foo) {
    return x;
}

foo("foo");

It compiles fine.

Next I tried moving “foo” to a local variable:

type Foo = "foo" | "bar";

function foo(x: Foo) {
    return x;
}

var z = "foo";

foo(z);

I get an error on the last line:

Argument of type 'string' is not assignable to parameter of type 'Foo'.(2345)

It works if I do var z : Foo = "foo" but that’s explicitly saying what’s the type.

So I don’t see TypeScript doing a lot here… it just works if you pass a string literal in a method argument position, but not as a local variable. EXACTLY the same as in Crystal. Except that in Crystal if you use symbol for comparison it doesn’t work, but that’s something we could fix (we could just disallow comparison of enum with symbol).

Do you agree with me that TypeScript has more or less the same issues as Crystal here?

I don’t think it was necessarily a bad idea. It’s a really convenient feature. And your PR that added it is one of the most liked. Removing it would certainly improve consistency, but hurt developer happiness :upside_down_face:

Currently, every enum member has three different spelling variants: The capitalized constant FOO or Foo, the autocasting symbol :foo and the predicate method .foo?.
If we simply remove the symbol completely, then it’s only two variants. That’s better but since method and symbol are very similar, consistency is only improved slightly. We could also remove the predicate methods. Then you’d have to always compare with qualified constants. Alternatives such as Enum#is? (#8000) couldn’t work without symbols either.

I’d rather explore other ideas to improve consistency with enums while keeping it enjoyable to use.

A prerequisite is probably to remove the symbol data type. It’s a Ruby legacy and not really useful when you have enums providing type safety. That’s especially true when enums are easy to use as literal types which is the goal anyway.

With symbol data type out of the way, we can repurpose symbol literals completely for enum members. Or just drop them if they’re not required.

When symbol literals are not typed as Symbol default, we should probably be able to make equality work so that Color::Red == :red is true.

Back to the inconsistent spelling issue: The obvious solution is to simply require the same spelling for all reference to an enum member. That’s how it should have been from the beginning). The winning argument for snake cased symbol variant over the exact spelling of the constant name was that it matches the predicate method. But one of the goals should be to remove the predicate methods. They’re not useful anymore when we can use type safe equality with symbol literals.

If we remove predicate methods and use the same spelling for enum member names everywhere, consistency issues are reduced.

3 Likes

Kinda similar but mirrored inside out. With Enums it’s verbose, inconvenient and full of surprises, with literal types it’s compact and natural with some minor limitations. Compare this (given all other concerns like type safety etc are pretty much the same).

buy_dress(Shop::Color::Red)
dress.color.should eq Shop::Color::Red

and

buy_dress(:red)
dress.color.should eq :red

I added more TypeScript examples, try uncomment broken parts to see type-safety in play

const p = console.log.bind(console)

type Color = 'red' | 'blue'

interface Dress {
  color: Color
}

const buy_dress = (color: Color): Dress => ({ color })

const dress = buy_dress('red')
p(JSON.stringify(dress))                 // Good { color: 'red' }

const
  color_as_color: Color = 'red',
  color_as_string: string = 'red'
  
p(color_as_color == color_as_string)     // Good true

if (dress.color == 'red') p("ok")        // Good, ok.

// Try wrong comparison, uncomment and see what happens
// if (dress.color == 'red1') p("error") // Good, caught.

// If you need type safe API mapping
const api_mapping: { [color in Color]: number } = {
  red: 1,
  blue: 2
}

// Try forgetting defining one color, 
// uncomment and see what happens
// const wrong_api_mapping: { [color in Color]: number } = {
//   red: 1,
// }

// This case would fail, not good. But I would say this 
// specific case is quite rare.
// buy_dress(color_as_string)

// Uncomment to see exhaustive check error
// const is_nice_color = (color: Color): boolean => {
//   switch (color) {
//     case 'red': return true
//     // case 'blue': return false
//   }
// }

Sorry, I got tired of discussing. Bye.

That’s just a matter of taste at the end if it feels more natural or not.

In enums quotes aren’t needed, and in Crystal "red" | "blue" would be very confusing: an union of strings (instances)? Unions are of type classes in Crystal.
Keep also in mind that enums values are members numbered, and can be set explicitly:

enum Alphabet
  A = 1
  B = 2
  Z = 26
end

I don’t see really how to simplify this even more.

Symbols can have surprising effects, better to avoid them (enum or not).
Auto-casting was a good idea, but Symbols being still a thing in the language, it’s is so easy to confuse both (like me many times).

1 Like

The only real bad side of enums is a boilerplate - you have to prefix values with enum name. So .red? was invented, then autocasting invented, and both are still unsufficient.

I think that simple solution would be allow to use enum values without enum name (like in pascal). In a case of ambiguity (using value that exists in more than one enum in current scope) compiler should emit an error, so it’s up to user to rename enum value or specify enum type explicitely.

Maybe that would create many conflicts and promote prefixing enum values though, most modern languages require prefixing enums, but using autocasted symbols isn’t better.