Skip to main content

Why Use @noony-serverless/type-builder?

Understanding the Design Philosophy and Trade-offs


Table of Contents​

  1. The Builder Pattern Problem
  2. The Type Detection Innovation
  3. Performance Through Object Pooling
  4. Type Safety Without Runtime Cost
  5. The Three Modes Philosophy

The Builder Pattern Problem​

The Classic Builder Pattern​

The Builder pattern is a creational design pattern that lets you construct complex objects step by step. It's been around since the Gang of Four book (1994), and it solves a real problem:

The Problem:

// ❌ Constructor with many parameters (hard to read, error-prone)
const user = new User('john@example.com', 'John Doe', 25, 'New York', true, 'premium', new Date());

// Which parameter is which? Easy to swap them by accident.

The Classic Solution:

// ✅ Builder pattern (readable, explicit)
const user = new UserBuilder()
.withEmail('john@example.com')
.withName('John Doe')
.withAge(25)
.withCity('New York')
.withActive(true)
.withPlan('premium')
.withCreatedAt(new Date())
.build();

This is much better. But there's a catch...

The Boilerplate Problem​

To get this nice API, you need to write a builder class for every type:

class UserBuilder {
private email?: string;
private name?: string;
private age?: number;
private city?: string;
private active?: boolean;
private plan?: string;
private createdAt?: Date;

withEmail(email: string): this {
this.email = email;
return this;
}

withName(name: string): this {
this.name = name;
return this;
}

withAge(age: number): this {
this.age = age;
return this;
}

// ... 4 more methods

build(): User {
if (!this.email || !this.name) {
throw new Error('Missing required fields');
}
return new User(
this.email,
this.name,
this.age || 0,
this.city || '',
this.active || false,
this.plan || 'free',
this.createdAt || new Date()
);
}
}

That's 50+ lines of boilerplate for a simple 7-property object. Now imagine you have:

  • 20 domain models
  • 30 DTOs
  • 15 API request/response types

You're looking at thousands of lines of repetitive code just to get a nice builder API.

Why Does This Matter?​

Maintenance Burden:

  • Add a property? Update the builder class.
  • Rename a property? Update the builder class.
  • Change a type? Update the builder class.
  • Every change = 3 places to update (model, builder, tests).

Developer Experience:

  • Copy-paste errors
  • Outdated builders that don't match the model
  • Tests that need constant updating
  • New developers overwhelmed by boilerplate

This is what @noony-serverless/type-builder solves.


The Type Detection Innovation​

The Core Insight​

What if we could generate builders automatically by detecting what you passed in?

This library introduces a novel approach: runtime type detection with compile-time safety.

How It Works​

1. Zod Schema Detection​

const UserSchema = z.object({
email: z.string().email(),
name: z.string(),
});

const createUser = builder(UserSchema);

Behind the scenes:

function isZodSchema(input: any): boolean {
return (
input &&
typeof input === 'object' &&
typeof input.parse === 'function' &&
typeof input.safeParse === 'function' &&
input._def !== undefined
);
}

We check for Zod's unique method signatures. If it has .parse(), .safeParse(), and ._def, it's a Zod schema.

What we extract:

  • Property names from schema._def.shape()
  • Type information (inferred by TypeScript)
  • Validation rules (used in .build())

2. Class Detection​

class Product {
id!: number;
name!: string;
price!: number;
}

const createProduct = builder(Product);

Behind the scenes:

function isClass(input: any): boolean {
return typeof input === 'function' && input.prototype && input.prototype.constructor === input;
}

We check if it's a function with a prototype. That's how JavaScript represents classes under the hood.

What we extract:

  • Property names (by creating a proxy instance and capturing this.x = y assignments)
  • Constructor function (used in .build())
  • Methods (preserved in the final instance)

3. Interface Detection​

interface Order {
id: string;
total: number;
}

const createOrder = builder<Order>(['id', 'total']);

Why the array?

Interfaces don't exist at runtime. They're erased during TypeScript compilation:

// TypeScript
interface Order {
id: string;
total: number;
}

// Compiles to JavaScript
// (nothing - interface disappears)

Since there's no runtime information, you must provide the property names explicitly.

Why This Matters​

Zero Boilerplate:

  • No builder classes to write
  • No manual method generation
  • No maintenance burden

Type Safety:

  • TypeScript infers all types automatically
  • IDE autocomplete works perfectly
  • Compile-time error checking

Flexibility:

  • Works with Zod (validation)
  • Works with classes (methods + OOP)
  • Works with interfaces (pure speed)

Performance Through Object Pooling​

The Performance Problem​

Building objects is fast, but creating millions of objects triggers garbage collection (GC):

// Without pooling
for (let i = 0; i < 1000000; i++) {
const builder = new UserBuilder(); // 1M allocations
const user = builder.withName('John').build();
}

// GC runs multiple times during this loop
// Result: Unpredictable pauses, slower throughput

The Issue:

  • Each builder instance allocates memory
  • After .build(), the builder becomes garbage
  • GC must clean up 1M builder instances
  • GC pauses block your application

The Object Pooling Solution​

Core Concept: Reuse builder instances instead of creating new ones.

// With pooling
const pool = new BuilderPool<UserBuilder>(() => new UserBuilder());

for (let i = 0; i < 1000000; i++) {
const builder = pool.get(); // Reuse from pool (or create if empty)
builder.reset(); // Clear previous data
const user = builder.withName('John').build();
pool.release(builder); // Return to pool
}

// GC runs rarely - only ~100 builders created total
// Result: Consistent performance, no pauses

How We Implement It​

Automatic Pooling:

const createUser = builder(UserSchema);

// Behind the scenes, we create a pool:
const pool = new BuilderPool<UserBuilder>(() => new UserBuilder());

// When you call createUser():
const userBuilder = pool.get(); // Reused or created
return userBuilder; // You build your object

// After .build(), it's automatically returned to the pool

Pool Statistics:

import { getPoolStats } from '@noony-serverless/type-builder';

const stats = getPoolStats();
console.log(stats.averageHitRate); // ~98.5%

In production, 98.5% of builders are reused, meaning:

  • Only 1.5% require new allocations
  • GC pressure reduced by 98.5%
  • Consistent, predictable performance

Performance Impact​

ModeWithout PoolingWith PoolingImprovement
Interface~200,000 ops/sec~400,000 ops/sec2x faster
Class~150,000 ops/sec~300,000 ops/sec2x faster
Zod~50,000 ops/sec~100,000 ops/sec2x faster

Why This Matters:

  • High-throughput APIs can handle 2x more requests
  • Lower memory usage
  • Predictable latency (no GC spikes)
  • Better user experience

Type Safety Without Runtime Cost​

The TypeScript Magic​

One of the most powerful features is full type inference with zero runtime overhead.

How TypeScript Types Work​

Compile-time only:

// TypeScript (before compilation)
const createUser = builder<User>(UserSchema);
const user: User = createUser().withName('John').build();

// JavaScript (after compilation)
const createUser = builder(UserSchema);
const user = createUser().withName('John').build();

All type annotations vanish. The JavaScript output is identical whether you use types or not.

Our Type System​

We use advanced TypeScript features to generate method signatures:

Utility Types:

// Capitalize first letter
type Capitalize<S extends string> = S extends `${infer F}${infer R}` ? `${Uppercase<F>}${R}` : S;

// Generate method name
type WithMethodName<K extends string> = `with${Capitalize<K>}`;

// Generate all .withXYZ() methods
type WithMethods<T> = {
[K in keyof T & string as WithMethodName<K>]: (value: T[K]) => FluentBuilder<T>;
};

// Complete builder type
export type FluentBuilder<T> = WithMethods<T> & {
build(): T;
};

What This Does:

interface User {
name: string;
email: string;
age: number;
}

// TypeScript automatically generates:
type UserBuilder = {
withName(value: string): UserBuilder;
withEmail(value: string): UserBuilder;
withAge(value: number): UserBuilder;
build(): User;
};

IDE Autocomplete​

Because these types are generated automatically, your IDE gets perfect autocomplete:

Type Inference Chain:

  1. You pass UserSchema to builder()
  2. TypeScript extracts the schema type: z.infer<typeof UserSchema>
  3. Our WithMethods<T> type generates method signatures
  4. IDE sees all available methods
  5. You get autocomplete + type checking

The Result:

const createUser = builder(UserSchema);

createUser()
.with // IDE suggests: withName, withEmail, withAge
.withName('John') // IDE knows this takes a string
.withAge('invalid') // ❌ TypeScript error: Expected number
.withFoo('bar') // ❌ TypeScript error: Property doesn\'t exist
.build();

Zero Runtime Cost​

The Performance Question: "Doesn't all this type magic slow things down?"

Answer: No. Types are compile-time only.

Proof:

// TypeScript (types present)
type FluentBuilder<T> = WithMethods<T> & { build(): T };
const createUser = builder<User>(UserSchema);

// Compiles to JavaScript (types erased)
const createUser = builder(UserSchema);

Bundle size comparison:

  • TypeScript version: 6.23 KB
  • JavaScript version: 6.23 KB (identical)

Runtime performance:

  • TypeScript version: ~400,000 ops/sec
  • JavaScript version: ~400,000 ops/sec (identical)

The types only exist in your editor and during compilation. At runtime, they're gone.

Why This Matters​

Developer Experience:

  • Full autocomplete
  • Compile-time error checking
  • Refactoring support (rename properties, IDE updates all usages)

Production Performance:

  • Zero overhead
  • No bundle size increase
  • Same speed as hand-written code

Type Safety:

  • Catch errors before deployment
  • Impossible to call .withFoo() if foo doesn't exist
  • Parameter types validated automatically

The Three Modes Philosophy​

Design Philosophy: Right Tool for the Right Job​

We could have made a one-size-fits-all solution. Instead, we chose three optimized modes for different use cases.

Why Three Modes?​

The Insight: Different scenarios have different requirements.

ScenarioPriorityBest Mode
API validationCorrectnessZod (validation)
Domain modelsBehaviorClass (methods)
Data transformationSpeedInterface (performance)

Mode 1: Zod - Validation First​

When: External input (APIs, file uploads, user forms)

Why: You cannot trust external data.

Trade-off:

  • ✅ Runtime validation catches bad data
  • ✅ Type-safe parsing
  • ❌ Slower (~100k ops/sec due to validation overhead)

Example:

const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});

const validateUser = builder(CreateUserSchema);

// API endpoint
app.post('/api/users', (req) => {
const user = validateUser()
.withEmail(req.body.email) // Could be anything
.withPassword(req.body.password) // Could be anything
.build(); // ✅ Throws if invalid

// Now it's SAFE to use
});

Why Zod?

  • Catches `email: