ForbesLindesay / funtypes

Runtime validation for static types
MIT License
29 stars 4 forks source link

[Feature request] Provide a means of deriving the encoded/serialised type from a funtype #46

Open treykasada opened 3 years ago

treykasada commented 3 years ago

17 essentially transformed funtypes into codecs, able to both decode/parse and encode/serialise values.

The existing Static type function allows us to derive the decoded/parsed type from a funtype. It would be really useful to also have a way of deriving the encoded/serialised type. 🙂

The primary usecase I have is including data defined by funtypes describing non-JSON-representable values in statically-typed JSON API response payloads. E.g.

// Todo.ts

import * as f from 'funtypes';
import { StringEncodedDate } from './StringEncodedDate';

const Todo = f.Object({
  content: f.String,
  dueDate: StringEncodedDate, // Parsed to a `Date`, but serialised as an ISO-8601 timestamp
});
// api.ts

// Can't use `f.Static<typeof Todo>` here because `Date`s aren't JSON-representable, so I
// have to declare the serialised type manually
// Ideally I could instead just do something like `type Todo = f.SerialisedType<typeof Todo>`
type Todo = {
  content: string,
  dueDate: string,
};

type GetTodosPayload = Todo[];

I took a look at the existing type definitions, and it looks like the main blocker for this is that Codec doesn't currently keep track of the serialised type like it does for the parsed type. Hopefully not too hard to add though.

treykasada commented 3 years ago

Currently using this as a workaround:

import * as f from "funtypes";
import { RuntypeBase } from "funtypes/lib/runtype";
import { RecordFields } from "funtypes/lib/types/Object";

type IntersectionFromUnion<Union> = (
  Union extends any ? (_: Union) => void : never
) extends (_: infer Intersection) => void
  ? Intersection
  : never;

type StaticSerialisedObject<
  TypesByField extends RecordFields,
  IsPartial extends boolean,
  IsReadonly extends boolean
> = IsPartial extends false
  ? IsReadonly extends false
    ? { -readonly [K in keyof TypesByField]: StaticSerialised<TypesByField[K]> }
    : { readonly [K in keyof TypesByField]: StaticSerialised<TypesByField[K]> }
  : IsReadonly extends false
  ? { -readonly [K in keyof TypesByField]?: StaticSerialised<TypesByField[K]> }
  : { readonly [K in keyof TypesByField]?: StaticSerialised<TypesByField[K]> };

type StaticSerialisedUnion<
  MemberTypes extends readonly RuntypeBase<any>[]
> = StaticSerialised<Extract<MemberTypes[number], RuntypeBase<any>>>;

type StaticSerialisedIntersection<
  MemberTypes extends readonly RuntypeBase<any>[]
> = IntersectionFromUnion<StaticSerialisedUnion<MemberTypes>>;

export type StaticSerialised<
  Funtype extends RuntypeBase<any>
> = Funtype extends f.ReadonlyArray<infer ElementType>
  ? readonly StaticSerialised<ElementType>[]
  : Funtype extends f.Array<infer ElementType>
  ? StaticSerialised<ElementType>[]
  : Funtype extends f.Partial<infer TypesByField, infer IsReadonly>
  ? StaticSerialisedObject<TypesByField, true, IsReadonly>
  : Funtype extends f.Object<infer TypesByField, infer IsReadonly>
  ? StaticSerialisedObject<TypesByField, false, IsReadonly>
  : Funtype extends f.Union<infer MemberTypes>
  ? StaticSerialisedUnion<MemberTypes>
  : Funtype extends f.Intersect<infer MemberTypes>
  ? StaticSerialisedIntersection<MemberTypes>
  : Funtype extends f.ParsedValue<infer SourceType, any>
  ? StaticSerialised<SourceType>
  : Funtype extends f.Constraint<infer SourceType>
  ? StaticSerialised<SourceType>
  : Funtype extends f.Brand<any, infer SourceType>
  ? StaticSerialised<SourceType>
  : Funtype extends f.Codec<infer CodecType>
  ? CodecType
  : never;

export const serialise = <Funtype extends f.Codec<any>>(
  funtype: Funtype,
  value: f.Static<Funtype>
): StaticSerialised<Funtype> =>
  funtype.serialize(value) as StaticSerialised<Funtype>;

export const safeSerialise = <Funtype extends f.Codec<any>>(
  funtype: Funtype,
  value: f.Static<Funtype>
): f.Result<StaticSerialised<Funtype>> =>
  funtype.safeSerialize(value) as f.Result<StaticSerialised<Funtype>>;

Seems to work, but it's a bit of a mess, and likely not as performant (in terms of build times) as the approach used by funtypes to derive the decoded/parsed type (i.e. Static). 😅

MicahZoltu commented 3 years ago

Looks like I came to just about the same solution. 😖 Should have checked here first (I checked a while ago but this answer is new!)

export type ToWireType<T> =
    T extends t.Intersect<infer U> ? UnionToIntersection<{ [I in keyof U]: ToWireType<U[I]> }[number]>
    : T extends t.Union<infer U> ? { [I in keyof U]: ToWireType<U[I]> }[number]
    : T extends t.Partial<infer U, infer V> ? V extends true ? { readonly [K in keyof U]?: ToWireType<U[K]> } : { [K in keyof U]?: ToWireType<U[K]> }
    : T extends t.Object<infer U, infer V> ? V extends true ? { readonly [K in keyof U]: ToWireType<U[K]> } : { [K in keyof U]: ToWireType<U[K]> }
    : T extends t.ReadonlyArray<infer U> ? readonly ToWireType<U>[]
    : T extends t.Array<infer U> ? ToWireType<U>[]
    : T extends t.ParsedValue<infer U, infer _> ? ToWireType<U>
    : T extends t.Codec<infer U> ? U
    : never

Likely incomplete (comparing with above it appears I'm missing Brand and Constraint at least). One nice thing about this solution is it doesn't require a dependency on internal RuntypeBase so it can be used without an update to funtypes. That being said, it would be great if funtypes integrated one of these solutions directly into the serialize functions so we don't have to hack around it!