How to trigger a compiler error when aliased type doesn't match precisely

Lets say I do this:

type token = int
let x: int = 10
let incrementToken = (t: token) => t + 1
incrementToken(x)

I want the compiler to throw an error because even though token and int are aliases, I want the invocation of the code to explicitly pass a token type and not (an explicit) int.

Is this possible to be done by ReScript?

One possible way to do it is to wrap the new type into a single-variant ADT:

type token = Token(int)
let x: int = 10

let incrementToken = (t: token) => {
  let Token(val) = t
  Token(val + 1) // or (val + 1)->Token
}

// or shorter

let incrementToken = (Token(val): token) => Token(val + 1)

In case you want to further hide the details of underlying type you can make a dedicated module (Token.res) for your type (Token.t) and make this t an opaque type exposing only functions that make sense for it.

4 Likes

Thanks, I like this solution.

Another question, does it not make sense that the code I posted throw an error by the rescript compiler? That is, why is my code valid?

No I think. Such transparent aliasing is useful in many cases. For example, to make shortcuts to otherwise long-to-type and noisy data types:

type dbResult = Promise.t<result<array<ResultRow.t>, error>>
type id = string /* uuid */

let readAll = () => dbResult
let readFirst = () => dbResult
let readOne = (id) => dbResult

// Use dbResult as a promise of result of array without any casting

I understand, but in my example, I explicitly tagged x to be an int, whereas incrementToken wants a token type.

From a philosophical point of view, type checker is essentially a piece of software which checks your code against your assumptions (i.e. types). I assume that all values passed to incrementToken are tokens and not just int (otherwise I would say it is int), if at another place I am assuming x to be explicitly int and not token then the call incrementToken(x) is an inconsistency in my code so it should throw an error.

Improvement suggested, using @unboxed decorator will result in cleaner output:

1 Like

By declaring type token = int, then you’re telling the typechecker that token and int are exactly the same, and thus interchangeable.

If token and int were not equivalent, then this function would not compile:

let incrementToken = (t: token) => t + 1

Because the function + is type (int, int) => int. If token != int, then that’s a type error.

Aliases are exactly that: aliases. They’re just to make naming types easier for us humans, but don’t affect the compiler logic. They’re mainly useful for writing shorter names for long types. Example:

type t = array<(int, array<string>)>

Writing that full type definition every time would be tedious, so we can just alias it to t. We can still use it with all of our standard Array functions though, like Array.map.

In addition to the other suggestions posted, another standard way of breaking type equivalence is to use a module signature.

module Token: {
  type t // Hide the implementation, so this becomes a completely new type.
  let fromInt: int => t
  let add: (t, t) => t
} = {
  type t = int
  let fromInt = i => i
  let add = (a, b) => a + b
}

Now your token type (Token.t) will be a completely unique type to any code outside of its own module. You won’t be able to use any built-in int functions, like +, so we have to expose our own versions like Token.add.

6 Likes

Hm, you can’t define + inside the Token module, and do Token.(s + t)? I think that’s possible in OCaml.

You can, but it’s not as convenient, because ReScript doesn’t support M.(...) syntax. You would have to use open:

module Token: {
  type t // Hide the implementation, so this becomes a completely new type.
  let fromInt: int => t
  let \"+": (t, t) => t
} = {
  type t = int
  let fromInt = i => i
  let \"+" = (a, b) => a + b
}

let test = {
  open! Token
  fromInt(1) + fromInt(2)
}
1 Like

Oh, that’s not too bad still. :slight_smile: But let’s get M.(s + t) into ReScript also! :smiley:

Reason supports this, but probably was removed on purpose from ReScript. I wish it wasn’t. I like it.

1 Like

You can also use a private type alias, which prevents the type from being constructed but still allows it to be coerced to the underlying type:

module Token: {
  type t = private int
  let fromInt: int => t
} = {
  type t = int
  let fromInt = i => i
}

let one = Token.fromInt(1)
//let two: Token.t = 2 // type error
let three: int = (one :> int) + 2

This is more idiomatic I think (at least in OCaml) than using an unboxed single-constructor variant. Usually it makes more sense to go fully abstract though.

5 Likes

Original example with private:

type token = private int
let x: int = 10
let incrementToken = (t: token) => (t :> int) + 1

// Error
incrementToken(x)

(Just to make it a bit more clear that you don’t need a module to hide the type implementation if you use private)

You do need to separate the signature (with private) and implementation (without private) if you want to construct a token without using hacks. And if you want to manipulate the values directly without using coercion.

2 Likes

Ah, got it.

// Type int is not a subtype of token
let makeToken = (x: int) => (x :> token)
1 Like

I’ve been trying to optimize on this solution, and here are two possible ways by this is achieved.

Using @unboxed decorator (Simple, and clean in terms of generated output):

@unboxed type token = Token(int)  //Create a constructor
let x: int = 10
let incrementToken = (Token(t): token) => t + 1
incrementToken(x) // Throws an error because x is not of type `token`

Using encapsulated module type (Busy output, but flexible, for instance more runtime validation can be performed):

module BaseInt: {
  type t
  let toInt: t => int
  let fromInt: int => t
} = {
  type t = int
  let toInt: t => int = x => x
  let fromInt: int => t = x => x
}

module Token = {
  include BaseInt
}

let x: int = 10
let incrementToken = (t: Token.t) => t->Token.toInt + 1
incrementToken(x) // Will throw an error

For the sake of readers not acquainted with the pattern, runtime validation usually means let fromInt: int => option(t) or even result(t, someError). toInt, on the other hand, is still t => int, ’cause any t can be mapped to int.

1 Like