Skip to content

TypeScript Type Manipulation — Complete Guide

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about TypeScript Type Manipulation. We cover key concepts, practical examples, and best practices to help you master this topic.

TypeScript type manipulation techniques like satisfies and branded types give you finer control over Type Checkingsatisfies validates types without widening, while branded types simulate nominal typing for scenarios where structural typing is too permissive.

What You'll Learn

  • The satisfies operator (TS 4.9+)
  • satisfies vs as — when to use each
  • Branded types for nominal typing
  • Flavoring types for distinct identity
  • Practical patterns for type-safe IDs, currencies, and units

Why It Matters

TypeScript's structural type system is powerful but sometimes too loose — it considers string equal to string even when one is a user ID and the other is an email. Branded types add compile-time identity to structurally identical types. satisfies catches mistakes without changing the inferred literal type.

Real-World Use

Durga Antivirus Pro uses branded types for UserId, ScanId, and ThreatId — you cannot accidentally pass a scan ID where a user ID is expected, even though both are string. The Doda Browser extension configuration uses satisfies to validate that a config object matches a schema while preserving its literal types for autocompletion.

Learning Path

flowchart LR
  A[Declaration Files] --> B[Type Manipulation]
  B --> C[Classes & OOP]
  B --> D[You Are Here]
  C --> E[Inheritance]
  E --> F[Decorators]

The satisfies Operator

Introduced in TypeScript 4.9, satisfies checks that a value's type matches a given type without widening the value's inferred type:

type Color = "red" | "green" | "blue";

const palette1: Record<string, Color> = {
  primary: "red",
  secondary: "blue",
  // accent: "purple", // Error: not in Color
};
// Problem: palette1.primary is inferred as Color (wider than "red")

const palette2 = {
  primary: "red",
  secondary: "blue",
  accent: "purple", // No error — no type annotation!
} satisfies Record<string, Color>; // Error: "purple" is not in Color

// palette2.primary is inferred as "red" (literal type preserved!)

Think of satisfies as: "Check that this value matches the type, but keep the precise literal type for autocompletion."

satisfies vs as

type Point = { x: number; y: number };

const p1 = { x: 10, y: 20 } as Point;
// p1.x is type: number (widened)

const p2 = { x: 10, y: 20 } satisfies Point;
// p2.x is type: 10 (literal preserved!)

// as: assumes you're right (type assertion)
// satisfies: checks you're right (type validation)

satisfies with Arrays

const colors = ["red", "green", "blue"] satisfies string[];
// colors is inferred as string[], fine

const specific = ["red", "green", "blue"] satisfies [string, string, string];
// specific is inferred as [string, string, string]

satisfies with Complex Objects

interface RouteConfig {
  path: string;
  component: string;
  children?: RouteConfig[];
  meta?: Record<string, unknown>;
}

const routes = [
  {
    path: "/",
    component: "Home",
  },
  {
    path: "/users",
    component: "UserList",
    meta: {
      requiresAuth: true,
    },
  },
] satisfies RouteConfig[];

// routes[0].path is "string" but inferred as "/" literal
// routes[0].meta?.requiresAuth // Error: meta may not exist on / route — correctly caught!

Branded Types

Branded types add a compile-only property to distinguish structurally identical types:

type UserId = string & { __brand: "UserId" };
type ScanId = string & { __brand: "ScanId" };
type Email = string & { __brand: "Email" };

function createUserId(id: string): UserId {
  return id as UserId;
}

function createScanId(id: string): ScanId {
  return id as ScanId;
}

function getUser(id: UserId): void {
  console.log(`Getting user ${id}`);
}

function getScan(id: ScanId): void {
  console.log(`Getting scan ${id}`);
}

const userId = createUserId("usr-001");
const scanId = createScanId("scan-001");

getUser(userId);    // OK
// getUser(scanId); // Error: ScanId is not assignable to UserId
// getUser("usr-002"); // Error: string is not assignable to UserId

Think of brands as tamper-proof labels — two strings look the same, but their brands distinguish them at compile time. The brand property doesn't exist at runtime; it's erased during compilation.

Brand Helper

declare const brand: unique symbol;

type Brand<T, B extends string> = T & { [brand]: B };

type UserId = Brand<string, "UserId">;
type ScanId = Brand<string, "ScanId">;

function create<T, B extends string>(value: T, _brand: B): Brand<T, B> {
  return value as Brand<T, B>;
}

const uid = create("usr-001", "UserId");
const sid = create("scan-001", "ScanId");

Number Brands

type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;

function toMs(seconds: Seconds): Milliseconds {
  return (seconds * 1000) as Milliseconds;
}

const timeout = 30 as Seconds;
const delay = toMs(timeout); // type: Milliseconds
// toMs(delay); // Error: Milliseconds is not Seconds

Flavoring (a Lighter Brand)

Flavoring doesn't require a brand property — it uses a phantom type parameter:

type Flavor<T, F> = T & { __flavor?: F };

type USD = Flavor<number, "USD">;
type EUR = Flavor<number, "EUR">;

function createUSD(amount: number): USD {
  return amount as USD;
}

const price = createUSD(29.99);
// Number operations still work:
const total = (price * 1.1) as USD; // Cast needed after operations

Practical Branding Patterns

Type-Safe API Parameters

type ArticleId = Brand<string, "ArticleId">;
type CommentId = Brand<string, "CommentId">;

declare function getArticle(id: ArticleId): Promise<Article>;
declare function getComment(id: CommentId): Promise<Comment>;

// Cannot accidentally swap them
// getComment(articleId); // Error

Currency Safety

type USD = Brand<number, "USD">;
type EUR = Brand<number, "EUR">;

function usd(amount: number): USD { return amount as USD; }
function eur(amount: number): EUR { return amount as EUR; }

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const price = usd(29.99);
const tax = usd(3.00);
const total = addUSD(price, tax); // OK: USD + USD = USD
// addUSD(price, eur(10)); // Error: EUR is not USD

Unit Safety

type Meters = Brand<number, "Meters">;
type Feet = Brand<number, "Feet">;

function meters(value: number): Meters { return value as Meters; }
function feet(value: number): Feet { return value as Feet; }

const roomWidth = meters(10);
// roomWidth + 5 // OK: number operations work
// addMeters(roomWidth, feet(3)) // Error

Common Mistakes

1. Using as Instead of satisfies When You Want Validation

type Config = { debug: boolean; timeout: number };

// Bad: as disables validation for excess properties
const config = { debug: true, timeout: 5000, extra: "oops" } as Config;

// Good: satisfies catches the error
const config2 = { debug: true, timeout: 5000 } satisfies Config;

2. Forgetting That Brands Are Compile-Time Only

type UserId = Brand<string, "UserId">;
const uid: UserId = "abc" as UserId;

// At runtime, uid is just "abc" — any library receiving it sees a plain string
// Brand is not validated at runtime — it's purely for compile-time safety

3. Accidentally Creating a Non-Overlapping Brand

// This brand type is impossible — string & number cannot overlap
type Impossible = string & number & { __brand: "test" };
// Result: never

4. Using satisfies When You Need a Type Assertion

// satisfies checks the type but doesn't narrow
const data = JSON.parse(raw) satisfies User;
// data is still unknown! satisfies didn't narrow it.

// Use "as" when you need to narrow:
const data = JSON.parse(raw) as User;

5. Brand Pollution Through Operations

type USD = Brand<number, "USD">;
const money = 100 as USD;
const doubled = money * 2; // Type: number (brand lost!)
// Fix: const doubled = (money * 2) as USD;

6. Using Brands Unnecessarily

Not every value needs a brand. Use brands only when two structurally identical types must be distinguished for correctness (currency, units, IDs).

Practice Questions

  1. What does the satisfies operator do that as does not? satisfies validates the type without widening it, preserving literal types. as asserts a type without validation.

  2. Why do we need branded types in TypeScript? TypeScript has structural typing — two types with the same shape are considered the same. Brands add compile-time distinction to structurally identical types.

  3. How does a branded type work at runtime? The brand property is erased during compilation. At runtime, the value is just its underlying type (e.g., a plain string).

  4. What is the difference between branding and flavoring? Branding uses a required intersection with a unique symbol. Flavoring uses an optional phantom type parameter, making it slightly less strict.

Challenge: Create a branded type system for a recipe app with Teaspoon, Tablespoon, and Cup types. Write functions to convert between them with compile-time safety.

FAQ

Is `satisfies` available in all TypeScript versions?

No. satisfies was introduced in TypeScript 4.9 (November 2022).

Can I use branded types with classes?

Yes. Brand a class instance: type AdminUser = User & { __brand: "Admin" };.

Do branded types affect bundle size?

No. Brands are compile-time only and erased during compilation. They have zero runtime cost.

Can I brand a function's return type?

Yes. Return types can be branded: function createUser(): Brand<User, "ActiveUser">.

What is the `unique symbol` type used for in branding?

unique symbol ensures the brand property is truly unique across your codebase, preventing accidental brand collisions.

Mini Project: Type-Safe Measurement System

// src/measurements.ts

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { [brand]: B };

type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Miles = Brand<number, "Miles">;

function meters(value: number): Meters { return value as Meters; }
function kilometers(value: number): Kilometers { return value as Kilometers; }
function miles(value: number): Miles { return value as Miles; }

function kmToMiles(km: Kilometers): Miles {
  return (km * 0.621371) as Miles;
}

function kmToMeters(km: Kilometers): Meters {
  return (km * 1000) as Meters;
}

function addMeters(a: Meters, b: Meters): Meters {
  return (a + b) as Meters;
}

const distance = kilometers(5);
const inMiles = kmToMiles(distance);
const inMeters = kmToMeters(distance);

console.log(`5 km = ${inMiles} miles`);  // 5 km = 3.106855 miles
console.log(`5 km = ${inMeters} meters`); // 5 km = 5000 meters

const leg1 = meters(1000);
const leg2 = meters(2500);
const total = addMeters(leg1, leg2);
console.log(`Total walk: ${total} meters`); // 3500 meters

// Type errors (uncomment to test):
// kmToMiles(meters(100)); // Error: Meters is not Kilometers
// addMeters(leg1, distance); // Error: Kilometers is not Meters

What's Next

You've completed Module 2: Advanced Types! Now dive into Classes and OOP:

Lesson Description
{{< ref "/programming-languages/typescript/17-declaration-files" >}} Review declaration files
{{< ref "/programming-languages/typescript/19-classes" >}} Class syntax, implements, access modifiers
{{< ref "/programming-languages/typescript/20-abstract-classes" >}} Abstract classes and static members

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro