Skip to content

Codec & Transform

Type-safe data transformation with bidirectional schema documentation.

Overview

Codecs and Transforms provide documented transformations between data formats. Unlike simple functions, they preserve schema metadata on both input and output sides.

The Core Idea: Shared API Documentation

The main purpose of Codecs is to define transformation logic once and share it across boundaries. A single Codec definition serves as both implementation and documentation that any party can execute:

  • Client encodes data before sending (e.g., AuthInfo -> Authorization header)
  • Server decodes data after receiving (e.g., Authorization header -> AuthInfo)
  • Server-to-Server can do both directions as needed

This means your API transformation logic is:

  1. Defined once - No duplicate encode/decode implementations
  2. Self-documenting - Input and output schemas are always available
  3. Executable by any party - Client, server, or tooling can run it
  4. Automatically consistent - Both sides use the exact same transformation

Benefits

  • OpenAPI documentation generation from schema metadata
  • Type-safe bidirectional transformations (encode/decode)
  • Composable transformation pipelines
  • Runtime validation of both input and output

GGTransform

A single-direction transformation with input and output schemas.

typescript
import {GGTransform} from "@grest-ts/schema"
import {IsString, IsNumber, IsObject} from "@grest-ts/schema"

// Transform string to number
const StringToNumber = new GGTransform(
    IsString,           // input schema
    IsNumber,           // output schema
    (s) => parseInt(s, 10)  // transform function
);

// Use the transform
const result = StringToNumber.encode("42");
if (result.success) {
    console.log(result.value);  // 42
}

Validation Semantics

Transform validates both input and output with different error handling:

  • Input validation failure - Returns error result (bad user data)
  • Output validation failure - Throws exception (coding error in transform)
typescript
// Invalid input - returns error
const result = StringToNumber.encode(123 as any);
// result.success === false, result.issues contains validation errors

// Invalid output - throws (bug in your transform function)
const BadTransform = new GGTransform(
    IsString,
    IsNumber,
    () => "not a number" as any  // Bug: returns wrong type
);
BadTransform.encode("test");  // Throws SERVER_ERROR

Chaining Transforms

typescript
const StringToNumber = new GGTransform(IsString, IsNumber, (s) => parseInt(s, 10));
const NumberToBoolean = new GGTransform(IsNumber, IsString, (n) => n > 0 ? "true" : "false");

// Chain: String -> Number -> String("true"/"false")
const StringToBoolString = StringToNumber.transformTo(NumberToBoolean);

const result = StringToBoolString.encode("42");
// result.value === "true"

GGCodec

Bidirectional transformation with encode and decode operations.

typescript
import {GGCodec, GGTransform} from "@grest-ts/schema"
import {IsString, IsObject} from "@grest-ts/schema"

const AuthInfoSchema = IsObject({
    userId: IsString,
    token: IsString
});

const AuthHeaderCodec = new GGCodec({
    encode: new GGTransform(
        AuthInfoSchema,
        IsString,
        (info) => `Bearer ${info.userId}:${info.token}`
    ),
    decode: new GGTransform(
        IsString,
        AuthInfoSchema,
        (header) => {
            const [, payload] = header.split(' ');
            const [userId, token] = payload.split(':');
            return {userId, token};
        }
    )
});

// Encode: AuthInfo -> Header string
const encoded = AuthHeaderCodec.encode({userId: 'user123', token: 'abc'});
// encoded.value === "Bearer user123:abc"

// Decode: Header string -> AuthInfo
const decoded = AuthHeaderCodec.decode('Bearer user123:abc');
// decoded.value === {userId: 'user123', token: 'abc'}

Chaining Codecs

typescript
const StringNumberCodec = new GGCodec({
    encode: new GGTransform(IsString, IsNumber, (s) => parseInt(s, 10)),
    decode: new GGTransform(IsNumber, IsString, (n) => n.toString())
});

const NumberBoolCodec = new GGCodec({
    encode: new GGTransform(IsNumber, IsString, (n) => n > 0 ? "positive" : "zero"),
    decode: new GGTransform(IsString, IsNumber, (s) => s === "positive" ? 1 : 0)
});

// Chain codecs: String <-> Number <-> String
const chained = StringNumberCodec.codecTo(NumberBoolCodec);

chained.encode("42");   // "positive"
chained.decode("positive");  // "1"

Use Cases

API Header Transformation

typescript
const AuthHeaderCodec = new GGCodec({
    encode: new GGTransform(AuthInfoSchema, IsString, (info) => `Bearer ${info.token}`),
    decode: new GGTransform(IsString, AuthInfoSchema, parseAuthHeader)
});

// Server: decode incoming header
const authInfo = AuthHeaderCodec.decode(req.headers.authorization);

// Client: encode outgoing header
const header = AuthHeaderCodec.encode({userId: '123', token: 'abc'});

Data Serialization

typescript
// IsDate validates YYYY-MM-DD string format
// Transform between date string and timestamp number
const DateTimestampCodec = new GGCodec({
    encode: new GGTransform(IsDate, IsNumber, (dateStr) => new Date(dateStr).getTime()),
    decode: new GGTransform(IsNumber, IsDate, (ts) => new Date(ts).toISOString().split('T')[0])
});

// Encode: "2024-01-15" -> 1705276800000
const timestamp = DateTimestampCodec.encode("2024-01-15");

// Decode: 1705276800000 -> "2024-01-15"
const dateStr = DateTimestampCodec.decode(1705276800000);

OpenAPI Documentation

Since transforms preserve both input and output schemas, tools can generate accurate API documentation:

typescript
const transform = new GGTransform(InputSchema, OutputSchema, transformFn);

// Access schemas for documentation generation
transform.inputSchema   // Request body schema
transform.outputSchema  // Wire format schema

Best Practices

Validate Output Schemas

Always define accurate output schemas. Output validation catches bugs in your transform functions early:

typescript
// Good - output schema matches what transform returns
const Transform = new GGTransform(
    IsString,
    IsObject({id: IsNumber, name: IsString}),
    (s) => ({id: parseInt(s), name: `User ${s}`})
);

// Bad - output schema doesn't match, will throw at runtime
const BadTransform = new GGTransform(
    IsString,
    IsNumber,  // Says it returns number
    (s) => ({id: s})  // Actually returns object - throws!
);

Use Codecs for Bidirectional Data

When data flows both ways (client/server, serialize/deserialize), use GGCodec:

typescript
// Good - bidirectional transformation
const UserCodec = new GGCodec({
    encode: new GGTransform(UserSchema, UserDTOSchema, toDTO),
    decode: new GGTransform(UserDTOSchema, UserSchema, fromDTO)
});

// Avoid - separate unrelated transforms
const toDTO = new GGTransform(UserSchema, UserDTOSchema, ...);
const fromDTO = new GGTransform(UserDTOSchema, UserSchema, ...);

Chain vs Compose

Use transformTo/codecTo for pipelines, not nested function calls:

typescript
// Good - composable chain
const pipeline = step1.transformTo(step2).transformTo(step3);

// Avoid - nested calls lose schema metadata
const result = step3.encode(step2.encode(step1.encode(input)));