Skip to main content

TS Ep 1: Fundamental TypeScript — Inference, Type Narrowing, and Discriminated Unions

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 1: This Article
TypeScript is not just JavaScript with types—it is a static type analysis system designed to catch runtime errors before your code ever runs.

In this first episode of our TotalTypeScript-inspired masterclass series, we build a rock-solid foundation in TypeScript Fundamentals. We explore primitive types, the inference engine, union and intersection types, type narrowing, discriminated unions, unknown vs any, and as const immutability.


1. Type Inference vs. Explicit Annotation
#

TypeScript’s compiler (tsc) features a powerful inference engine that automatically determines types based on variable assignment:

// Inferred as number
let count = 42;

// Inferred as string[]
const tags = ["typescript", "javascript", "webdev"];

// Explicit annotation (necessary for uninitialized variables or function parameters)
let userId: string;
userId = "user_98412";

Best Practice Rule
#

Avoid redundant type annotations when TypeScript can infer the type cleanly. Reserve explicit annotations for function arguments, return types of public APIs, or uninitialized variables.


2. Union (|) and Intersection (&) Types
#

Unions model values that can be one of several types. Intersections combine multiple object shapes into a single unified type:

// Union Type
type Status = "pending" | "approved" | "rejected";

// Intersection Type
type Timestamped = { createdAt: Date; updatedAt: Date };
type User = { id: string; name: string };

type DatabaseUser = User & Timestamped;

const currentUser: DatabaseUser = {
  id: "usr_101",
  name: "Rachmat Hidayat",
  createdAt: new Date(),
  updatedAt: new Date(),
};

3. The Narrowing Engine: Moving from Broad to Specific Types
#

Type Narrowing is the process of refining a broad type (like string | number or unknown) into a more specific type using control flow analysis.


flowchart TD
  BroadType["Broad Input Type: string | number | Date"] --> GuardCheck{"Type Guard Check"}
  GuardCheck -->|typeof val === 'string'| StringBranch["Narrowed: string"]
  GuardCheck -->|typeof val === 'number'| NumberBranch["Narrowed: number"]
  GuardCheck -->|val instanceof Date| DateBranch["Narrowed: Date"]

Built-in Narrowing Guards (typeof, instanceof, in)
#

function processInput(input: string | number | Date | { url: string }) {
  // 1. typeof Guard
  if (typeof input === "string") {
    return input.toUpperCase();
  }

  // 2. typeof Guard for numbers
  if (typeof input === "number") {
    return input.toFixed(2);
  }

  // 3. instanceof Guard for classes/constructors
  if (input instanceof Date) {
    return input.toISOString();
  }

  // 4. in Operator Guard for object property existence
  if ("url" in input) {
    return input.url;
  }
}

Custom Type Predicates (is Keyword)
#

When built-in guards are insufficient, define user-defined type guards using the arg is Type predicate syntax:

interface ApiResponse {
  data: unknown;
  status: number;
}

interface UserPayload {
  id: string;
  email: string;
}

// User-Defined Type Guard
function isUserPayload(payload: unknown): payload is UserPayload {
  return (
    typeof payload === "object" &&
    payload !== null &&
    "id" in payload &&
    "email" in payload &&
    typeof (payload as UserPayload).id === "string" &&
    typeof (payload as UserPayload).email === "string"
  );
}

function handleResponse(response: ApiResponse) {
  if (isUserPayload(response.data)) {
    // TypeScript automatically narrows response.data to UserPayload
    console.log(`User Email: ${response.data.email.toLowerCase()}`);
  }
}

4. Discriminated Unions & Exhaustive Type Checking
#

Discriminated Unions (also known as Tagged Unions or Algebraic Data Types) are the single most powerful pattern for modeling state in TypeScript. Every member of the union shares a common literal property (the discriminant).

type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; error: Error };

type AsyncState = LoadingState | SuccessState | ErrorState;

// Exhaustive Switch Checking with 'never'
function renderState(state: AsyncState): string {
  switch (state.status) {
    case "loading":
      return "Loading data...";
    case "success":
      return `Loaded ${state.data.length} items.`;
    case "error":
      return `Error: ${state.error.message}`;
    default: {
      // If a new status is added to AsyncState, compiler throws error here!
      const _exhaustiveCheck: never = state;
      return _exhaustiveCheck;
    }
  }
}

5. Unknown vs. Any vs. Never
#

  • any: Turns off type checking completely. Avoid at all costs in production.
  • unknown: Type-safe counterpart of any. Requires narrowing before performing operations.
  • never: Represents values that can never occur (e.g., functions that throw errors or unreachable code branches).
// unknown requires narrowing
function parseJSON(raw: string): unknown {
  return JSON.parse(raw);
}

const result = parseJSON('{"name":"Alice"}');

// Compiler error if accessed directly:
// console.log(result.name);

if (typeof result === "object" && result !== null && "name" in result) {
  console.log((result as { name: string }).name); // Safe!
}

6. Immutable Literals with as const
#

Applying as const converts objects, arrays, and primitive values into deeply readonly literal types:

// Without as const: inferred as string[]
const HTTP_METHODS_MUTABLE = ["GET", "POST", "PUT", "DELETE"];

// With as const: inferred as readonly ["GET", "POST", "PUT", "DELETE"]
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE"] as const;

// Extract union type from as const array: "GET" | "POST" | "PUT" | "DELETE"
type HttpMethod = (typeof HTTP_METHODS)[number];

function sendRequest(url: string, method: HttpMethod) {
  console.log(`Sending ${method} to ${url}`);
}

sendRequest("/api/users", "POST"); // Valid!

Key Takeaways
#

  1. Leverage Inference: Let TypeScript infer primitive types and array shapes automatically; annotate function boundaries and complex objects explicitly.
  2. Master Narrowing: Use typeof, instanceof, in, and custom is predicates to refine types safely without dangerous type assertions (as).
  3. Use Discriminated Unions: Model domain states using tagged literal properties (status: "success") paired with never for compile-time exhaustive checks.
  4. Enforce Immutability: Use as const assertions to lock literal array/object structures into immutable type definitions.
typescript - This article is part of a series.
Part 1: This Article