TypeScript vs typelang

See the difference: traditional TypeScript patterns vs typelang's functional approach. Each example shows real code with pros and cons.

6 Examples Side by Side
State Management

Mutable vs Immutable State

Traditional TypeScript

// Mutation everywhere
let count = 0;
function increment() {
  count++;  // Side effect!
  console.log(count);  // Another side effect!
  return count;
}

// What if called from multiple places?
// What if count is modified elsewhere?
// How do you test this?

Issues

  • ❌ Hidden mutation makes testing hard
  • ❌ Side effects not tracked in types
  • ❌ No guarantee of execution order
  • ❌ Cannot safely parallelize
  • ❌ Debugging requires tracing all mutations

typelang

// Pure transformation
const increment = () =>
  seq()
    .let(() => State.get<{count: number}>())
    .let((state) => ({count: state.count + 1}))
    .do((next) => Console.op.log(`${next.count}`))
    .do((next) => State.put(next))
    .return((next) => next.count);

// Type: Eff<number, State | Console>
// Test: swap State handler for mock
// Debug: all state changes explicit

Benefits

  • ✅ All effects tracked in type signature
  • ✅ Pure functions = easy testing
  • ✅ Explicit sequencing with seq()
  • ✅ Handler swapping for different contexts
  • ✅ Time-travel debugging possible
Control Flow

if/else vs Pattern Matching

Traditional TypeScript

// if/else branching
function processConfig(input: string | undefined) {
  if (!input) {
    throw new Error("Missing input");
  }
  if (input === "beta") {
    return { mode: "beta" };
  } else if (input === "stable") {
    return { mode: "stable" };
  } else {
    throw new Error(`Unknown: ${input}`);
  }
}

// Easy to forget a case
// Easy to add bugs when extending
// Exceptions are invisible in types

Issues

  • ❌ Throws hidden exceptions
  • ❌ Non-exhaustive cases
  • ❌ Hard to extend safely
  • ❌ Implicit control flow
  • ❌ Type system doesn't help

typelang

// Pattern matching
type Mode = {tag: "Beta"} | {tag: "Stable"};

const processConfig = (input: string | undefined) =>
  match(presence(input), {
    Missing: () => Exception.op.fail({tag: "MissingInput"}),
    Present: ({value}) =>
      match(identifyMode(value), {
        Beta: () => ({tag: "Beta" as const}),
        Stable: () => ({tag: "Stable" as const}),
        Unknown: ({value: v}) =>
          Exception.op.fail({tag: "UnknownMode", value: v}),
      }),
  });

// Compiler enforces all cases
// Errors as typed values
// Easy to extend with new modes

Benefits

  • ✅ Exhaustive pattern matching
  • ✅ Errors as typed values
  • ✅ No hidden exceptions
  • ✅ Compiler enforces all cases
  • ✅ Safe to refactor
Async Operations

Promise Chains vs Effect Composition

Traditional TypeScript

// Promise hell
async function loadData() {
  try {
    const user = await fetchUser();
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    return { user, posts, comments };
  } catch (e) {
    console.error(e);  // Side effect!
    throw e;  // Re-throw
  }
}

// Sequential when could be parallel
// Error handling is imperative
// console.error is hidden side effect

Issues

  • ❌ Sequential when could be parallel
  • ❌ Side effects in error handling
  • ❌ Exceptions escape type system
  • ❌ Hard to test error paths
  • ❌ No structured logging

typelang

// Effect-based async
const loadData = () =>
  seq()
    .let(() => fetchUser())
    .let((user) =>
      par.all({
        posts: () => fetchPosts(user.id),
        metrics: () => fetchMetrics(user.id),
      })
    )
    .let((parallel) =>
      fetchComments(parallel.posts[0]?.id)
    )
    .return((comments, ctx) => ({
      user: ctx!["v1"],
      posts: (ctx!["v2"] as any).posts,
      comments,
    }));

// Type: Eff<Data, Http | Exception>

Benefits

  • ✅ Explicit parallelism with par.all()
  • ✅ No try/catch needed
  • ✅ Exception handler provides Result type
  • ✅ All effects visible in signature
  • ✅ Easy to add logging/metrics
Error Handling

Exceptions vs Result Types

Traditional TypeScript

// Try/catch everywhere
function parseJSON(input: string): User {
  try {
    const data = JSON.parse(input);
    if (!data.id || !data.name) {
      throw new Error("Invalid user");
    }
    return data as User;
  } catch (e) {
    // What type is e?
    throw new Error("Parse failed");
  }
}

// Caller must remember to catch
// No indication of possible failures
// Error types are unknown

Issues

  • ❌ Exceptions not in type signature
  • ❌ Caller must remember try/catch
  • ❌ Error types are unknown
  • ❌ Hard to handle different error cases
  • ❌ Testing error paths is awkward

typelang

// Result types
type ParseError =
  | {tag: "InvalidJSON"; message: string}
  | {tag: "MissingField"; field: string};

const parseJSON = (input: string) =>
  seq()
    .let(() =>
      match(tryParse(input), {
        Ok: ({value}) => value,
        Err: ({error}) =>
          Exception.op.fail({
            tag: "InvalidJSON",
            message: error
          }),
      })
    )
    .when(
      (parsed) => !parsed.id,
      () => Exception.op.fail({
        tag: "MissingField",
        field: "id"
      })
    )
    .return((parsed) => parsed as User);

// Use with Exception.tryCatch() handler
// Returns: Result<User, ParseError>

Benefits

  • ✅ Errors as typed data structures
  • ✅ All failures visible in signature
  • ✅ Exhaustive error handling
  • ✅ Easy to test all error cases
  • ✅ No try/catch spaghetti
Testing

Mocks vs Handler Swapping

Traditional TypeScript

// Complex mocking
class UserService {
  constructor(private db: Database) {}

  async getUser(id: string) {
    const user = await this.db.query("SELECT * FROM users");
    console.log(`Found user ${user.name}`);
    return user;
  }
}

// Test requires mocking framework
const mockDb = createMock<Database>();
mockDb.query.mockResolvedValue({ id: "1", name: "Alice" });
const consoleLog = jest.spyOn(console, 'log');

const service = new UserService(mockDb);
await service.getUser("1");

expect(mockDb.query).toHaveBeenCalled();
expect(consoleLog).toHaveBeenCalled();

Issues

  • ❌ Requires mocking library
  • ❌ Complex setup with spies/mocks
  • ❌ console.log can't be captured easily
  • ❌ Mocks can diverge from real behavior
  • ❌ Hard to test different scenarios

typelang

// Handler swapping
const getUser = (id: string) =>
  seq()
    .let(() => Database.op.query("SELECT * FROM users"))
    .do((user) => Console.op.log(`Found ${user.name}`))
    .return((user) => user);

// Production
await stack(
  handlers.Database.postgres(connectionString),
  handlers.Console.live(),
).run(() => getUser("1"));

// Test
const mockDb = testDatabaseHandler([
  { id: "1", name: "Alice" }
]);
const captured = await stack(
  mockDb,
  handlers.Console.capture(),
).run(() => getUser("1"));

assertEquals(captured.result, { id: "1", name: "Alice" });
assertEquals(captured.logs, ["Found Alice"]);
// No mocking library needed!

Benefits

  • ✅ No mocking library needed
  • ✅ Same code, different handlers
  • ✅ All effects captured naturally
  • ✅ Easy to test edge cases
  • ✅ Test handlers can't diverge
Code Organization

Classes vs Functions

Traditional TypeScript

// Class-based organization
class OrderProcessor {
  private tax = 0.1;

  constructor(
    private db: Database,
    private logger: Logger,
    private mailer: Mailer
  ) {}

  async processOrder(orderId: string) {
    this.logger.log(`Processing ${orderId}`);

    const order = await this.db.findOrder(orderId);
    if (!order) {
      throw new Error("Order not found");
    }

    const total = order.amount * (1 + this.tax);
    order.total = total;

    await this.db.updateOrder(order);
    await this.mailer.send(order.email, "Order processed");

    return order;
  }
}

Issues

  • ❌ Hidden mutation of order object
  • ❌ Dependencies injected via constructor
  • ❌ Hard to compose with other classes
  • ❌ Difficult to test in isolation
  • ❌ this keyword everywhere

typelang

// Function composition
type Order = Readonly<{
  id: string;
  amount: number;
  email: string;
}>;

const calculateTotal = (amount: number, taxRate: number) =>
  amount * (1 + taxRate);

const processOrder = (orderId: string, taxRate: number) =>
  seq()
    .do(() => Console.op.log(`Processing ${orderId}`))
    .let(() => Database.op.findOrder(orderId))
    .let((order) =>
      match(option(order), {
        None: () => Exception.op.fail({tag: "NotFound"}),
        Some: ({value}) => value,
      })
    )
    .let((validated) =>
      calculateTotal(validated.amount, taxRate)
    )
    .let((total, ctx) => ({
      ...(ctx!["v2"] as any),
      total,
    }))
    .do((updated) => Database.op.updateOrder(updated))
    .do((updated) =>
      Mailer.op.send(updated.email, "Order processed")
    )
    .return((updated) => updated);

// Type: Eff<Order, Console | Database | Exception | Mailer>

Benefits

  • ✅ Pure functions, no hidden state
  • ✅ Dependencies via effects, not DI
  • ✅ Easy to compose and reuse
  • ✅ Test with handler swapping
  • ✅ No this, no classes needed