io-ts

serializationTypeScriptfunctional-programmingvalidationfp-tscodec

Library

io-ts

Overview

io-ts is a functional programming library that provides a runtime type system for TypeScript. It integrates compile-time type safety with runtime data validation to safely process external input data. As part of the fp-ts ecosystem, it features error handling using the Either monad and building complex types through composable codecs.

Details

io-ts is designed around encoder and decoder pairs (codecs). Each codec provides functionality to decode unknown data into specific types and encode typed values into external representations. Based on functional programming principles, codecs are implemented as pure functions, making composition and transformation easy. Type errors are provided with detailed path and context information, facilitating debugging.

Key Features

  • Codec-based Design: Bidirectional encode/decode transformation
  • Functional Approach: Complete integration with fp-ts
  • Composability: Build complex types from small codecs
  • Detailed Error Information: Path and context on validation failure
  • Branded Types: Enhanced type safety through nominal typing
  • Custom Types: Easy implementation of custom validation logic

Pros and Cons

Pros

  • Perfect integration with functional programming
  • Very high type safety (branded type support)
  • Explicit and predictable error handling
  • Complex types expressible through codec composition
  • Side-effect-free implementation with pure functions
  • Academically robust theoretical foundation

Cons

  • Requires functional programming knowledge
  • Steep learning curve (concepts like Either, monads)
  • Error messages are hard to read out of the box
  • Somewhat verbose boilerplate
  • Mandatory dependency on fp-ts
  • Not suitable for imperative programming style

References

Code Examples

Basic Codec Definition and Usage

import * as t from 'io-ts';
import { isRight, isLeft } from 'fp-ts/Either';
import { PathReporter } from 'io-ts/PathReporter';

// Basic codec definition
const UserCodec = t.type({
  id: t.number,
  name: t.string,
  email: t.string,
  age: t.number,
  isActive: t.boolean
});

// TypeScript type extraction
type User = t.TypeOf<typeof UserCodec>;

// Decode (validate)
const unknownData = {
  id: 1,
  name: 'John Doe',
  email: '[email protected]',
  age: 30,
  isActive: true
};

const decoded = UserCodec.decode(unknownData);

if (isRight(decoded)) {
  const user: User = decoded.right;
  console.log('Valid user:', user);
} else {
  const errors = PathReporter.report(decoded);
  console.error('Validation errors:', errors);
}

// Encode
const user: User = {
  id: 1,
  name: 'John Doe',
  email: '[email protected]',
  age: 30,
  isActive: true
};

const encoded = UserCodec.encode(user);
console.log('Encoded:', encoded);

Custom Types and Refinements

import * as t from 'io-ts';
import { either } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

// Custom Email type codec
const EmailCodec = new t.Type<string, string, unknown>(
  'Email',
  (input): input is string => 
    typeof input === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input),
  (input, context) =>
    typeof input === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)
      ? t.success(input)
      : t.failure(input, context, 'Invalid email format'),
  t.identity
);

// Positive integer type
const PositiveIntCodec = t.brand(
  t.number,
  (n): n is t.Branded<number, { readonly PositiveInt: unique symbol }> =>
    Number.isInteger(n) && n > 0,
  'PositiveInt'
);

// Date string type (ISO 8601)
const DateStringCodec = new t.Type<Date, string, unknown>(
  'DateString',
  (input): input is Date => input instanceof Date,
  (input, context) => {
    if (typeof input !== 'string') {
      return t.failure(input, context, 'Expected string');
    }
    const date = new Date(input);
    return isNaN(date.getTime())
      ? t.failure(input, context, 'Invalid date string')
      : t.success(date);
  },
  (date) => date.toISOString()
);

// Usage in composite types
const AdvancedUserCodec = t.type({
  id: PositiveIntCodec,
  email: EmailCodec,
  createdAt: DateStringCodec,
  metadata: t.partial({
    lastLogin: DateStringCodec,
    preferences: t.record(t.string, t.unknown)
  })
});

type AdvancedUser = t.TypeOf<typeof AdvancedUserCodec>;

Union and Intersection Types

import * as t from 'io-ts';
import { fold } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

// Tagged union (discriminated union)
const NotificationCodec = t.union([
  t.type({
    type: t.literal('email'),
    to: t.string,
    subject: t.string,
    body: t.string
  }),
  t.type({
    type: t.literal('sms'),
    phoneNumber: t.string,
    message: t.string
  }),
  t.type({
    type: t.literal('push'),
    deviceToken: t.string,
    title: t.string,
    body: t.string,
    data: t.union([t.record(t.string, t.string), t.undefined])
  })
]);

type Notification = t.TypeOf<typeof NotificationCodec>;

// Intersection types
const TimestampedCodec = <C extends t.Mixed>(codec: C) =>
  t.intersection([
    codec,
    t.type({
      createdAt: t.string,
      updatedAt: t.string
    })
  ]);

const TimestampedUserCodec = TimestampedCodec(
  t.type({
    id: t.number,
    name: t.string,
    email: t.string
  })
);

// Usage example
const processNotification = (data: unknown) => {
  return pipe(
    NotificationCodec.decode(data),
    fold(
      (errors) => {
        console.error('Invalid notification:', PathReporter.report(either.left(errors)));
        return null;
      },
      (notification) => {
        switch (notification.type) {
          case 'email':
            console.log(`Sending email to ${notification.to}`);
            break;
          case 'sms':
            console.log(`Sending SMS to ${notification.phoneNumber}`);
            break;
          case 'push':
            console.log(`Sending push notification to ${notification.deviceToken}`);
            break;
        }
        return notification;
      }
    )
  );
};

Array and Record Processing

import * as t from 'io-ts';
import { fold } from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';

// Product codec
const ProductCodec = t.type({
  id: t.string,
  name: t.string,
  price: t.number,
  tags: t.array(t.string),
  attributes: t.record(t.string, t.union([t.string, t.number, t.boolean]))
});

// API response codec
const ApiResponseCodec = <T extends t.Mixed>(dataCodec: T) =>
  t.union([
    t.type({
      status: t.literal('success'),
      data: dataCodec,
      meta: t.type({
        page: t.number,
        totalPages: t.number,
        totalItems: t.number
      })
    }),
    t.type({
      status: t.literal('error'),
      error: t.type({
        code: t.string,
        message: t.string,
        details: t.union([t.array(t.string), t.undefined])
      })
    })
  ]);

const ProductListResponseCodec = ApiResponseCodec(t.array(ProductCodec));

// Usage example
const fetchProducts = async (page: number) => {
  const response = await fetch(`/api/products?page=${page}`);
  const data = await response.json();
  
  return pipe(
    ProductListResponseCodec.decode(data),
    fold(
      (errors) => {
        console.error('Invalid API response:', errors);
        throw new Error('Invalid API response');
      },
      (validResponse) => {
        if (validResponse.status === 'success') {
          return {
            products: validResponse.data,
            meta: validResponse.meta
          };
        } else {
          throw new Error(validResponse.error.message);
        }
      }
    )
  );
};

Integration with fp-ts

import * as t from 'io-ts';
import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array';
import { pipe } from 'fp-ts/function';
import { PathReporter } from 'io-ts/PathReporter';

// User input validation
const UserInputCodec = t.type({
  username: t.string,
  password: t.string,
  email: t.string,
  age: t.number
});

// Validation rules
const validateUsername = (username: string): E.Either<string, string> =>
  username.length >= 3 && username.length <= 20
    ? E.right(username)
    : E.left('Username must be between 3 and 20 characters');

const validatePassword = (password: string): E.Either<string, string> =>
  password.length >= 8 && /[A-Z]/.test(password) && /[0-9]/.test(password)
    ? E.right(password)
    : E.left('Password must be at least 8 characters with uppercase and number');

const validateAge = (age: number): E.Either<string, number> =>
  age >= 18 && age <= 120
    ? E.right(age)
    : E.left('Age must be between 18 and 120');

// Composite validation
const validateUserInput = (input: unknown): TE.TaskEither<string[], t.TypeOf<typeof UserInputCodec>> =>
  pipe(
    TE.fromEither(UserInputCodec.decode(input)),
    TE.mapLeft((errors) => PathReporter.report(E.left(errors))),
    TE.chain((validInput) =>
      pipe(
        E.Do,
        E.apS('username', validateUsername(validInput.username)),
        E.apS('password', validatePassword(validInput.password)),
        E.apS('age', validateAge(validInput.age)),
        E.map(() => validInput),
        TE.fromEither,
        TE.mapLeft((error) => [error])
      )
    )
  );

// Usage example
const processUserRegistration = async (input: unknown) => {
  const result = await validateUserInput(input)();
  
  pipe(
    result,
    E.fold(
      (errors) => {
        console.error('Validation errors:', errors);
      },
      (validUser) => {
        console.log('Valid user registration:', validUser);
        // Save to database, etc.
      }
    )
  );
};

Advanced Type Construction Patterns

import * as t from 'io-ts';
import { pipe } from 'fp-ts/function';
import * as E from 'fp-ts/Either';

// Recursive type definition
type TreeNode = {
  value: string;
  children: TreeNode[];
};

const TreeNodeCodec: t.Type<TreeNode> = t.recursion('TreeNode', () =>
  t.type({
    value: t.string,
    children: t.array(TreeNodeCodec)
  })
);

// Conditional types
const PaymentMethodCodec = t.union([
  t.type({
    type: t.literal('credit_card'),
    cardNumber: t.string,
    cvv: t.string,
    expiryDate: t.string
  }),
  t.type({
    type: t.literal('bank_transfer'),
    accountNumber: t.string,
    routingNumber: t.string
  }),
  t.type({
    type: t.literal('paypal'),
    email: t.string
  })
]);

// Dynamic schema generation
const createPaginatedResponseCodec = <T extends t.Mixed>(itemCodec: T) => {
  return t.type({
    items: t.array(itemCodec),
    pagination: t.type({
      page: t.number,
      perPage: t.number,
      total: t.number,
      totalPages: t.number
    }),
    links: t.type({
      first: t.string,
      last: t.string,
      prev: t.union([t.string, t.null]),
      next: t.union([t.string, t.null])
    })
  });
};

// Usage example
const UserListCodec = createPaginatedResponseCodec(
  t.type({
    id: t.number,
    name: t.string,
    email: t.string
  })
);

type UserList = t.TypeOf<typeof UserListCodec>;