gcanti / io-ts-types

A collection of codecs and combinators for use with io-ts
https://gcanti.github.io/io-ts-types/
MIT License
311 stars 40 forks source link

How do you use newtypes in io-ts now? #111

Closed matthewpflueger closed 4 years ago

matthewpflueger commented 4 years ago

🐛 Bug report

Hi, I am trying to upgrade to the latest io-ts and I use a bunch of newtypes (from newtype-ts) with help from io-ts-types like below:

Current Behavior

Declare a newtype:

import * as t from 'io-ts';
import {fromNewtype} from 'io-ts-types/lib/newtype-ts/fromNewtype';
import {Newtype} from 'newtype-ts';

export type Username = Newtype<'Username', string>;
export const UsernameT = fromNewtype<Username>(t.string);

Then I use it like:

import * as t from "io-ts";

import {
  UsernameT
} from "./Username";

const UserCredentialsT = t.type(
  {
    username: UsernameT
  },
  "UserCredentials"
);

Only the io-ts-types/lib/newtype-ts is missing from the latest version of that library which used to do the conversion.

So how do you use newtypes in io-ts now?

Expected behavior

Be able to use newtypes in io-ts...

Software Version(s)
fp-ts 2.1.0
io-ts 2.0.1
io-ts-types 0.5.1
TypeScript 3.6
samhh commented 4 years ago

This is what I'm doing at the moment, it appears to correctly flag if you pass an inappropriate base codec:

const NewtypeDecoderFactory = <T, U>(id: string, iso: Iso<T, U>, baseCodec: t.Type<U>) =>
    new t.Type<T, U>(
        id,
        (u): u is T => baseCodec.is(u),
        (u, c) => either.map(baseCodec.validate(u, c), iso.wrap),
        iso.unwrap,
    );

// [...]

type Seconds = Newtype<{ readonly Seconds: unique symbol }, number>;
const Seconds = iso<Seconds>();
const secondsDecoder = NewtypeDecoderFactory('Seconds', Seconds, t.number);
mlegenhausen commented 4 years ago

Thank @SamHH this seems to work quite well I optimized it a little bit so you don't need to provide the iso instance

export const fromNewtype = <N extends AnyNewtype>(codec: t.Type<CarrierOf<N>>, name = `Newtype<${codec.name}>`) => {
  const i = iso<N>()
  return new t.Type<N, CarrierOf<N>, unknown>(
    name,
    (u): u is N => codec.is(u),
    (u, c) => E.either.map(codec.validate(u, c), i.wrap),
    a => codec.encode(i.unwrap(a))
  )
}
mlegenhausen commented 4 years ago

It would be nice if we could re-add fromNewtype to the io-ts-types cause it is the missing link between io-ts and newtype-ts.

@gcanti what are your thoughts? I would prepare a PR.

matthewpflueger commented 4 years ago

Re-adding fromNewtype would be awesome! In lu of that, I added the old 0.4.7 fromNewtype to my project (with appropriate attribution of course). Any particular reason that one won't work with io-ts?

gcanti commented 4 years ago

@mlegenhausen I thought that branded types were the standard now (being more powerful). Is there a realistic use case for newtypes?

matthewpflueger commented 4 years ago

@gcanti I saw branded types and am obviously confused a little on how they replace newtypes. I use newtypes extensively to model my domain and also to verify input at the edges. Can branded types be used outside of io-ts like in simple modeling of the domain?

mlegenhausen commented 4 years ago

@gcanti I thaught that at first too but newtype-ts allows to write more strict code. The problem with branded types is that it has the build-in downgradability to the base type.

Example

interface PersonId extends Newtype<{ readonly PersonId: unique symbol }, string> {}
type Name = t.Branded<string, { readonly Name: unique symbol }>

interface Person {
  id: PersonId
  name: Name
  bio: string
}

declare const p1: Person

p1.name === p1.bio // No Error cause there is a common base type
p1.id === p1.bio // Wanted error cause the base type is hidden

You can argue that you simply need to define bio as branded type to get an error but the world is not perfect where every variable is a branded type especially when you integrate foreign libraries.

giogonzo commented 4 years ago

I still see the value in using newtypes. If I have e.g. an Auth module encapsulating the retrieval and validation of user tokens (this module would be the only one able to create values of the Token type, and accept Tokens in its signatures), I don't want those tokens to be mixed up with anything else in the rest of the application, i.e. I don't want them to be assignable to whatever the carrier type would be

gcanti commented 4 years ago
p1.name === p1.bio // No Error cause there is a common base type

@mlegenhausen I would argue that this is a feature

I don't want them to assignable to whatever the carrier type would be

@giogonzo why? are there any cons?

p.s. Mind you, I'm not against re-adding fromNewtype, just interested in any cons of branded types

giogonzo commented 4 years ago

why? are there any cons?

to continue with my example above, I'd like for instance to have the property that a Token can't be rendered in JSX in my react app. One might say that this could be done from the "other side", i.e. making JSX only accept e.g. RenderableString. But sometimes this other side is just too big to update, or you don't have direct control over the typings.

In this scenario I see assignability to the carrier type as a con, while I agree with you that it is a pro in the majority of cases

mlegenhausen commented 4 years ago

I would argue that this is a feature

@gcanti of cause this should not be the start to replace branded types at all but newtypes allow to make completely distinct set of types but who am I telling this to :wink:

As @giogonzo says for different purposes it seems reasonable to use branded types and newtypes in the same application they don't exclude each other.

mlegenhausen commented 4 years ago

I can provide a PR but I think I will need till next week.

gcanti commented 4 years ago

Relased in https://github.com/gcanti/io-ts-types/releases/tag/0.5.2

ghost commented 3 years ago

Sorry to comment on an old thread, but it came up in Google when searching around this issue. I just wanted to highlight my use case for preferring newtypes here.

I want to differentiate between pounds and pence in an application and prevent accidental arithmetic between the two.

// Using branded types (I haven't included all the boilerplate for creating them)
type Pounds = number & Brand<PoundsBrand>
type Pence = number & Brand<PenceBrand>

const amountInPounds = 30 as Pounds
const amountInPence = 4200 as Pence
amountInPence + amountInPounds // no error, type: number

Because they can be downgraded to number, the compiler still allows me to mix pounds and pence in arithmetic operations. This is obviously undesirable.

// Using a Newtype prevents this from happening:
interface GBX extends Newtype<{ readonly GBX: unique symbol }, number> {}
const isoGBX = iso<GBX>();

interface GBP extends Newtype<{ readonly GBP: unique symbol }, number> {}
const isoGBP = iso<GBP>();

const amountInPence = isoGBX.wrap(4200)
const amountInPounds = isoGBP.wrap(30)
amountInPence + amountInPounds // error, need to explicitly unwrap types before doing maths.

I'm now forced to be very explicit about how I'm unwrapping the types before doing maths with them.

mlegenhausen commented 3 years ago

For Branded or NewTypes you should implement yourself a Field, Ring or Semiring and do the unwrapping there once: https://github.com/gcanti/fp-ts/blob/master/src/Field.ts