Skip to content

celom/ts-proto-mapper

Repository files navigation

ts-proto-mapper

Type-safe bidirectional mapping between Protocol Buffer messages and TypeScript domain objects.

Built for use with @bufbuild/protobuf.

Install

bun add ts-proto-mapper
# or
npm install ts-proto-mapper

Peer dependency:

bun add @bufbuild/protobuf

Quick Start

import {
  createMapper,
  createAutoEnumTransformer,
  timestamp,
  optional,
} from 'ts-proto-mapper';

// 1. Create enum transformer
const statusTransformer = createAutoEnumTransformer<'active' | 'inactive'>(
  MyProtoEnum,
  { name: 'Status' },
);

// 2. Create object mapper
const userMapper = createMapper<ProtoUser, DomainUser>(ProtoUserSchema, {
  fields: {
    status: statusTransformer,
    createdAt: optional(timestamp),
  },
});

// 3. Use it
const domainUser = userMapper.decode(protoUser);
const protoUser = userMapper.encode(domainUser);

Core Concepts

Transformer

A bidirectional converter between proto and domain types:

interface Transformer<TProto, TDomain> {
  encode: (value: TDomain) => TProto;
  decode: (value: TProto) => TDomain;
}

Mapper

Object-level bidirectional converter with field transformers:

interface Mapper<TProto, TDomain> {
  encode(domain: TDomain): TProto;
  decode(proto: TProto): TDomain;
  encodeMany(domains: TDomain[]): TProto[];
  decodeMany(protos: TProto[]): TDomain[];
  encodeSafe(domain: TDomain): TransformResult<TProto>;
  decodeSafe(proto: TProto): TransformResult<TDomain>;
}

Features

  • Full Type Safety — No any types, strict generics throughout, correct type inference
  • Composability — Chain transformers together, reuse field transformers across mappers
  • Error Handling — Custom error types with context, safe methods returning Result<T>
  • Flexibility — Lifecycle hooks (beforeEncode, afterEncode, beforeDecode, afterDecode), validation support
  • Performance — Reverse mappings computed once at creation time, efficient batch operations

API Reference

Enum Transformers

createEnumTransformer

Manual enum mapping with full control:

const statusTransformer = createEnumTransformer<number, 'active' | 'inactive'>({
  mapping: new Map([
    [1, 'active'],
    [2, 'inactive'],
  ]),
  protoDefault: 0,
  domainDefault: 'active',
  name: 'Status',
});

statusTransformer.decode(1); // 'active'
statusTransformer.encode('inactive'); // 2

createAutoEnumTransformer

Automatic mapping from proto enum objects:

const statusTransformer = createAutoEnumTransformer<'active' | 'inactive'>(
  {
    STATUS_UNSPECIFIED: 0,
    STATUS_ACTIVE: 1,
    STATUS_INACTIVE: 2,
  },
  {
    transform: (key) => key.replace('STATUS_', '').toLowerCase(),
    name: 'Status',
  },
);

Object Mappers

createMapper

Create a bidirectional object mapper:

const userMapper = createMapper<ProtoUser, DomainUser>(ProtoUserSchema, {
  // Field transformers
  fields: {
    status: statusTransformer,
    createdAt: optional(timestamp),
    metadata: optional(json<Record<string, unknown>>()),
  },

  // Hooks
  beforeEncode: (domain) => {
    return domain;
  },
  afterDecode: (domain) => {
    return domain;
  },
});

Built-in Transformers

timestamp

const timestamp: Transformer<Timestamp, Date | string | number>;

Converts proto timestamps to Date objects. Encode accepts Date, ISO strings, or millisecond timestamps.

datestamp

const datestamp: Transformer<Timestamp, Date | string | number>;

Like timestamp, but normalizes to midnight UTC (date-only). Encode accepts Date, ISO strings, or millisecond timestamps.

json

const metadata = json<MyType>();

Converts JSON strings to typed objects.

identity

const passthrough = identity<string>();

No transformation, just type casting.

trimmedString

const trimmedString: Transformer<string, string>;

Trims whitespace on decode.

bigintToNumber

const bigintToNumber: Transformer<bigint, number>;

Converts protobuf int64/uint64 (bigint) to number. Throws RangeError if the value exceeds safe integer range.

bigintToString

const bigintToString: Transformer<bigint, string>;

Converts protobuf int64/uint64 (bigint) to string. Use when values may exceed safe integer range.

struct

const struct: Transformer<JsonObject, unknown>;

Converts google.protobuf.Struct JSON objects. Sanitizes values (strips undefined, converts Date to ISO strings).

typedStruct

const metadata = typedStruct<MyType>();
// Transformer<JsonObject, MyType>

Like struct, but generic so consumers don't need to cast.

Transformer Combinators

optional

const optionalTimestamp = optional(timestamp);
// Transformer<Timestamp | undefined, Date | undefined>

nullable

const nullableTimestamp = nullable(timestamp);
// Transformer<Timestamp | null | undefined, Date | null | undefined>

array

const statusArray = array(statusTransformer);
// Transformer<number[], ('active' | 'inactive')[]>

record

const timestampMap = record(timestamp);
// Transformer<Record<string, Timestamp>, Record<string, Date>>

Transforms each value in a Record<string, V>. Useful for protobuf map<string, V> fields.

composeTransformers

const composed = composeTransformers(trimTransformer, uppercaseTransformer);

Custom Transformers

createTransformer

const emailTransformer = createTransformer<string, string>({
  encode: (email) => email.toLowerCase().trim(),
  decode: (email) => email.toLowerCase().trim(),
});

createSafeTransformer

Like createTransformer, but wraps results in TransformResult<T>:

const safeTransformer = createSafeTransformer<number, string>({
  encode: (str) => Number(str),
  decode: (num) => String(num),
});

const result = safeTransformer.encode('42');
if (result.success) {
  console.log(result.value); // 42
}

withValidation

const validatedEmail = withValidation(emailTransformer, {
  encode: (email) => {
    if (!email.includes('@')) throw new Error('Invalid email');
  },
});

toTransformer

Converts a Mapper into a Transformer for use as a nested field transformer:

const addressMapper = createMapper<ProtoAddress, DomainAddress>(ProtoAddressSchema);

const userMapper = createMapper<ProtoUser, DomainUser>(ProtoUserSchema, {
  fields: {
    address: optional(toTransformer(addressMapper)),
  },
});

withLogging

const logged = withLogging(statusTransformer, 'Status');

// With a custom logger
const logged = withLogging(statusTransformer, 'Status', myLogger);

Accepts an optional TransformLogger (any object with a log method). Defaults to console.

Safe Transformations

Use *Safe methods to get Result<T> instead of throwing:

const result = userMapper.decodeSafe(protoUser);

if (result.success) {
  console.log('User:', result.value);
} else {
  console.error('Error:', result.error.message);
}

Error Types

TransformError

Base error for transformation failures:

catch (error) {
  if (error instanceof TransformError) {
    console.error(error.context);
  }
}

EnumMappingError

Specific error for enum mapping failures:

catch (error) {
  if (error instanceof EnumMappingError) {
    console.error(error.context);
  }
}

Common Patterns

Nested Objects

const addressMapper = createMapper<ProtoAddress, DomainAddress>(ProtoAddressSchema);

const userMapper = createMapper<ProtoUser, DomainUser>(ProtoUserSchema, {
  fields: {
    address: optional(toTransformer(addressMapper)),
  },
});

Validation Hooks

const postMapper = createMapper<ProtoPost, DomainPost>(ProtoPostSchema, {
  fields: { createdAt: optional(timestamp) },
  beforeEncode: (domain) => {
    if (!domain.title || domain.title.length < 3) {
      throw new Error('Title must be at least 3 characters');
    }
    return domain;
  },
});

Batch Operations

const domainUsers = userMapper.decodeMany(protoUsers);
const protoUsers = userMapper.encodeMany(domainUsers);

Type Utilities

Extract types from transformers and mappers:

import type {
  DomainType,
  ProtoType,
  MapperDomainType,
  MapperProtoType,
} from 'ts-proto-mapper';

type MyDomain = DomainType<typeof myTransformer>;
type MyProto = ProtoType<typeof myTransformer>;
type UserDomain = MapperDomainType<typeof userMapper>;
type UserProto = MapperProtoType<typeof userMapper>;

License

MIT

About

Type-safe bidirectional mapping between Protocol Buffer messages and TypeScript domain objects

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors