Skip to content

rustify-ts/serde

Repository files navigation

@rustify/serde

CI npm version

A production-ready TypeScript serialization/deserialization library inspired by Rust's serde. Provides pure structural transformation between types and their serialized forms with Result-based error handling.

Features

  • 🦀 Rust-inspired: API design inspired by Rust's powerful serde library with Result types
  • 🔧 Pure transformation: Focus on serialization/deserialization with type-safe error handling
  • 🌳 Tree-shakeable: ESM-only with minimal bundle size
  • 🎯 Type-safe: Full TypeScript support with excellent type inference
  • 🚫 Zero dependencies: Lightweight with only @rustify/result as dependency
  • 🔄 Composable: Build complex serializers from simple building blocks
  • 📦 Browser-first: Designed for modern browsers and bundlers
  • Fast: Optimized for performance

Installation

# npm
npm install @rustify/serde

# pnpm
pnpm add @rustify/serde

# yarn
yarn add @rustify/serde

Quick Start

import * as t from '@rustify/serde'

// Define a person serializer
const PersonSerde = t.object({
  name: t.string,
  age: t.number,
  active: t.boolean
})

// Serialize data (returns Result<S, string>)
const person = { name: "Alice", age: 30, active: true }
const serializedResult = PersonSerde.serialize(person)
if (serializedResult.isOk()) {
  console.log(serializedResult.value) // { name: "Alice", age: 30, active: true }
}

// Deserialize data (returns Result<T, string>)
const result = PersonSerde.deserialize(serializedResult.value)
if (result.isOk()) {
  console.log(result.value) // { name: "Alice", age: 30, active: true }
} else {
  console.error(result.error) // Error message
}

// Use .unwrap() when you're confident the operation will succeed
const serialized = PersonSerde.serialize(person).unwrap()
const deserialized = PersonSerde.deserialize(serialized).unwrap()

Core Concepts

Serde Interface

All serializers implement the Serde<T, S> interface:

interface Serde<T, S> {
  serialize(value: T): Result<S, string>
  deserialize(serialized: unknown): Result<T, string>
}

Result-Based Error Handling

Both serialization and deserialization operations return Result<T, string> for type-safe error handling:

import * as t from '@rustify/serde'

const result = t.string.deserialize(123) // not a string
if (result.isOk()) {
  console.log(result.value) // string
} else {
  console.log(result.error) // "Expected string, got number"
}

// Use .unwrap() when you want throwing behavior
try {
  const value = t.string.deserialize(123).unwrap()
} catch (error) {
  console.error(error.message) // "Expected string, got number"
}

API Reference

Primitive Serializers

import * as t from '@rustify/serde'

// Basic types (all return Result<S, string>)
const stringResult = t.string.serialize("hello")
if (stringResult.isOk()) {
  console.log(stringResult.value) // "hello"
}

const numberResult = t.number.serialize(42)
if (numberResult.isOk()) {
  console.log(numberResult.value) // 42
}

const boolResult = t.boolean.serialize(true)
if (boolResult.isOk()) {
  console.log(boolResult.value) // true
}

// Date serialization (to/from ISO string)
const date = new Date("2023-01-01T00:00:00.000Z")
const dateResult = t.date.serialize(date)
if (dateResult.isOk()) {
  console.log(dateResult.value) // "2023-01-01T00:00:00.000Z"
}

const dateResult = t.date.deserialize("2023-01-01T00:00:00.000Z")
if (dateResult.isOk()) {
  console.log(dateResult.value) // Date object
}

Literal Values

import * as t from '@rustify/serde'

const ConstantSerde = t.literal("CONSTANT")
const literalResult = ConstantSerde.serialize("CONSTANT")
if (literalResult.isOk()) {
  console.log(literalResult.value) // "CONSTANT"
}

const result = ConstantSerde.deserialize("CONSTANT")
if (result.isOk()) {
  console.log(result.value) // "CONSTANT"
}

Complex Types

Objects

import * as t from '@rustify/serde'

const PersonSerde = t.object({
  name: t.string,
  age: t.number
})

Arrays

import * as t from '@rustify/serde'

const NumberArraySerde = t.array(t.number)
const arrayResult = NumberArraySerde.serialize([1, 2, 3])
if (arrayResult.isOk()) {
  console.log(arrayResult.value) // [1, 2, 3]
}

Tuples

import * as t from '@rustify/serde'

const CoordinateSerde = t.tuple(t.number, t.number, t.string)
const tupleResult = CoordinateSerde.serialize([10, 20, "point"])
if (tupleResult.isOk()) {
  console.log(tupleResult.value) // [10, 20, "point"]
}

Records

import * as t from '@rustify/serde'

const StringRecordSerde = t.record(t.string)
const recordResult = StringRecordSerde.serialize({ key: "value" })
if (recordResult.isOk()) {
  console.log(recordResult.value) // { key: "value" }
}

Modifiers

Optional Fields

import * as t from '@rustify/serde'

const OptionalString = t.optional(t.string)
const optionalResult1 = OptionalString.serialize(undefined)
if (optionalResult1.isOk()) {
  console.log(optionalResult1.value) // undefined
}

const optionalResult2 = OptionalString.serialize("hello")
if (optionalResult2.isOk()) {
  console.log(optionalResult2.value) // "hello"
}

Nullable Fields

import * as t from '@rustify/serde'

const NullableString = t.nullable(t.string)
const nullableResult1 = NullableString.serialize(null)
if (nullableResult1.isOk()) {
  console.log(nullableResult1.value) // null
}

const nullableResult2 = NullableString.serialize("hello")
if (nullableResult2.isOk()) {
  console.log(nullableResult2.value) // "hello"
}

Default Values

import * as t from '@rustify/serde'

const NumberWithDefault = t.withDefault(t.number, 0)
NumberWithDefault.deserialize(undefined) // 0
NumberWithDefault.deserialize(42) // 42

Custom Transformations

Transform data during serialization/deserialization:

import { createTransformSerde } from '@rustify/serde/serializers/primitives'
import * as t from '@rustify/serde'
import { Ok, Err } from '@rustify/result'

// Boolean to "True"/"False" string transformation
const BooleanString = createTransformSerde(
  t.string,
  (value: boolean) => value ? "True" : "False",
  (serialized: string) => serialized === "True",
  (serialized: string) => {
    if (serialized === "True") return Ok(true)
    if (serialized === "False") return Ok(false)
    return Err(`Invalid boolean string: ${serialized}`)
  }
)

const serializeResult1 = BooleanString.serialize(true)
if (serializeResult1.isOk()) {
  console.log(serializeResult1.value) // "True"
}

const serializeResult2 = BooleanString.serialize(false)
if (serializeResult2.isOk()) {
  console.log(serializeResult2.value) // "False"
}

const result1 = BooleanString.deserialize("True")
if (result1.isOk()) {
  console.log(result1.value) // true
}

const result2 = BooleanString.deserialize("Invalid")
if (result2.isErr()) {
  console.log(result2.error) // "Invalid boolean string: Invalid"
}

Recursive Types

Handle recursive data structures using getter methods (similar to Zod's approach):

import * as t from '@rustify/serde'
import type { Serde } from '@rustify/serde'

interface TreeNode {
  value: number
  name: string
  children?: TreeNode[]
}

// Use getters to define self-referential types
const TreeNodeSerde = t.object({
  value: t.number,
  name: t.string,
  get children() {
    return t.optional(t.array(TreeNodeSerde))
  }
}) as Serde<TreeNode, Record<string, unknown>>

// Now you can serialize/deserialize tree structures
const tree: TreeNode = {
  value: 1,
  name: "root",
  children: [
    { value: 2, name: "child1" },
    { value: 3, name: "child2", children: [
      { value: 4, name: "grandchild" }
    ]}
  ]
}

const serializedResult = TreeNodeSerde.serialize(tree)
if (serializedResult.isErr()) {
  console.error("Serialization failed:", serializedResult.error)
  return
}
const serialized = serializedResult.value
const result = TreeNodeSerde.deserialize(serialized)
if (result.isOk()) {
  const deserialized = result.value
  console.log(deserialized)
}

Mutually Recursive Types

You can also represent mutually recursive types using getters:

import * as t from '@rustify/serde'
import type { Serde } from '@rustify/serde'

interface User {
  email: string
  posts: Post[]
}

interface Post {
  title: string
  author: User
}

const UserSerde = t.object({
  email: t.string,
  get posts() {
    return t.array(PostSerde)
  }
}) as Serde<User, Record<string, unknown>>

const PostSerde = t.object({
  title: t.string,
  get author() {
    return UserSerde
  }
}) as Serde<Post, Record<string, unknown>>

// Note: Be careful with cyclical data - it will cause infinite loops

Error Handling

All deserialization operations return Result<T, string> for comprehensive error handling:

import * as t from '@rustify/serde'

const PersonSerde = t.object({
  name: t.string,
  age: t.number
})

const result = PersonSerde.deserialize({
  name: "Alice",
  age: "not a number" // Invalid!
})

if (result.isOk()) {
  console.log(result.value) // Person
} else {
  console.log(result.error) // "Field 'age': Expected number, got string"
}

// Chain operations with Result methods
const chainedResult = t.number.deserialize(42)
  .map(n => n * 2)
  .map(n => `The result is ${n}`)

if (chainedResult.isOk()) {
  console.log(chainedResult.value) // "The result is 84"
}

Default Export

For convenience, all serializers are available on the default export:

import * as t from '@rustify/serde'

const PersonSerde = t.object({
  name: t.string,
  age: t.number,
  active: t.boolean
})

Advanced Usage

Union Types and Error Handling

import * as t from '@rustify/serde'

// Create a union of different types
const StringOrNumberSerde = t.union([t.string, t.number])

const result1 = StringOrNumberSerde.deserialize("hello")
if (result1.isOk()) {
  console.log(result1.value) // "hello"
}

const result2 = StringOrNumberSerde.deserialize(42)
if (result2.isOk()) {
  console.log(result2.value) // 42
}

const result3 = StringOrNumberSerde.deserialize(true)
if (result3.isErr()) {
  console.log(result3.error) // Union deserialization error
}

Working with Complex Nested Data

import * as t from '@rustify/serde'

const UserSerde = t.object({
  id: t.number,
  profile: t.object({
    name: t.string,
    email: t.optional(t.string),
    preferences: t.record(t.boolean)
  }),
  posts: t.array(t.object({
    title: t.string,
    content: t.string,
    tags: t.array(t.string)
  }))
})

// Handle complex nested deserialization
const userData = {
  id: 1,
  profile: {
    name: "Alice",
    email: "[email protected]",
    preferences: { darkMode: true, notifications: false }
  },
  posts: [
    { title: "Hello", content: "World", tags: ["intro", "greeting"] }
  ]
}

const result = UserSerde.deserialize(userData)
if (result.isOk()) {
  console.log("Valid user data:", result.value)
} else {
  console.log("Validation error:", result.error)
}

Browser Support

@rustify/serde targets modern browsers that support:

  • ES2022 features
  • ESM modules
  • Node.js 22+ LTS

Contributing

  1. Fork the repository
  2. Create a feature branch
  3. Make your changes
  4. Add tests for new functionality
  5. Run pnpm test and pnpm build
  6. Submit a pull request

Development

# Install dependencies
pnpm install

# Run tests
pnpm test

# Run tests in watch mode
pnpm test:watch

# Run with coverage
pnpm test:coverage

# Build the package
pnpm build

# Lint code
pnpm lint

# Format code
pnpm format

# Run examples
pnpm examples

# Run individual examples
pnpm examples:basic
pnpm examples:recursive

License

MIT © pavi2410

Inspiration

This library is inspired by Rust's serde library, adapted for TypeScript's type system and JavaScript ecosystem.

About

A production-ready TypeScript serialization/deserialization library inspired by Rust's serde.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published