Type-safe bidirectional mapping between Protocol Buffer messages and TypeScript domain objects.
Built for use with @bufbuild/protobuf.
bun add ts-proto-mapper
# or
npm install ts-proto-mapperPeer dependency:
bun add @bufbuild/protobufimport {
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);A bidirectional converter between proto and domain types:
interface Transformer<TProto, TDomain> {
encode: (value: TDomain) => TProto;
decode: (value: TProto) => TDomain;
}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>;
}- Full Type Safety — No
anytypes, 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
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'); // 2Automatic 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',
},
);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;
},
});const timestamp: Transformer<Timestamp, Date | string | number>;Converts proto timestamps to Date objects. Encode accepts Date, ISO strings, or millisecond timestamps.
const datestamp: Transformer<Timestamp, Date | string | number>;Like timestamp, but normalizes to midnight UTC (date-only). Encode accepts Date, ISO strings, or millisecond timestamps.
const metadata = json<MyType>();Converts JSON strings to typed objects.
const passthrough = identity<string>();No transformation, just type casting.
const trimmedString: Transformer<string, string>;Trims whitespace on decode.
const bigintToNumber: Transformer<bigint, number>;Converts protobuf int64/uint64 (bigint) to number. Throws RangeError if the value exceeds safe integer range.
const bigintToString: Transformer<bigint, string>;Converts protobuf int64/uint64 (bigint) to string. Use when values may exceed safe integer range.
const struct: Transformer<JsonObject, unknown>;Converts google.protobuf.Struct JSON objects. Sanitizes values (strips undefined, converts Date to ISO strings).
const metadata = typedStruct<MyType>();
// Transformer<JsonObject, MyType>Like struct, but generic so consumers don't need to cast.
const optionalTimestamp = optional(timestamp);
// Transformer<Timestamp | undefined, Date | undefined>const nullableTimestamp = nullable(timestamp);
// Transformer<Timestamp | null | undefined, Date | null | undefined>const statusArray = array(statusTransformer);
// Transformer<number[], ('active' | 'inactive')[]>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.
const composed = composeTransformers(trimTransformer, uppercaseTransformer);const emailTransformer = createTransformer<string, string>({
encode: (email) => email.toLowerCase().trim(),
decode: (email) => email.toLowerCase().trim(),
});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
}const validatedEmail = withValidation(emailTransformer, {
encode: (email) => {
if (!email.includes('@')) throw new Error('Invalid email');
},
});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)),
},
});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.
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);
}Base error for transformation failures:
catch (error) {
if (error instanceof TransformError) {
console.error(error.context);
}
}Specific error for enum mapping failures:
catch (error) {
if (error instanceof EnumMappingError) {
console.error(error.context);
}
}const addressMapper = createMapper<ProtoAddress, DomainAddress>(ProtoAddressSchema);
const userMapper = createMapper<ProtoUser, DomainUser>(ProtoUserSchema, {
fields: {
address: optional(toTransformer(addressMapper)),
},
});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;
},
});const domainUsers = userMapper.decodeMany(protoUsers);
const protoUsers = userMapper.encodeMany(domainUsers);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>;MIT