Io-ts: How to describe enums and still get typescript types?

Created on 16 Oct 2018  路  13Comments  路  Source: gcanti/io-ts

I've studied the various approaches in #67 and I've done a bit of my own code to try to crack this nut, but I can't seem to figure out how to represent enums with io-ts and also get all the necessary typescript hints. The various approaches suggested in that ticket and they work at runtime, but I lose all type hints at compile time. The result from a t.TypeOf or a decode on my enum types only show up to typescript as any. Furthermore, the values that are accepted by a function, for example, at compile time that expects the type of the t.TypeOf my io-ts enum accept _all_ numbers or _all_ strings instead of only the valid values associated with the given enum.

Is this a lost cause or is there some way to accurately get the full benefits of io-ts with enums?

Most helpful comment

I wrote this to check enum types, works for both string or number enums, since enums are just simple objects

update 2020-4-19: add enum reverse mapping check for number enums

/* EnumType.ts */
import * as t from 'io-ts'

// EnumType Class
export class EnumType<A> extends t.Type<A> {
  public readonly _tag: 'EnumType' = 'EnumType'
  public enumObject!: object
  public constructor(e: object, name?: string) {
    super(
      name || 'enum',
      (u): u is A => {
        if (!Object.values(this.enumObject).find((v) => v === u)) {
          return false
        }
        // enum reverse mapping check
        if (typeof (this.enumObject as any)[u as string] === 'number') {
          return false
        }

        return true
      },
      (u, c) => (this.is(u) ? t.success(u) : t.failure(u, c)),
      t.identity,
    )
    this.enumObject = e
  }
}

// simple helper function
export const createEnumType = <T>(e: object, name?: string) => new EnumType<T>(e, name)
/* fruit.ts */
// fruit enum
enum FRUIT {
  APPLE = 'APPLE',
  BANANA = 'BANANA',
}

// fruit codec
const FruitItem = t.type({
  fruit: createEnumType<FRUIT>(FRUIT, 'FRUIT'),
  quatity: t.number,
})

const result1 = FruitItem.is({
  fruit: 'BANANA',
  quatity: 10,
})
expect(result1).toBe(true)

const result2 = FruitItem.is({
  fruit: 'apple',
  quatity: 8,
})
expect(result2).toBe(false)


type FruitStaticType = t.TypeOf<typeof FruitItem>
// FruitStaticType is expected to be same as below
type FruitStaticType = {
  fruit: FRUIT
  quatity: number
}


All 13 comments

Literally the first thing I tried to do after installing this module today was to try to define an enum. I'm flummoxed, and wondering if it's worth trying to move forward with io-ts at all.

They need to be literals of your valid values.

For example:
enum = ['a', 'b', 'c']

would have to be implemented like this:

a = t.literal('a')
b = t.literal('b')
c = t.literal('c')

enumVar = t.union([a,b,c])

I just stumbled upon this issue when looking for searching for enums. But isn't the example proposed by @smpalileo the one that is shown as a bad practice here: https://github.com/gcanti/io-ts#union-of-string-literals ?

Firstly, io-ts library is ground-breaking.

It's my first week using TypeScript, but how about this implementation?

Using this, I'm able to get all benefits of type inference for autocomplete.

// Define a regular enum
enum TestEnum {
  Foo = 'Foo',
  Bar = 'Bar',
}

// Create an Enum-like validator
const TestEnumV = t.keyof({  // using "keyof" for better performance instead of "union"
  [TestEnum.Foo]: null,
  [TestEnum.Bar]: null,
});

// Create a Product type validator
const ProductV = t.type({
  id: t.number,
  name: t.string,
  quantity: t.number,
  type: TestEnumV,
});

type Product = t.TypeOf<typeof ProductV>;
const item: Product = {
  id: 3,
  name: 'yo',
  quantity: 123,
  type: TestEnum.Foo,  // Use TypeScript's "enum" here instead of "TestEnumV"
};

const result = ProductV.is(item);
console.log('result', result); // => true

I wrote this to check enum types, works for both string or number enums, since enums are just simple objects

update 2020-4-19: add enum reverse mapping check for number enums

/* EnumType.ts */
import * as t from 'io-ts'

// EnumType Class
export class EnumType<A> extends t.Type<A> {
  public readonly _tag: 'EnumType' = 'EnumType'
  public enumObject!: object
  public constructor(e: object, name?: string) {
    super(
      name || 'enum',
      (u): u is A => {
        if (!Object.values(this.enumObject).find((v) => v === u)) {
          return false
        }
        // enum reverse mapping check
        if (typeof (this.enumObject as any)[u as string] === 'number') {
          return false
        }

        return true
      },
      (u, c) => (this.is(u) ? t.success(u) : t.failure(u, c)),
      t.identity,
    )
    this.enumObject = e
  }
}

// simple helper function
export const createEnumType = <T>(e: object, name?: string) => new EnumType<T>(e, name)
/* fruit.ts */
// fruit enum
enum FRUIT {
  APPLE = 'APPLE',
  BANANA = 'BANANA',
}

// fruit codec
const FruitItem = t.type({
  fruit: createEnumType<FRUIT>(FRUIT, 'FRUIT'),
  quatity: t.number,
})

const result1 = FruitItem.is({
  fruit: 'BANANA',
  quatity: 10,
})
expect(result1).toBe(true)

const result2 = FruitItem.is({
  fruit: 'apple',
  quatity: 8,
})
expect(result2).toBe(false)


type FruitStaticType = t.TypeOf<typeof FruitItem>
// FruitStaticType is expected to be same as below
type FruitStaticType = {
  fruit: FRUIT
  quatity: number
}


In the meantime, here's a relatively clean alternative using fromRefinement in io-ts-types:

enum Thing {
    A = 'a',
    B = 'b',
}

const isThing: Refinement<unknown, Thing> = (arg): arg is Thing =>
    Object.values<unknown>(Thing).includes(arg);

const thingCodec = fromRefinement<Thing>('thing', isThing);

I took @SamHH's idea, removed the dependency on io-ts-types and turned this into a fromEnum utility function. Works great so far.

// this utility function can be used to turn a TypeScript enum into a io-ts codec.
export function fromEnum<EnumType>(enumName: string, theEnum: Record<string, string | number>) {
  const isEnumValue = (input: unknown): input is EnumType => Object.values<unknown>(theEnum).includes(input);

  return new t.Type<EnumType>(
    enumName,
    isEnumValue,
    (input, context) => (isEnumValue(input) ? t.success(input) : t.failure(input, context)),
    t.identity
  );
}

here's a usage example:

import { PathReporter } from "io-ts/lib/PathReporter";

enum Thing {
  FOO = "foo",
  BAR = "bar"
}

const ThingCodec = fromEnum<Thing>("Thing", Thing);

console.log(PathReporter.report(ThingCodec.decode('invalidvalue')));
// prints [ 'Invalid value "invalidvalue" supplied to : Thing' ]
console.log(PathReporter.report(ThingCodec.decode('foo')));
// prints [ 'No errors!' ]

If you're only using string enums, I found a very slight improvement that allows omission of the type argument without it creating any types:

export function fromEnum<EnumType extends string>(
  enumName: string,
  theEnum: Record<string, EnumType>,
): t.Type<EnumType, EnumType, unknown> {
  const isEnumValue = (input: unknown): input is EnumType =>
    Object.values<unknown>(theEnum).includes(input);

  return new t.Type<EnumType>(
    enumName,
    isEnumValue,
    (input, context) =>
      isEnumValue(input) ? t.success(input) : t.failure(input, context),
    t.identity,
  );
}

Usage:

enum Thing {
  FOO = 'foo',
  BAR = 'bar',
}

const ThingCodec = fromEnum('Thing', Thing); // Can omit the type argument
type ThingType = t.TypeOf<typeof ThingCodec>;

const thing1: ThingType = Thing.FOO;

const thing2: ThingType = 'foo';
// `tsc` error TS2322: Type '"foo"' is not assignable to type 'Thing'.

console.log(PathReporter.report(ThingCodec.decode('invalidvalue')));
// prints [ 'Invalid value "invalidvalue" supplied to : Thing' ]
console.log(PathReporter.report(ThingCodec.decode('foo')));
// prints [ 'No errors!' ]

Of course with numeric enums, the reverse mapping breaks this 馃し

Of course with numeric enums, the reverse mapping breaks this

Numeric enums shouldn't be used anyway, they are unsafe

export enum E {
  a = 1
}
declare function f(e: E): void
f(666)

Numeric enums shouldn't be used anyway, they are unsafe

Completely agree!

I wrote this to check enum types, works for both string or number enums, since enums are just simple objects

update 2020-4-19: add enum reverse mapping check for number enums

should merge this into https://github.com/gcanti/io-ts-types

I took @samhh's idea, removed the dependency on io-ts-types and turned this into a fromEnum utility function. Works great so far.

// this utility function can be used to turn a TypeScript enum into a io-ts codec.
export function fromEnum<EnumType>(enumName: string, theEnum: Record<string, string | number>) {
  const isEnumValue = (input: unknown): input is EnumType => Object.values<unknown>(theEnum).includes(input);

  return new t.Type<EnumType>(
    enumName,
    isEnumValue,
    (input, context) => (isEnumValue(input) ? t.success(input) : t.failure(input, context)),
    t.identity
  );
}

here's a usage example:

import { PathReporter } from "io-ts/lib/PathReporter";

enum Thing {
  FOO = "foo",
  BAR = "bar"
}

const ThingCodec = fromEnum<Thing>("Thing", Thing);

console.log(PathReporter.report(ThingCodec.decode('invalidvalue')));
// prints [ 'Invalid value "invalidvalue" supplied to : Thing' ]
console.log(PathReporter.report(ThingCodec.decode('foo')));
// prints [ 'No errors!' ]

a bit better version that actually returns typesafe enum values:

export const fromEnum = <T extends string, TEnumValue extends string | number>(
  enumName: string,
  theEnum: { [key in T]: TEnumValue }
): t.Type<TEnumValue> => {
  const isEnumValue = (input: unknown): input is TEnumValue =>
    Object.values(theEnum).includes(input)

  return new t.Type<TEnumValue>(
    enumName,
    isEnumValue,
    (input, context) => (isEnumValue(input) ? t.success(input) : t.failure(input, context)),
    t.identity
  )
}

For those that find themselves here, another iteration on the theme.

/**
 * Produce a Map populated with an enumeration's inverted key-value pair(s).
 */
export type ReverseMap<T> = Map<{ [K in keyof T]: T[K] }[keyof T], keyof T>;

/**
 * The Utils service class provides a collection of convenience methods.
 */
export class Utils {
  /**
   * Given an enumeration created in Typescript, produce an io-ts validator schema.
   *
   * @see https://github.com/gcanti/io-ts/issues/216
   *
   * @example
   * ```typescript
   * enum Foo {
   *   bar = 'baz',
   * }
   *
   * const TFizz = t.type({
   *   buzz: Utils.enum<Foo>(Foo),
   * });
   * ```
   *
   * @param {E} enumeration
   * The enumeration object.
   *
   * @param {string} name
   * An optional name of the enumeration type.
   *
   * @returns {t.Type<T>}
   * An io-ts validation schema for the enumeration.
   */
  public static enum<T, E extends object = object>(
    enumeration: E,
    name: string = 'enum',
  ): t.Type<T> {
    return new (class extends t.Type<T> {
      public readonly reverseMap: ReverseMap<E>;

      constructor() {
        super(
          name,
          (value: unknown): value is T => this.reverseMap.has(value as E[keyof E]),
          (value: unknown, context: t.Context): either.Either<t.Errors, T> =>
            this.is(value) ? t.success(value) : t.failure(value, context),
          t.identity,
        );

        this.reverseMap = Utils.enumReverseMap(enumeration);
      }
    })();
  }

  /**
   * Given an arbitrary enumeration, generate the reverse lookup map.
   *
   * @param {object} enumeration
   * The enumeration.
   *
   * @returns {Map}
   * A Mapping where the key is the value and the value is the key.
   */
  public static enumReverseMap<E extends object>(enumeration: E): ReverseMap<E> {
    return new Map(Object.entries(enumeration).map(([key, value]) => [value, key as keyof E]));
  }
}
Was this page helpful?
0 / 5 - 0 ratings

Related issues

philipp-schaerer-lambdait picture philipp-schaerer-lambdait  路  3Comments

ggoodman picture ggoodman  路  8Comments

cloudify picture cloudify  路  4Comments

mottetm picture mottetm  路  5Comments

xogeny picture xogeny  路  5Comments