0

While doing functional programming I often end up in situations where I know something that the type system of the language does not know. Consider the following TypeScript example that parses a UUID and shows the embedded fields to the user. The program first validates it's input with io-ts to make sure the input follows UUID specification. Later, after splitting the input, the program is unable to verify that the split UUID contains five parts which leaves me with an fp-ts Option. It throws an assert false from getOrElse to get rid of the Option. Does functional programming have some more idiomatic ways to deal with assertions? Reporting the error to the end user doesn't feel helpful since this case would be an error in underlying assumptions of the programmer rather than something that the end user could solve.

#!/usr/bin/env ts-node

import { append, intersperse, map, prepend } from 'fp-ts/lib/Array';
import { isRight } from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/function';
import { IO } from 'fp-ts/lib/IO';
import { fromPredicate, getOrElse } from 'fp-ts/lib/Option';
import { empty } from 'fp-ts/lib/string';
import * as t from 'io-ts';

type Tuple5<A, B, C, D, E> = [A, B, C, D, E];
const length = 5;
const fromArray = fromPredicate(
  <A>(as: Array<A>): as is Tuple5<A, A, A, A, A> => as.length === length,
);
const Tuple5_ = {
  length,
  fromArray,
};

const separator = '-';

const hex = (n: number): string => `[A-Fa-f0-9]{${n}}`;
const fields: Tuple5<number, number, number, number, number> = [8, 4, 4, 4, 12];
const regexp = pipe(
  fields,
  map(hex),
  intersperse(separator),
  prepend('^'),
  append('$'),
).join(empty);

export type Uuid = t.Branded<string, UuidBrand>;
export type UuidC = t.BrandC<t.StringC, UuidBrand>;
export const Uuid: UuidC = t.brand(
  t.string,
  (x): x is t.Branded<string, UuidBrand> => x.match(RegExp(regexp)) !== null,
  'Uuid',
);
export type UuidBrand = {
  readonly Uuid: unique symbol;
};

export type TimeLow = string;
export type TimeMid = string;
export type TimeHiAndVersion = string;
export type ClockSeq = string;
export type Node = string;

export type Groups = Tuple5<TimeLow, TimeMid, TimeHiAndVersion, ClockSeq, Node>;

export const groups = (uuid: Uuid): Groups =>
  pipe(
    uuid.split(separator),
    Tuple5_.fromArray,
    getOrElse((): Groups => {
      // eslint-disable-next-line
      throw new Error('Assert false! Uuid invalid despite validation.');
    }),
  );

const main: IO<void> = () => {
  const [_node, _script, input] = process.argv;
  const result = Uuid.decode(input);
  if (isRight(result)) {
    const uuid: Uuid = result.right;
    const [timeLow, timeMid, timeHiAndVersion, clockSeq, node] = groups(uuid);
    console.log({ timeLow, timeMid, timeHiAndVersion, clockSeq, node });
  } else {
    console.error('Invalid input!');
  }
};

main();
cyberixae
  • 843
  • 5
  • 15
  • 1
    *"after splitting the input, the program is unable to verify that the split UUID contains five parts"* Why is that? I'm not going to try to understand that wall of TypeScript (a language I've only rudimentary knowledge of), but based on that sentence, wouldn't a quintuple do the job? – Mark Seemann Mar 16 '22 at 14:32
  • 2
    You may find Alexis King's [Parse, don't validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) illuminating. – Mark Seemann Mar 16 '22 at 14:32
  • You could write your own function to split up the UUID which has the assumptions you need built in. Something like `(uuid: Uuid) => Tuple5`. You could throw an error in the function if something goes wrong, but you shouldn't even need to do that as the Uuid type basically guarantees that you have the right format. Though it will require you to use a type assertion I imagine – cdimitroulas Mar 30 '22 at 08:55

1 Answers1

1

Parse, don't validate.

type UuidPart1 = string & { readonly UuidPart1: unique symbol }
type UuidPart2 = string & { readonly UuidPart2: unique symbol }
type UuidPart3 = string & { readonly UuidPart3: unique symbol }
type UuidPart4 = string & { readonly UuidPart4: unique symbol }
type UuidPart5 = string & { readonly UuidPart5: unique symbol }
type SplitUuid = [UuidPart1, UuidPart2, UuidPart3, UuidPart4, UuidPart5]

declare const parseUuid: (a: Uuid) => Option<SplitUuid>

declare const recombineUuid: (a: SplitUuid) => Uuid

The former function should split the Uuid into 5 parts and then make sure each of the 5 parts conform to the format of the 5 parts of a Uuid. If they all do, then you return a Some wrapping the SplitUuid type (which is a 5-tuple). If not, return None.

Now you write any code that requires a split-up Uuid to take SplitUuid instead of Uuid.

If it doesn't require the split, you can losslessly convert back to Uuid and have the function take a Uuid param.

Now you don't need to validate. Just write code that takes the correct type and you don't have to do any runtime validation.

If you really do have something that could take a Uuid or SplitUuid, then you need a type guard:

type AnyUuid = Uuid | SplitUuid
function isSplitUuid(a: AnyUuid): a is SplitUuid {
  return typeof a === 'object'
}

declare const logSplitUuid = (a: SplitUuid) => console.log('this is split!', a)

const example: (a: AnyUuid) => void = a => pipe(
  O.fromPredicate(isSplitUuid),
  O.getOrElse(() => parseUuid(a)),
  logSplitUuid
)

That function, as an example, will take either a split or non-split Uuid, split it if it's not already split, and then log the split. Fully type safe.

superjos
  • 12,189
  • 6
  • 89
  • 134
user1713450
  • 1,307
  • 7
  • 18