@healthinal/typescript-schema-transformer

Transformation library to ensure runtime types match the typescript type declarations

Usage no npm install needed!

<script type="module">
  import healthinalTypescriptSchemaTransformer from 'https://cdn.skypack.dev/@healthinal/typescript-schema-transformer';
</script>

README

TypeScript Schema Transformer

Ensure your typings match the runtime object structures.

CircleCI codecov semantic-release License: MIT Known Vulnerabilities npm version

import {
  addStaticValueSchema,
  TransformationSchema,
  optionalStringSchema,
  requiredBooleanSchema,
  requiredStringSchema,
  transformWithSchema,
} from '@healthinal/typescript-schema-transformer';

enum AinurType {
  VALAR = 'VALAR',
  MAIAR = 'MAIAR',
}

type Valar = {
  readonly type: AinurType.VALAR;
  readonly name: string;
  readonly domain?: string;
  readonly isFemale: boolean;
};

const valarSchema: TransformationSchema<Valar> = {
  type: addStaticValueSchema(AinurType.VALAR),
  name: requiredStringSchema(),
  domain: optionalStringSchema(),
  isFemale: requiredBooleanSchema(true),
};

const [valar, _] = transformWithSchema(valarSchema, { name: 'Varda' });
console.log(valar);
/*
{
  type: 'VALAR',
  name: 'Varda',
  domain: undefined,
  isFemale: true,
}
*/

Motivation

TypeScript does not provide you with runtime type checks which is no problem most of the time if the interaction is limited to TypeScript code. But as soon as you add e. g. an external API or some storage it easily can get messy because TypeScript just assumes that an API actually returns a value of the type you assert. This can lead to situations where your TypeScript functions are called with a different type than your type annotation requires. As soon as this happens, there is no guarantee anymore that you will not face runtime errors caused by type errors.

To prevent such problems it is important to add an anti-corruption layer which ensures that the TypeScript typings actually match the runtime values.

Goals

  • Enforce the correctness of the provided schema via TypeScript typings
  • Correct wrong data instead of failing (e. g. do not fail if an API returns null instead of an empty array)
  • Concise syntax
  • Favor a functional style (use pure functions whenever possible)
  • Do not use decorators to enable structural typing

Non-Goals

  • Validate an object according to a JSON Schema (use something like Ajv)
  • Support preservation of prototype information

Installation

# Yarn
yarn add @healthinal/typescript-schema-transformer

# npm
npm install @healthinal/typescript-schema-transformer

Use the package with

import { transformWithSchema } from '@healthinal/typescript-schema-transformer';

API

Transform

transformWithSchema(schema: TransformationSchema<T>, objectToTransform: unknown): [T, ValidationRemarks]

This is the main function to call from this library. The first argument is a schema which describes the type T and how a value can be transformed to this type. The second argument is the actual object which can have any type (therefore the unknown type). The function returns a tuple containing the transformed object which is assured to be of type T and a validation remarks object.

const [valar, validationRemarks] = transformWithSchema(valarSchema, {
  name: 'Varda',
});

transformWithSchema is curried which means you can easily create a function which is fixed on a specific schema.

type Valar = {
  readonly name: string;
  readonly isFemale: boolean;
};

const valarSchema: TransformationSchema<Valar> = {
  name: requiredStringSchema(),
  isFemale: requiredBooleanSchema(true),
};

const transformValar = transformWithSchema(valarSchema);
const [valar] = transformValar({ name: 'Varda', isFemale: true });
/*
valar = {
  name: 'Varda',
  isFemale: true,
}
*/

Validation remarks

As seen above, transformWithSchema returns a tuple with the transformed object and a so called validation remarks object. You might wonder what the type of ValidationRemarks is so there you go:

const objectValidationRemarkKey = Symbol();
type ValidationRemarks =
  | string
  | {
      [objectValidationRemarkKey]?: string;
      [key: string]: ValidationRemarks;
    };

It is basically a data structure which can hold the results of the validation according to the structure of the validation schema. For further usage the following functions can be used:

logWarningIfValidationRemarksArePresent(transformationName: string, validationRemarks: ValidationRemarks): void

WARNING: This function is NOT pure!!!

This is a utility function which can be used to log validation remarks to the console. This is useful if you want to work with whatever the transformation returns but still want to be able to see if something was not quite right.

const [_, validationRemarks] = transformWithSchema(valarSchema, {
  name: 'Varda',
});
logWarningIfValidationRemarksArePresent(validationRemarks);

validationRemarksToStrings(validationRemarks: ValidationRemarks): string[]

Transforms the validation remarks to a list of strings containing the remarks in a human readable format.

const [_, validationRemarks] = transformWithSchema(valarSchema, {
  name: 'Varda',
});
const remarksAsStrings = validationRemarksToStrings(validationRemarks);

hasNoValidationRemarks(validationRemarks: ValidationRemarks): boolean

Checks if there were no validation remarks. If this function returns true, the transformation process did not change anything.

const [_, validationRemarks] = transformWithSchema(valarSchema, {
  name: 'Varda',
});
if (hasNoValidationRemarks(validationRemarks)) {
  // ...
}

Schema

To be able to transform an object a schema describing the transformation has to exist for a type. The typical process is to create a schema constant of the type TransformationSchema<YourType> which will help to create a correct schema for the type. Your IDE should also be able to help with autocompletion in this process.

Object schema

The root of a schema is always a object schema. It is used to transform an object literal with zero to n (finite) keys. The values of the properties are object, array or value schemas. The used schemas have to match the passed type (referenced in TransformationSchema, TypeScript should enforce this).

Imagine a value of the following type should be transformed:

type Valar = {
  readonly name: string;
  readonly domain?: string;
  readonly isFemale: boolean;
};

There are several possible object schemas which will result in different transformations, but all will ensure that the returned value is actually of type Valar.

Example 1:

const valarSchema: TransformationSchema<Valar> = {
  name: requiredStringSchema(),
  domain: optionalStringSchema(),
  isFemale: requiredBooleanSchema(),
};

This is the most basic schema which will use the predefined default values.

Example 2:

const valarSchema: TransformationSchema<Valar> = {
  name: requiredStringSchema('no name available'),
  domain: requiredStringSchema(),
  isFemale: requiredBooleanSchema(),
};

In this example the default value of the name has been changed, meaning that a non-string value in the name property (or no name property at all) will result in the value 'no name available' instead of an empty string (which is the predefined default value). The transformation of the domain has been changed too. Since the domain property allows undefined and strings, the requiredStringSchema which ensures the value to be a string is valid too because it enforces a subset of the type domain requires.

Example 3 (DOES NOT WORK):

// NOT VALID
const valarSchema: TransformationSchema<Valar> = {
  name: optionalStringSchema(),
  domain: optionalNumberSchema(),
  isFemale: requiredNumberSchema(),
};

The example above does not work because the property name requires a string and not string | undefined and the domain as well as the isFemale property cannot hold a number. This is enforced using TypeScript so you should not be able to create a wrong schema on accident (on purpose this is of course possible).

Array schema

To transform an object which has a property containing an array you need an array schema. It contains another schema which will be used to transform all elements of the array.

Example:

type Maiar = {
  readonly name: string;
  readonly enemies: readonly string[];
};

const maiarSchema: TransformationSchema<Maiar> = {
  name: requiredStringSchema(),
  enemies: [requiredStringSchema()],
};

In the example above you can see that an array schema is a simple array containing a single item which is the transformation schema for the elements of the array.

Value schema

Value schemas can be used to transform primitive values like strings, booleans and numbers. Generally there are two types of value transformers: required and optional. Required transformers always ensure the value to be of the specific type while optional transformers allow undefined values (null will be transformed to undefined too). Required transformers allow the default value to be overridden, otherwise the predefined default value will be used (e. g. an empty string for strings or 0 for numbers).

requiredStringSchema(defaultValue?: string): ValueTransformationSchema<string>
silentRequiredStringSchema(defaultValue?: string): ValueTransformationSchema<string>

Never produces validation remarks.

optionalStringSchema(): ValueTransformationSchema<string | undefined>
requiredNumberSchema(defaultValue?: number): ValueTransformationSchema<number>
silentRequiredNumberSchema(defaultValue?: number): ValueTransformationSchema<number>

Never produces validation remarks.

optionalNumberSchema(): ValueTransformationSchema<number | undefined>
requiredBooleanSchema(defaultValue?: boolean): ValueTransformationSchema<boolean>
silentRequiredBooleanSchema(defaultValue?: boolean): ValueTransformationSchema<boolean>

Never produces validation remarks.

optionalBooleanSchema(): ValueTransformationSchema<boolean | undefined>
requiredDateSchema(defaultValue?: string): ValueTransformationSchema<string>

This ensures the string to be a valid date of the format YYYY-MM-DD. It does not only check the format but also if it is a real date (e. g. 2019-02-30 is not a valid date).

optionalDateSchema(defaultValue?: string): ValueTransformationSchema<string | undefined>
requiredTimeSchema(defaultValue?: string): ValueTransformationSchema<string>

This ensures the string to be a valid time of the format hh:mm:ss. It does not only check the format but also if it is a real time (e. g. 22:45:70 is not a valid time).

optionalTimeSchema(defaultValue?: string): ValueTransformationSchema<string | undefined>
requiredIsoDateTimeSchema(defaultValue?: string): ValueTransformationSchema<string>

Does the same checks as requiredDateSchema and requiredTimeSchema but has to be parsable by parseISO of date-fns.

optionalIsoDateTimeSchema(defaultValue?: string): ValueTransformationSchema<string | undefined>
optionalColorStringSchema(): ValueTransformationSchema<string | undefined>

Checks if the string is a valid hex color (e. g. #ffEA99).

requiredEnumSchema(enumValues: T[], defaultValue?: T): ValueTransformationSchema<T>

This transformer schema can be used to enforce a value to be one of a list of values, typically from an enum. See the examples below for more information.

staticValueSchema(value: T): ValueTransformationSchema<T>

Checks if a value matches a static value and raises a remark if it does not. Without other elements this may seem quite odd but with union transformers it can be really useful to create discriminated unions.

addStaticValueSchema(value: T): ValueTransformationSchema<T>

This is basically the same as staticValueSchema but does not raise a remark if the source object does not contain the value. It is useful to add an attribute for discriminated unions if the source (e. g. the API) does not deliver such a value.

Optional schemas

For value schemas one can just use an optional version to allow undefined values. As object and array schemas use the built-in object/array syntax, it is not directly possible to create a schema for the following type:

type Human = {
  readonly name?: string; // This is possible with optionalStringSchema
  readonly serves?: Maiar;
  readonly relatives?: string[];
};

This problem could be resolved by union types (see below) as well but this would lead to overcomplicated code for such a common problem. Therefore, an optional schema can be used which is a wrapper allowing undefined values (null will be transformed to undefined as well). This optional wrapper schema can be used with any other schema.

const schema = {
  name: optionalSchema(requiredStringSchema()),
  serves: optionalSchema({
    name: requiredStringSchema(),
    enemies: [requiredStringSchema()],
  }),
  relatives: optionalSchema([requiredStringSchema()]),
};

const [output] = transformWithSchema(schema, {});

/*
output1 = { name: undefined, serves: undefined, relatives: undefined }
 */

Union

Quite often, it is necessary to choose a transformation schema dynamically, e. g. if you receive a list of polymorphic objects which do not share every property. To support such use cases, this library has a feature called union transformers. The basic idea is to have a function which decides based on the data you receive on the schema to apply.

Example:

type Color = { red: number; green: number; blue: number };
const schema: TransformationSchema<SomeTypeSchemaDefinition> = {
  color: createUnionTypeTransformationSchema<[string | undefined, Color]>(
    noTransformationSchema,
    (base) =>
      typeof base === 'object'
        ? {
            red: requiredNumberSchema(),
            green: requiredNumberSchema(),
            blue: requiredNumberSchema(),
          }
        : optionalColorStringSchema()
  ),
};

const [output1] = transformWithSchema(schema, {
  color: '#ffffff',
});
const [output2] = transformWithSchema(schema, {
  color: {
    red: 255,
    green: 255,
    blue: 255,
  },
});
const [output3] = transformWithSchema(schema, {
  color: true,
});

/*
output1 = { color: '#ffffff' }
output2 = { color: { red: 255, green: 255, blue: 255 } }
output3 = { color: undefined }
*/

The example above uses the function createUnionTypeTransformationSchema(base => specificTransformationSchema) to transform objects containing a color field, which can either hold a color string or an object with three numeric values representing a color. The type of createUnionTypeTransformationSchema contains a lot of calculated values, so it is omitted here (you can see it here). For now, it is only important to remember that the argument is a function which receives the untransformed value and returns the transformation schema which is then actually used in the transformation. The type parameter of createUnionTypeTransformationSchema is quite important because they determine the possible results of the transformation. It is a tuple with all types which are possible types of specific transformation schemas. In the example above it is possible to return a schema which can transform a value of the type string | undefined or Color. If we added a third type of boolean, it would be possible to return a requiredBooleanSchema as the specific transformation schema.

_Side note: Ideally the union types would be resolved completely dynamic. As it turns out, recursion and the conversion of tuple types to unions do not work very well together in TS. Therefore, the resolution is done manually with a helper type which transforms tuple types to unions. This helper type currently only allows nine types in a tuple but this should be sufficient for most use cases.

In the example above you might wonder what SomeTypeSchemaDefinition looks like. Ideally it would look like this:

// DOES NOT WORK WITH THE SCHEMA ABOVE
type SomeTypeSchemaDefinition = {
  readonly color: (string | undefined) | Color;
};

The issue with this type definition is that it is almost impossible to correctly split union types automatically. You will encounter issues like boolean being split into true | false. Therefore, it is required to give the schema a hint which parts a union consists of. You can do this like this:

type SomeTypeSchemaDefinition = {
  readonly color: UnionType<[string | undefined, Color]>;
};

If you now create a schema with TransformationSchema<SomeTypeSchemaDefinition> you will be forced to use a union transformer which handles those two cases with createUnionTypeTransformationSchema.

With this solution one new issue arises: The type SomeType should actually contain a TypeScript union and not the custom type UnionType. One possibility would be to create SomeTypeSchemaDefinition (containing UnionType<[string | undefined, Color]>) as well as SomeType (containing the union (string | undefined) | Color) manually. But this would be very repetitive and SomeType can be calculated easily. To solve this issue there is the type helper DeepWithoutUnionTypes which removes all UnionType and replaces them with the matching union type.

The following example shows how this could be used with the other parts:

type SomeTypeSchemaDefinition = {
  readonly title: UnionType<[string, false]>;
};
type SomeType = DeepWithoutUnionTypes<SomeTypeSchemaDefinition>;

const schema: TransformationSchema<SomeTypeSchemaDefinition> = {
  title: createUnionTypeTransformationSchema<[string, false]>((base) =>
    typeof base === 'string' ? requiredStringSchema() : staticValueSchema(false)
  ),
};

const transformSomeType = (input: unknown): SomeType =>
  transformWithSchema(schema, input)[0];

Recursion

Sometimes data structures are nested up to a unknown depth. The features seen until now are not able to support this use case because the transformation schema is a finite data structure. It is possible to trick TypeScript into supporting this use case and transformWithSchema is able to handle it in most cases.

Caution: This can result in an infinite recursion if the input value is an infinite structure too!!!

Example of recursive schema:

type File = { name: string };
type Directory = { name: string; files: File[]; directories: Directory[] };
const schema: TransformationSchema<Directory> = {
  name: requiredStringSchema(),
  files: [
    {
      name: requiredStringSchema(),
    },
  ],
  directories: [] as any,
};
schema.directories = [schema];

As seen above it is necessary to create the schema in two steps. Since the schema in the first step is not valid we need to tell TypeScript to not check it with as any.

Custom value transformation schemas

It is possible to define your own value transformation schemas. There are two possibilities to do this.

The first possibility is to simply implement the type of ValueTransformationSchema:

type SupportedValueTypes = boolean | number | string | undefined;
type ValueTransformationSchema<T extends SupportedValueTypes> = (
  value: unknown
) => [T, string | undefined];

const evenNumberSchema: ValueTransformationSchema<number> = (value) =>
  typeof value === 'number' && value % 2 === 0
    ? [value, undefined]
    : [0, value + ' is not a even number'];

ValueTransformationSchema is a simple function which gets the value to transform as an argument and returns a tuple with the final value and a validation remark (which is undefined if everything is ok).

The other possibility is to use the predefined helpers createValueTransformationSchema(type: string, defaultValue: T, isValid: (value: unknown) => boolean, shouldRaiseRemark?: (value: unknown) => boolean): ValueTransformationSchema<T> and createValueTransformationSchemaForOptionalValue(type: string, isValid: (value: unknown) => boolean): ValueTransformationSchema<T | undefined> to create those functions.

export const requiredEvenNumberSchema = createValueTransformationSchema<number>(
  'even number',
  0,
  (value) => typeof value === 'number' && value % 2 === 0
);

export const optionalEvenNumberSchema = createValueTransformationSchemaForOptionalValue<
  number
>('even number', (value) => typeof value === 'number' && value % 2 === 0);

The type is only for the error message. In the required variant a default value has to be defined (in the optional variant this is always undefined). And most importantly a function has to be defined which checks if the value is valid according to the schema.

Example transformations

The following examples should you understand what the transformation actually does. If you need more examples, feel free to head over to the test suite containing many more examples.

The examples assume a basic transformation like this:

const schema: TransformationSchema<SomeType> = {
  // ...
};
const [output] = transformWithSchema(schema, input);

The corresponding type will be omitted in the examples because it can be derived from the schema. Accordingly the following schema...

const schema: TransformationSchema<SomeType> = {
  foo: requiredStringSchema(),
  bar: [requiredBooleanSchema()],
};

...would derive the following type:

type SomeType = {
  readonly foo: string;
  readonly bar: readonly boolean[];
};
schema input output No warnings
const schema = {
  type: addStaticValueSchema(AinurType.VALAR),
  name: requiredStringSchema(),
  domain: optionalStringSchema(),
  isFemale: requiredBooleanSchema(true),
};
const input = {
  type: 'VALAR',
  name: 'Varda',
  domain: 'Stars',
  isFemale: true,
};
const output = {
  type: 'VALAR',
  name: 'Varda',
  domain: 'Stars',
  isFemale: true,
};
:white_check_mark:
const input = {
  name: 'Varda',
  domain: null,
  isFemale: true,
};
const output = {
  type: 'VALAR',
  name: 'Varda',
  domain: undefined,
  isFemale: true,
};
:white_check_mark:
const input = {};
const output = {
  type: 'VALAR',
  name: '',
  domain: undefined,
  isFemale: true,
};
:x:
const input = {
  type: 'MAIAR',
  name: 123,
  domain: null,
  isFemale: false,
};
const output = {
  type: 'VALAR',
  name: '',
  domain: undefined,
  isFemale: false,
};
:x:
const schema = {
  name: requiredStringSchema(),
  enemies: [requiredStringSchema()],
  friends: [optionalStringSchema()],
  rings: [
    {
      name: requiredStringSchema(),
      gem: optionalStringSchema(),
    },
  ],
};
const input = {
  name: 'Gandalf',
  enemies: ['Sauron', 'Saruman'],
  friends: ['Frodo', undefined, null, 'Aragorn'],
  rings: [
    {
      name: 'Narya',
      gem: 'Ruby',
    },
  ],
};
const output = {
  name: 'Gandalf',
  enemies: ['Sauron', 'Saruman'],
  friends: ['Frodo', undefined, undefined, 'Aragorn'],
  rings: [
    {
      name: 'Narya',
      gem: 'Ruby',
    },
  ],
};
:white_check_mark:
const input = {
  name: 'Gandalf',
  enemies: ['Sauron', undefined, true],
  friends: 'Frodo',
  rings: [
    false,
    {
      name: 'Narya',
      gem: 'Ruby',
    },
  ],
};
const output = {
  name: 'Gandalf',
  enemies: ['Sauron', '', ''],
  friends: [],
  rings: [
    {
      name: '',
      gem: undefined,
    },
    {
      name: 'Narya',
      gem: 'Ruby',
    },
  ],
};
:x:
const schema = {
  a: requiredStringSchema(),
  b: optionalStringSchema(),
  c: requiredNumberSchema(),
  d: optionalNumberSchema(),
  e: requiredBooleanSchema(),
  f: optionalBooleanSchema(),
  g: requiredDateSchema(),
  h: requiredTimeSchema(),
  i: requiredIsoDateTimeSchema(),
  j: optionalColorStringSchema(),
};
const input = {
  a: 'a',
  b: undefined,
  c: 2,
  d: undefined,
  e: true,
  f: undefined,
  g: '2020-02-02',
  h: '14:55:12',
  i: '2020-02-02T14:55:12',
  j: undefined,
};
const output = {
  a: 'a',
  b: undefined,
  c: 2,
  d: undefined,
  e: true,
  f: undefined,
  g: '2020-02-02',
  h: '14:55:12',
  i: '2020-02-02T14:55:12',
  j: undefined,
};
:white_check_mark:
const input = {
  a: 'a',
  b: 'b',
  c: 2,
  d: 3,
  e: true,
  f: false,
  g: '2020-02-02',
  h: '14:55:12',
  i: '2020-02-02T14:55:12',
  j: '#fe45AE',
};
const output = {
  a: 'a',
  b: 'b',
  c: 2,
  d: 3,
  e: true,
  f: false,
  g: '2020-02-02',
  h: '14:55:12',
  i: '2020-02-02T14:55:12',
  j: '#fe45AE',
};
:white_check_mark:
const input = {};
const output = {
  a: '',
  b: undefined,
  c: 0,
  d: undefined,
  e: false,
  f: undefined,
  g: '0001-01-01',
  h: '00:00:00',
  i: '0001-01-01T00:00:00',
  j: undefined,
};
:x:
const schema = {
  a: requiredStringSchema('default'),
  b: requiredNumberSchema(42),
  c: requiredBooleanSchema(true),
  d: requiredDateSchema('2019-01-01'),
  e: requiredTimeSchema('14:00:00'),
  f: requiredIsoDateTimeSchema('2019-01-01T14:00:00'),
};
const input = {};
const output = {
  a: 'default',
  b: 42,
  c: true,
  d: '2019-01-01',
  e: '14:00:00',
  f: '2019-01-01T14:00:00',
};
:x:
const values = <T extends object, K extends keyof T>(obj: T): T[K][] =>
  Object.keys(obj).map((k) => (obj as any)[k]);

enum ElfType {
  VANYAR = 'VANYAR',
  NOLDOR = 'NOLDOR',
  TELERI = 'TELERI',
}

const schema = {
  name: requiredStringSchema(),
  type: requiredEnumSchema(values(ElfType)),
};
const input = {
  name: 'Galadriel',
  type: ElfType.NOLDOR,
};
const output = {
  name: 'Galadriel',
  type: ElfType.NOLDOR,
};
:white_check_mark:
const input = {
  name: 'Ingwe',
};
const output = {
  name: 'Ingwe',
  type: ElfType.VANYAR,
};
:x:
const schema = {
  name: requiredStringSchema(),
  type: requiredEnumSchema(values(ElfType), ElfType.TELERI),
};
const input = {
  name: 'Elwe',
  type: 'ELVISH',
};
const output = {
  name: 'Elwe',
  type: ElfType.TELERI,
};
:x:
const schema = {
  a: staticValueSchema(true),
  b: addStaticValueSchema(true),
};
const input = {};
const output = {
  a: true,
  b: true,
};
:x:
const input = {
  a: true,
};
const output = {
  a: true,
  b: true,
};
:white_check_mark:
const input = {
  a: false,
  b: false,
};
const output = {
  a: true,
  b: true,
};
:x:
enum AinurType {
  VALAR = 'VALAR',
  MAIAR = 'MAIAR',
}

type Ainur = {
  readonly type: AinurType;
  readonly name: string;
};

type Valar = Ainur & {
  readonly type: AinurType.VALAR;
  readonly domain: string;
};

type Maiar = Ainur & {
  readonly type: AinurType.MAIAR;
  readonly hasRing: boolean;
};

type World = {
  readonly name: string;
  readonly ainur: readonly UnionType<[Valar, Maiar]>[];
};

const valarSchema: TransformationSchema<Valar> = {
  type: addStaticValueSchema(AinurType.VALAR),
  name: requiredStringSchema(),
  domain: requiredStringSchema(),
};

const maiarSchema: TransformationSchema<Maiar> = {
  type: addStaticValueSchema(AinurType.MAIAR),
  name: requiredStringSchema(),
  hasRing: requiredBooleanSchema(),
};

const schema: TransformationSchema<World> = {
  name: requiredStringSchema(),
  ainur: [
    createUnionTypeTransformationSchema<[Valar, Maiar]>((base) =>
      base?.type === AinurType.MAIAR ? maiarSchema : valarSchema
    ),
  ],
};
const input = {
  name: 'Arda',
  ainur: [
    {
      type: AinurType.VALAR,
      name: 'Varda',
      domain: 'Stars',
    },
    {
      type: AinurType.MAIAR,
      name: 'Gandalf',
      hasRing: true,
    },
    {
      name: 'Ulmo',
      domain: 'Sea',
    },
    {
      type: AinurType.MAIAR,
      name: 'Melian',
    },
  ],
};
const output = {
  name: 'Arda',
  ainur: [
    {
      type: AinurType.VALAR,
      name: 'Varda',
      domain: 'Stars',
    },
    {
      type: AinurType.MAIAR,
      name: 'Gandalf',
      hasRing: true,
    },
    {
      type: AinurType.VALAR,
      name: 'Ulmo',
      domain: 'Sea',
    },
    {
      type: AinurType.MAIAR,
      name: 'Melian',
      hasRing: false,
    },
  ],
};
:x:
type File = { name: string };
type Directory = { name: string; files: File[]; directories: Directory[] };
const schema: TransformationSchema<Directory> = {
  name: requiredStringSchema(),
  files: [
    {
      name: requiredStringSchema(),
    },
  ],
  directories: [] as any,
};
schema.directories = [schema];
const input = {
  name: 'd1',
  files: [{ name: 'f1' }, { name: 0 }],
  directories: [
    {
      name: 'd1.1',
      files: [{ name: 'f1' }, { name: 'f2' }],
      directories: [],
    },
    {
      name: 'd1.2',
      files: [{ name: 'f1' }, { name: 0 }],
      directories: [
        {
          name: 'd1.2.1',
          files: [{ name: 'f1' }, { name: 0 }],
          directories: [],
        },
      ],
    },
  ],
};
const output = {
  name: 'd1',
  files: [{ name: 'f1' }, { name: '' }],
  directories: [
    {
      name: 'd1.1',
      files: [{ name: 'f1' }, { name: 'f2' }],
      directories: [],
    },
    {
      name: 'd1.2',
      files: [{ name: 'f1' }, { name: '' }],
      directories: [
        {
          name: 'd1.2.1',
          files: [{ name: 'f1' }, { name: '' }],
          directories: [],
        },
      ],
    },
  ],
};
:x:

Inspiration

  • deox: Project setup (check this library out if you are a Redux & TypeScript user)

License

This library is released under MIT license.