Variants

Hi everybody. I have written a proposal to bring variants to the language! Found in many other languages, variants indicate that a value is part of a set of choices, with some additional data attached depending on the choice. Redux actions are perfect examples of variants.

3 Likes

Hi @serras! When spreading a record with a tag would the tag be retained?

function setCountry(record, country) {
  return #{ ...record, country };
}

let p = #person { name: 'Alice', country: 'france' };
p = setCountry(p, 'germany');
Variant.tag(p); // 'person'?

or would code need to do this:

function setCountry(record, country) {
  return #[Variant.tag(record)]{ ...record, country };
}

Thanks for the catch @aclaymore. I've updated the proposal with how spreading would work for fields and tags. In short, you can now use #[v] to reuse the tag of another variant. Your setCountry function would be written as follows:

function setCountry(record, country) {
  return #[record] { ...record, country };
}

I am not convinced this warrants new syntax over the existing type: "tag" pattern. Neither of the points in your FAQ seem all compelling to me. And the downside (beyond just the burden of new syntax) is pretty significant: you lose the ability to compose this with the rest of the language naturally. There's a good example with spread properties above, but consider also destructuring (function foo({ type, value }) {....} is a nice pattern) and utilities like Object.fromEntries.

You can add special cases for some of these, but that mostly just increases the amount of stuff in the proposal without a corresponding increase in utility. By contrast, if you stick with the type field, you get composability with the rest of the language for free.

Records essentially exist in TypeScript through interfaces. This is just another proposal asking for TypeScript features in JS... just use TypeScript. This kind of runtime behavior just slows things down in Js (unless you want it to be sugar over something, but that’s pointless)

1 Like

This kind of runtime behavior just slows things down in Js

Not significantly - engines have long been able to optimize away conditions when it can infer a value is always of a specific type, and for the few it can't, there's only two types it can't reduce to at worst a pointer-width load + pointer-width test and branch (a single micro-op in x86 and 2-3 ARM micro-ops) for pure equality checking:

  • String equality comparison (if the slow path fails)
  • Floating point comparison (simple for SpiderMonkey 64-bit, complicated for most the rest as they store floats on the heap as they need to load both and then compare)

In polymorphic code, it's still fairly quick as long as you aren't comparing two strings, and even then, in all of this, we're talking nanoseconds and single microseconds - this rarely means much unless you're writing a DOM framework, 3D renderer, or whatever.

The prior art of this proposal seems duplicated with the Enum proposal. And if the Enum proposal becomes ADT Enum, it will be better type it with TypeScript.

Variants aka Tagged Unions required some runtime works like extract and check discriminator also it required some storage which should passed by value not by reference. That's why in many languages ADT like Tagged Unions is builtin entity. TypeScript may only provide exhaustive matchings/verification for such variants.

Engines already do this to tell apart primitives and even internal subtypes of primitives, even though most of them employ various tricks that make it extremely implicit and/or extremely easy to check (because it's so performance-sensitive). Here's a couple examples:

  • You know V8's famous Smis (and why people optimize so hard for 31-bit integers in perf-sensitive code)? That's technically equivalent to a two-variant union of Smi(int31), HeapValue(pointer).
  • V8 stores their typeof info as part of the type info, and when you add everything up with all their other type-based optimizations, after defining class C { v = 1.5 } and invoking it a few times, new C() takes approximately the same amount of memory as a boxed 1.5.
  • SpiderMonkey on 64-bit platforms NaN-boxes all their pointers and uses the excess bits to include typeof info as well as needed info for 32-bit integers. So they take a more pure approach to tagging, and they leave doubles unboxed. Their object format is similar to V8's, though I'm not 100% sure what all optimizations they put into it (I'm more familiar with V8's).

For tagged unions, it's almost certainly going to be no more expensive than instantiating a class and setting a property, and have at worst identical memory usage. And yes, they will implement it as a reference, even if it's a value type per spec - they already have the framework in place for strings, symbols, the previously-proposed-and-since-abandoned SIMD.js vectors, and (now) bigints.