gcanti / newtype-ts

Implementation of newtypes in TypeScript
https://gcanti.github.io/newtype-ts/
MIT License
578 stars 14 forks source link

Make use of `unique symbol` #9

Closed grassator closed 6 years ago

grassator commented 6 years ago

Since version 2.7 it is possible to use unique symbol to have truly unique types instead of relying on strings as tags.

Would you be open to support this? I could the following API:

interface EUR extends Newtype<number, { readonly tag: unique symbol }> {}

It's a bit uglier, but offers increased type safety. With conditional types I believe it could even be possible to support both...

sledorze commented 6 years ago

@grassator on the typesafety side; great. What's the story on error messages when setting a EUR on a USD? (the previous string scheme was indicative of the error with an helpful message)

gcanti commented 6 years ago

Would you be open to support this?

@grassator there no constraint on URI

export interface Newtype<URI, A> {
  _URI: URI
  _A: A
}

So AFAIK it's already supported.

What's the story on error messages?

@sledorze isn't good

interface EUR extends Newtype<{ readonly tag: unique symbol }, number> {}
interface USD extends Newtype<{ readonly tag: unique symbol }, number> {}

declare function f(eur: EUR): void

declare const eur: EUR
declare const usd: USD

f(eur) // ok
f(usd) // Type 'typeof tag' is not assignable to type 'typeof tag'. Two different types with this name exist, but they are unrelated

However the error message can be improved using different names for the tag

interface EUR extends Newtype<{ readonly EUR: unique symbol }, number> {}
interface USD extends Newtype<{ readonly USD: unique symbol }, number> {}

f(usd) // Property 'EUR' is missing in type '{ readonly USD: unique symbol; }'

And if you define EUR twice you get

interface EUR2 extends Newtype<{ readonly EUR: unique symbol }, number> {}
declare const eur2: EUR2
f(eur2) // Type 'typeof EUR' is not assignable to type 'typeof EUR'. Two different types with this name exist, but they are unrelated

Using an object for URI can be helpful to encode relations between newtypes (via subtyping).

Given the following basic types

interface NonZero extends Newtype<{ readonly NonZero: unique symbol }, number> {}

interface NonNegative extends Newtype<{ readonly NonNegative: unique symbol }, number> {}

interface NonPositive extends Newtype<{ readonly NonPositive: unique symbol }, number> {}

interface Integer extends Newtype<{ readonly Integer: unique symbol }, number> {}

One can build more newtypes...

// merges two newtypes
interface Concat<N1 extends Newtype<object, any>, N2 extends Newtype<object, Carrier<N1>>>
  extends Newtype<N1['_URI'] & N2['_URI'], Carrier<N1>> {}

interface NonZeroInteger extends Concat<Integer, NonZero> {}

interface Positive extends Concat<NonNegative, NonZero> {}

interface Negative extends Concat<NonPositive, NonZero> {}

interface PositiveInteger extends Concat<Integer, Positive> {}

interface NegativeInteger extends Concat<Integer, Negative> {}

Example

declare function f(x: NonZero): void
declare const i: Integer
declare const nz: NonZero
declare const nzi: NonZeroInteger
declare const nn: NonNegative
declare const np: NonPositive
declare const p: Positive
declare const n: Negative
declare const pi: PositiveInteger
declare const ni: NegativeInteger

f(i) // static error (Property 'NonZero' is missing in type '{ readonly Integer: unique symbol; }')
f(nz) // ok
f(nzi) // ok
f(nn) // static error
f(np) // static error
f(p) // ok
f(n) // ok
f(pi) // ok
f(ni) // ok

...or other useful type alias

type Rational = [Integer, NonZeroInteger]

Finally, since often we are talking about refinements rather than isomorphisms, we could define a prism helper along with the existing iso helper

import { Prism } from 'monocle-ts'

const prism = <S extends AnyNewtype>(predicate: Predicate<Carrier<S>>): Prism<Carrier<S>, S> => {
  return new Prism(s => (predicate(s) ? some(s) : none), identity)
}

const isNonNegative = (n: number) => n >= 0
const isInteger = (n: number) => n % 1 === 0
const isNonZero = (n: number) => n !== 0
const isNonZeroInteger = (n: number) => isNonZero(n) && isInteger(n)
const isPositive = (n: number) => n > 0
// etc...

const nonNegative = prism<NonNegative>(isNonNegative)
const integer = prism<Integer>(isInteger)
const nonZero = prism<NonZero>(isNonZero)
const nonZeroInteger = prism<NonZeroInteger>(isNonZeroInteger)
const positive = prism<Positive>(isPositive)
// etc...

const x1 = positive.getOption(1) // Some(1)
const x2 = positive.getOption(-1) // None
sledorze commented 6 years ago

Blogpost that @gcanti , its too interesting and neat to stay hidden in an issue. Thanks again

gcanti commented 6 years ago

@grassator @sledorze if you agree we could add Concat and prism to the library. Maybe also those newtypes (Integer, NonZero, etc...)

There's another helper which would be handy

interface Extends<N extends AnyNewtype, Tags extends object> extends Newtype<Tags & N['_URI'], Carrier<N>> {}

Usage

interface EUR extends Extends<Integer, { readonly EUR: unique symbol }> {}
gcanti commented 6 years ago

Here we go

https://github.com/gcanti/newtype-ts/pull/10

and

https://github.com/gcanti/newtype-ts/pull/11

grassator commented 6 years ago

@gcanti Thank you for such quick and comprehensive answer and update to the lib! I agree with @sledorze — would be great to have that in a blog post.

The only thing I wonder if it makes sense to make the order of arguments in Extends and Newtype consistent:

// ----------------------------------------------v 1st arg
interface EUR extends Newtype<{ readonly EUR: unique symbol }, number> {}
// -----------------------------------------------------v 2nd arg
interface EUR2 extends Extends<Integer, { readonly EUR: unique symbol }> {}
gcanti commented 6 years ago

The only thing I wonder if it makes sense to make the order of arguments in Extends and Newtype consistent

@grassator I'm not sure, they are quite different

//                                                             v-- this is the carrier
interface EUR extends Newtype<{ readonly EUR: unique symbol }, number> {}
//                             v-- this is NOT the carrier (the carrier is still number)
interface EUR2 extends Extends<Integer, { readonly EUR: unique symbol }> {}

I haven't a strong opinion on this though

gcanti commented 6 years ago

@grassator released https://github.com/gcanti/newtype-ts/releases/tag/0.2.1 Thanks a lot for the suggestion about unique symbols.

grassator commented 6 years ago

@gcanti Thanks, new release looks great.