gcanti / io-ts

Runtime type system for IO decoding/encoding
https://gcanti.github.io/io-ts/
MIT License
6.7k stars 328 forks source link

Describe smart constructor for objects #558

Open steida opened 4 years ago

steida commented 4 years ago

📖 Documentation

We can enforce business rules stricter than plain types with https://dev.to/gcanti/functional-design-smart-constructors-14nb pattern. We can leverage https://github.com/gcanti/io-ts/blob/master/index.md#branded-types--refinements for that.

But it seems nowhere is described how to brand non-primitive types. Is this pattern right or am I overlooking something?

// Internal (not exported) type used only for Foo construction.
const _Foo = t.type({
  first: t.string,
  second: t.string,
});
type _Foo = t.TypeOf<typeof _Foo>;

interface FooBrand {
  readonly Foo: unique symbol;
}
export const Foo = t.brand(
  _Foo,
  (n): n is t.Branded<_Foo, FooBrand> => n.first !== n.second, // Just an example of something.
  'Foo',
);
export type Foo = t.TypeOf<typeof Foo>;
steida commented 4 years ago

OK, this approach is not type-safe. The spread operation on an object can make an invalid type.

@gcanti Any idea? How do you brand objects?

steida commented 4 years ago

As I see it, branded objects are still useful despite the fact they are easily breakable anytime the original object is somehow reused. They only can not be created from scratch. As a workaround, Foo.encode encodes value to not branded type, so if users encode everything before a manipulation, branded types are safe enough.

gcanti commented 4 years ago

What about a private constructor + a private property?

import * as O from 'fp-ts/Option'

class Foo {
  static smartConstructor(first: string, second: string): O.Option<Foo> {
    return first === second ? O.some(new Foo(first, second)) : O.none
  }
  private readonly _: unknown
  private constructor(readonly first: string, readonly second: string) {}
}

declare const foo: Foo

export const foo2: Foo = { ...foo, first: 'a' } // error: Property '_' is missing in type '{ first: string; second: string; }' but required in type 'Foo'.
steida commented 4 years ago

@gcanti Thank you, but how to do it with io-ts?

steida commented 3 years ago

For what is worth, it seems branded Array is safe because there is no spread operation which would copy brand prop. So we can at least enforce sorted etc. arrays safely.

const _Foo = t.readonlyArray(t.Int);
type _Foo = t.TypeOf<typeof _Foo>;

interface FooBrand {
  readonly Foo: unique symbol;
}
export const Foo = t.brand(
  _Foo,
  (n): n is t.Branded<_Foo, FooBrand> => n.length > 2, // Just an example of something.
  'Foo',
);
export type Foo = t.TypeOf<typeof Foo>;

const f = Foo.decode([1, 2, 3]);
if (f._tag === 'Right') {
  const a: Foo = f.right;
  // Error
  const b: Foo = [...a]
}
steida commented 3 years ago

It seems objects should be doable as well via Symbol

https://functionalprogramming.slack.com/archives/CPKPCAGP4/p1611993770099300?thread_ts=1611855662.084900&cid=CPKPCAGP4 https://functionalprogramming.slack.com/archives/CPKPCAGP4/p1611869451091300?thread_ts=1611869293.090800&cid=CPKPCAGP4