gcanti / io-ts

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

Make optional fields more user friendly #542

Open safareli opened 3 years ago

safareli commented 3 years ago

Other decoding/validation libraries have more user friendly optionality support like this:

D.type({
  foo: D.string,
  baz: D.optional(D.string)
})

Would be great to have something like this here too. As discussed with @gcanti there was some issues with the stable API #140 #266 but he doesn't remember if he even tried this with the experimental API.

aroman commented 3 years ago

Any update on this? Would be very helpful.

ruizb commented 3 years ago

The only "elegant" way I found recently is to use optionFromNullable from the io-ts-types library. However, it uses an Option<A> instead of A | undefined, so maybe it's not exactly what you are looking for...

import * as t from 'io-ts'
import { optionFromNullable } from 'io-ts-types'

const Foo = t.type({
  foo: t.string,
  baz: optionFromNullable(t.string)
})

type Foo = t.TypeOf<typeof Foo> // { foo: string, baz: O.Option<string> }

console.log(Foo.decode({ foo: 'foo' }))             // right({ foo: 'foo', baz: none })
console.log(Foo.decode({ foo: 'foo', baz: 'baz' })) // right({ foo: 'foo', baz: some('baz') })
Software Version
fp-ts 2.9.5
io-ts 2.2.16
io-ts-types 0.5.15
safareli commented 3 years ago

After being inspired by sparceType from io-ts-extra I've implemented similar thing for Decoder:

import * as D from "io-ts/Decoder";
import { pipe } from "fp-ts/lib/function";
import { partition } from "fp-ts/lib/Record";

type AnyDecoder = D.Decoder<unknown, unknown>;

type Props = { [K in string]: AnyDecoder & Partial<Optional> };

export interface Optional {
  optional: true;
}

const isOptional = <T>(val: T & Partial<Optional>): val is T & Optional => {
  return val.optional ?? false;
};

type OptionalKeys<Base> = {
  [Key in keyof Base]: Base[Key] extends Optional ? Key : never;
}[keyof Base];

type RequiredKeys<Base> = {
  [Key in keyof Base]: Base[Key] extends Optional ? never : Key;
}[keyof Base];

type Sparse<P> = {
  [K in RequiredKeys<P>]: D.TypeOf<P[K]>;
} &
  {
    [K in OptionalKeys<P>]?: D.TypeOf<P[K]>;
  };

/**
 * Marks decoder as an `Optional`, intended to be used with `D.sparse`.
 *
 * @see sparse
 */
export const optional = <D extends AnyDecoder>(decoder: D): D & Optional => {
  return Object.assign({}, decoder, { optional: true as const });
};

/**
 * Combines `D.struct` and `D.partial` in a nice way where instead of:
 * ```ts
 * const Person = pipe(
 *   D.struct({ name: D.string }),
 *   D.intersect(D.partial({ age: D.number }))
 * )
 * ```
 *
 * You can do:
 * ```ts
 * const Person = sparse({
 *   name: D.string,
 *   age: optional(D.number),
 * })
 * ```
 *
 * While having a great type inference:
 * ```ts
 * // const: Person: D.Decoder<unknown, {
 * //   name: string;
 * //   age?: number | undefined;
 * // }>
 * })
 * ```
 */
export const sparse = <P extends Props>(
  props: P
): D.Decoder<unknown, { [K in keyof Sparse<P>]: Sparse<P>[K] }> => {
  const partitioned = pipe(props, partition(isOptional));
  return pipe(
    D.struct(partitioned.left),
    D.intersect(D.partial(partitioned.right))
  ) as any;
};

Also, It has really nice type inference.

Let me know if this is desired and will make PR to add this /cc @gcanti


Downside is that when you hover over the sparse inferred type for P is not as nice as it would have been in case of struct:

Screen Shot 2021-06-06 at 12 20 42 AM
thewilkybarkid commented 2 years ago

The only "elegant" way I found recently is to use optionFromNullable from the io-ts-types library. However, it uses an Option<A> instead of A | undefined, so maybe it's not exactly what you are looking for...

Worth noting that it encodes to A | null rather than A | undefined, which can be unexpected (it decodes from A | null | undefined).

thewilkybarkid commented 2 years ago

I think I have a simplistic Option/undefined-based codec working:

const optionalD: <I, A>(or: d.Decoder<I, A>) => d.Decoder<undefined | I, O.Option<A>> = or =>
  ({ decode: i => i === undefined ? E.right(O.none) : pipe(i, or.decode, E.map(O.some)) })

const optionalE: <I, A>(or: e.Encoder<I, A>) => e.Encoder<undefined | I, O.Option<A>> = or =>
  ({ encode: flow(O.map(or.encode), O.toUndefined) })

const optionalC = <I, O, A>(codec: c.Codec<I, O, A>) => c.make(optionalD(codec), optionalE(codec))
mjburghoffer commented 2 years ago

I added a PR to address this: https://github.com/gcanti/io-ts/pull/654