Learn the Basics

Master typelang's core building blocks: sequential composition, pattern matching, function pipelines, and algebraic data types.

~15 min Beginner Friendly
Concept 1

Sequential Building with seq()

Compose computations step by step with automatic context threading

Understanding Sequential Building

seq() is typelang's monadic builder for sequential programs. Think of it as a pipeline where each step can access all previous results.

Each .let() step binds a new variable into the context. .do() runs a side effect without binding. .return() produces the final value.

The context is automatically typed—TypeScript infers what's available at each step.

No mutation needed: each step produces a new frozen context object.

Code Example

// Build a user profile sequentially
const buildProfile = (userId: string) =>
  seq()
    .let(() => fetchUser(userId))            // ctx.v1
    .let((user) => fetchPosts(user.id))       // ctx.v2
    .let((posts, ctx) => fetchFollowers((ctx!["v1"] as User).id)) // ctx.v3
    .do((followers, ctx) => Console.op.log(`Building profile for ${(ctx!["v1"] as User).name}`))
    .return((followers, ctx) => ({
      user: ctx!["v1"] as User,
      postCount: (ctx!["v2"] as Post[]).length,
      followerCount: followers.length,
    }));

// Auto-named context keys track all values:
// After first .let():  ctx.v1 = User
// After second .let(): ctx.v2 = Post[]
// After third .let():  ctx.v3 = User[]

Key Points

  • .let(fn) binds the result to auto-named context key (v1, v2, ...)
  • .do(fn) runs an effect without storing a new binding
  • .when(pred, fn) conditionally runs an effect
  • .return(fn) produces the final value using last and ctx
  • Context grows with each .let(), accessible via ctx['v1'], ctx['v2'], etc.
Concept 2

Pattern Matching with match()

Exhaustive branching that replaces if/else

Understanding Pattern Matching

match() is typelang's replacement for if/else. It works on discriminated unions (types with a 'tag' property).

The compiler ensures all cases are handled. Forget a case? Compilation error.

Unlike if/else, match() is an expression—it always returns a value.

Perfect for modeling state machines, error handling, and option types.

Code Example

// Define a discriminated union
type LoadingState =
  | Readonly<{tag: "Idle"}>
  | Readonly<{tag: "Loading"}>
  | Readonly<{tag: "Success"; data: User}>
  | Readonly<{tag: "Error"; message: string}>;

// Match exhaustively
const renderLoadingState = (state: LoadingState): string =>
  match(state, {
    Idle: () => "Click to load",
    Loading: () => "Loading...",
    Success: ({data}) => `Welcome, ${data.name}!`,
    Error: ({message}) => `Error: ${message}`,
  });

// Forget a case? TypeScript error!
// match(state, {
//   Idle: () => "...",
//   Loading: () => "...",
//   // Missing Success and Error!
// });

Key Points

  • Works on discriminated unions (objects with 'tag' property)
  • All cases must be handled (exhaustive checking)
  • Returns a value (unlike if/else which is a statement)
  • Destructure fields in each case handler
  • Compiler catches missing cases at build time
Concept 3

Function Composition with pipe()

Chain transformations left-to-right

Understanding Function Composition

pipe() chains function calls in a readable, left-to-right flow.

Instead of f(g(h(x))), write pipe(x, h, g, f).

Each function receives the output of the previous function.

Great for data transformations, validation chains, and parsing.

Code Example

// Traditional nested calls (hard to read)
const result = JSON.stringify(
  Object.fromEntries(
    Object.entries(data)
      .filter(([k, v]) => v !== null)
      .map(([k, v]) => [k.toLowerCase(), v])
  )
);

// With pipe() (reads top to bottom)
const result = pipe(
  data,
  Object.entries,
  (entries) => entries.filter(([k, v]) => v !== null),
  (entries) => entries.map(([k, v]) => [k.toLowerCase(), v]),
  Object.fromEntries,
  JSON.stringify,
);

// Real example: parse and validate
type ParseError = {tag: "InvalidFormat"} | {tag: "OutOfRange"};

const parseAge = (input: string): Age | ParseError =>
  pipe(
    input,
    (s) => parseInt(s, 10),
    (n) => isNaN(n) ? {tag: "InvalidFormat"} : n,
    (n) => n < 0 || n > 150 ? {tag: "OutOfRange"} : n,
  );

Key Points

  • First argument is the initial value
  • Subsequent arguments are functions to apply in order
  • Reads naturally left-to-right, top-to-bottom
  • Type-safe: each function must accept the previous output
  • Up to 9 functions supported (extend with more overloads if needed)
Concept 4

Data Modeling with Discriminated Unions

Make illegal states unrepresentable

Understanding Data Modeling

Model your domain with union types instead of classes and inheritance.

Each variant has a 'tag' property to distinguish cases.

The compiler ensures you handle all variants when matching.

Prevents invalid state combinations at the type level.

Code Example

// Bad: boolean flags create impossible states
type BadUser = {
  name: string;
  isGuest: boolean;
  isPremium: boolean;  // Can be both guest AND premium? 🤔
  email: string | null;
};

// Good: discriminated union
type User =
  | Readonly<{tag: "Guest"; sessionId: string}>
  | Readonly<{tag: "Registered"; email: string; name: string}>
  | Readonly<{tag: "Premium"; email: string; name: string; tier: "gold" | "platinum"}>;

// Now impossible states are impossible!
const getUserEmail = (user: User): string | null =>
  match(user, {
    Guest: () => null,
    Registered: ({email}) => email,
    Premium: ({email}) => email,
  });

// State machine example
type TrafficLight =
  | Readonly<{tag: "Red"; countdown: number}>
  | Readonly<{tag: "Yellow"}>
  | Readonly<{tag: "Green"; countdown: number}>;

const nextLight = (current: TrafficLight): TrafficLight =>
  match(current, {
    Red: ({countdown}) =>
      countdown > 0
        ? {tag: "Red", countdown: countdown - 1}
        : {tag: "Green", countdown: 30},
    Yellow: () => ({tag: "Red", countdown: 45}),
    Green: ({countdown}) =>
      countdown > 0
        ? {tag: "Green", countdown: countdown - 1}
        : {tag: "Yellow"},
  });

Key Points

  • Use 'tag' property to discriminate between variants
  • Make each variant's shape explicit
  • Impossible states become type errors
  • Works perfectly with match() for exhaustive handling
  • Better than inheritance: no hidden behavior, pure data