DEV_NET_CORE
GET_STARTED
ReactTypeScript for React

Types, unions, intersections, and discriminated unions

Types, unions, intersections, and discriminated unions

Overview

TypeScript adds static typing to JavaScript. In React applications, TypeScript is commonly used to describe component props, state, API responses, form values, reducer actions, event handlers, context values, and reusable domain models.

This topic focuses on four important TypeScript concepts:

  • Types
  • Union types
  • Intersection types
  • Discriminated unions

These concepts are important because React applications often deal with values that can have different shapes depending on UI state, API state, user actions, component variants, permissions, or data-loading status.

Examples:

Code
type ButtonVariant = "primary" | "secondary" | "danger";

type User = {
  id: string;
  name: string;
  email: string;
};

type LoadingState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; error: string };

In this example:

  • ButtonVariant is a union of string literal types.
  • User is an object type.
  • LoadingState is a discriminated union.
  • The status property is the discriminant that allows TypeScript to narrow the possible shape.

These features help developers model valid states and prevent invalid combinations.

For example, without discriminated unions, a loading state may be written like this:

Code
type BadState = {
  loading: boolean;
  data?: User[];
  error?: string;
};

This allows confusing states:

Code
const state: BadState = {
  loading: true,
  data: [{ id: "1", name: "Minh", email: "[email protected]" }],
  error: "Failed to load users",
};

The state says loading, has data, and has an error at the same time. That may be invalid for the application.

A discriminated union makes invalid states harder to represent:

Code
type UsersState =
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; error: string };

Now TypeScript knows:

  • If status is "loading", there is no data or error.
  • If status is "success", data exists.
  • If status is "error", error exists.

This is powerful in React because UI rendering often depends on state shape.

Code
function UsersPanel({ state }: { state: UsersState }) {
  if (state.status === "loading") {
    return <p>Loading...</p>;
  }

  if (state.status === "error") {
    return <p>Error: {state.error}</p>;
  }

  return (
    <ul>
      {state.data.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

TypeScript narrows state based on the status check, so state.error and state.data are only accessible in the correct branches.

This topic matters for interviews because TypeScript is not only about adding simple annotations like string and number. A strong React developer should know how to model real application states, component variants, mutually exclusive props, API responses, and reducer actions safely.

Interviewers often ask about these concepts because they reveal whether a candidate can:

  • Design safe component props.
  • Avoid invalid UI states.
  • Use type narrowing.
  • Understand compile-time vs runtime behavior.
  • Choose between type and interface.
  • Use unions instead of overly broad optional props.
  • Use intersections to combine shared props.
  • Use never for exhaustiveness checks.
  • Avoid over-engineering simple components.

A strong answer should be practical:

Code
Use types and unions to describe what values are allowed.
Use intersections to combine requirements.
Use discriminated unions to model values that can be one of several known shapes.
Use narrowing to safely access properties.
Use exhaustive checks to catch missing cases.

Core Concepts

TypeScript Types

A TypeScript type describes the shape or allowed values of a variable, parameter, return value, object, function, component prop, or state.

Basic examples:

Code
let name: string = "Minh";
let age: number = 30;
let isActive: boolean = true;
let tags: string[] = ["react", "typescript"];

Object type example:

Code
type User = {
  id: string;
  name: string;
  email: string;
  isActive: boolean;
};

Function type example:

Code
type SaveUser = (user: User) => Promise<void>;

React prop type example:

Code
type UserCardProps = {
  user: User;
  onSelect: (userId: string) => void;
};

function UserCard({ user, onSelect }: UserCardProps) {
  return (
    <button onClick={() => onSelect(user.id)}>
      {user.name}
    </button>
  );
}

TypeScript types exist at compile time. They help the compiler and editor detect incorrect usage before runtime. Most TypeScript types are removed during compilation and do not exist in the emitted JavaScript.

Important point:

Code
TypeScript checks types at compile time.
JavaScript still runs at runtime.

If data comes from an API, user input, local storage, or a third-party script, TypeScript cannot automatically guarantee it is valid at runtime. Runtime validation may still be required.

Type Aliases

A type alias gives a name to a type.

Code
type ProductId = string;

type Product = {
  id: ProductId;
  name: string;
  price: number;
};

Type aliases can name:

  • Primitive types.
  • Object types.
  • Union types.
  • Intersection types.
  • Function types.
  • Tuple types.
  • Literal types.
  • Generic types.
  • Utility-type results.

Examples:

Code
type Status = "idle" | "loading" | "success" | "error";

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

type ClickHandler = (event: React.MouseEvent<HTMLButtonElement>) => void;

type ApiResponse<T> = {
  data: T;
  statusCode: number;
};

Type aliases are very common in React because component props and state models are often easier to read when named.

Code
type ButtonProps = {
  label: string;
  disabled?: boolean;
  onClick: () => void;
};

function Button({ label, disabled, onClick }: ButtonProps) {
  return (
    <button disabled={disabled} onClick={onClick}>
      {label}
    </button>
  );
}

Type Aliases vs Interfaces

Both type and interface can describe object shapes.

Using type:

Code
type User = {
  id: string;
  name: string;
};

Using interface:

Code
interface User {
  id: string;
  name: string;
}

They are similar for simple object models, but they differ in some important ways.

Featuretypeinterface
Object shapeYesYes
Union typeYesNo
Intersection typeYesThrough extends, but not all cases
Primitive aliasYesNo
Tuple aliasYesNo
Function aliasYesYes, but type is often clearer
Declaration mergingNoYes
Common React props usageVery commonVery common

Examples where type is required or clearer:

Code
type Status = "loading" | "success" | "error";

type Id = string | number;

type UserWithRole = User & {
  role: string;
};

Example where interface can be useful:

Code
interface ButtonProps {
  label: string;
  onClick: () => void;
}

interface IconButtonProps extends ButtonProps {
  icon: React.ReactNode;
}

Practical guidance:

Code
Use `type` when you need unions, intersections, utility types, or complex composition.
Use `interface` when you are defining extendable object shapes, especially public library APIs.
In application code, either can be acceptable if the team is consistent.

For this topic, type is especially important because unions and intersections are usually written as type aliases.

Literal Types

A literal type represents one exact value.

Code
type Direction = "left" | "right" | "up" | "down";

let direction: Direction = "left";

This is different from string.

Code
let anyString: string = "anything";

let fixed: "success" = "success";

fixed can only be "success".

Literal types are commonly used in React props:

Code
type AlertProps = {
  variant: "success" | "warning" | "error";
  message: string;
};

function Alert({ variant, message }: AlertProps) {
  return <div className={`alert alert-${variant}`}>{message}</div>;
}

Usage:

Code
<Alert variant="success" message="Saved successfully" />
<Alert variant="error" message="Failed to save" />

Invalid:

Code
<Alert variant="blue" message="Invalid variant" />

The compiler catches the invalid variant.

Literal types are also the foundation of discriminated unions.

Union Types

A union type means a value can be one of several possible types.

Syntax:

Code
type Id = string | number;

Example:

Code
function formatId(id: string | number) {
  return String(id);
}

Valid:

Code
formatId("user-1");
formatId(123);

Invalid:

Code
formatId(true);

Union types are useful for:

  • Component variants.
  • Status values.
  • API result states.
  • Nullable values.
  • IDs that can be string or number.
  • Event payloads.
  • Reducer actions.
  • Form field types.
  • Feature flags.
  • Permission states.
  • Return values that can fail.

React example:

Code
type BadgeProps = {
  status: "active" | "inactive" | "pending";
};

function Badge({ status }: BadgeProps) {
  return <span>{status}</span>;
}

Union types make invalid values impossible at compile time.

Working Safely with Union Types

When you have a union, TypeScript only lets you access members that are safe for all possible members.

Example:

Code
function printValue(value: string | number) {
  console.log(value.toUpperCase());
}

This is invalid because number does not have toUpperCase.

You must narrow first:

Code
function printValue(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase());
    return;
  }

  console.log(value.toFixed(2));
}

TypeScript uses control flow analysis to narrow the type.

Common narrowing tools:

  • typeof
  • instanceof
  • in
  • Equality checks
  • Truthiness checks
  • Discriminant property checks
  • User-defined type guards
  • Assertion functions
  • switch statements

Example with in:

Code
type User = {
  id: string;
  name: string;
};

type Admin = {
  id: string;
  name: string;
  permissions: string[];
};

function renderPerson(person: User | Admin) {
  if ("permissions" in person) {
    return person.permissions.join(", ");
  }

  return person.name;
}

TypeScript knows that permissions exists only in the Admin branch.

Union of Values vs Union of Object Shapes

Union types can be simple value unions:

Code
type Theme = "light" | "dark";

They can also be object-shape unions:

Code
type TextField = {
  type: "text";
  value: string;
};

type CheckboxField = {
  type: "checkbox";
  checked: boolean;
};

type FormField = TextField | CheckboxField;

This is common in React because UI components often render different views based on object shape.

Code
function FieldRenderer({ field }: { field: FormField }) {
  switch (field.type) {
    case "text":
      return <input value={field.value} readOnly />;

    case "checkbox":
      return <input type="checkbox" checked={field.checked} readOnly />;
  }
}

The union makes invalid field shapes impossible:

Code
const invalidField: FormField = {
  type: "checkbox",
  value: "wrong",
};

A checkbox field must have checked, not value.

Optional Properties vs Union Types

Optional properties are useful, but they can create invalid combinations when a value has multiple possible states.

Weak model:

Code
type DialogProps = {
  title?: string;
  message?: string;
  error?: string;
  loading?: boolean;
};

This allows invalid or unclear combinations:

Code
const props: DialogProps = {
  title: "User",
  error: "Failed",
  loading: true,
};

Is it loading or error?

Better with a union:

Code
type DialogProps =
  | { state: "loading"; message?: string }
  | { state: "error"; error: string }
  | { state: "success"; title: string; message: string };

Now each state has only the properties that make sense.

Use optional properties when:

  • A property is truly optional in the same shape.
  • The object is still valid with or without the property.
  • The property does not determine a different mode.

Use union types when:

  • Different modes require different properties.
  • Some properties are mutually exclusive.
  • The object can be one of several known shapes.
  • You want TypeScript to narrow based on mode.

Intersection Types

An intersection type combines multiple types into one type.

Syntax:

Code
type A = { id: string };
type B = { createdAt: string };

type AAndB = A & B;

AAndB must satisfy both A and B.

Code
const item: AAndB = {
  id: "1",
  createdAt: "2026-05-23T10:00:00Z",
};

Intersection types are useful for combining shared props, base models, metadata, and feature-specific fields.

Example:

Code
type Entity = {
  id: string;
};

type Timestamped = {
  createdAt: string;
  updatedAt: string;
};

type User = Entity &
  Timestamped & {
    name: string;
    email: string;
  };

A User must have:

  • id
  • createdAt
  • updatedAt
  • name
  • email

React example:

Code
type BaseButtonProps = {
  disabled?: boolean;
  children: React.ReactNode;
};

type TrackingProps = {
  trackingId: string;
};

type ButtonProps = BaseButtonProps &
  TrackingProps & {
    onClick: () => void;
  };

function Button({ disabled, children, trackingId, onClick }: ButtonProps) {
  return (
    <button data-tracking-id={trackingId} disabled={disabled} onClick={onClick}>
      {children}
    </button>
  );
}

Intersection means "all of these requirements at the same time."

Intersection vs Union

Union and intersection are often confused.

Code
type Union = A | B;
type Intersection = A & B;

Conceptually:

TypeMeaning
`AB`
A & BValue must satisfy A and B

Example:

Code
type HasName = {
  name: string;
};

type HasEmail = {
  email: string;
};

type NameOrEmail = HasName | HasEmail;
type NameAndEmail = HasName & HasEmail;

Valid NameOrEmail:

Code
const a: NameOrEmail = { name: "Minh" };
const b: NameOrEmail = { email: "[email protected]" };
const c: NameOrEmail = { name: "Minh", email: "[email protected]" };

Valid NameAndEmail:

Code
const d: NameAndEmail = {
  name: "Minh",
  email: "[email protected]",
};

Invalid NameAndEmail:

Code
const e: NameAndEmail = {
  name: "Minh",
};

Important interview point:

Code
Union widens possible shapes.
Intersection combines requirements.

Intersections with Conflicting Properties

Intersections can become confusing when the same property exists with incompatible types.

Code
type A = {
  id: string;
};

type B = {
  id: number;
};

type C = A & B;

C["id"] becomes never because a value cannot be both string and number at the same time.

Code
const value: C = {
  id: "1",
};

This is invalid.

This often happens accidentally when combining props.

Example:

Code
type LinkProps = {
  href: string;
  onClick?: never;
};

type ButtonProps = {
  onClick: () => void;
  href?: never;
};

type BadProps = LinkProps & ButtonProps;

This requires both link and button rules at the same time, which is impossible.

Use union when the component can be one mode or another:

Code
type ActionProps = LinkProps | ButtonProps;

Best practice:

Code
Use intersection to combine compatible requirements.
Use union to model alternatives.

Discriminated Unions

A discriminated union is a union of object types where each member has a common property with a different literal value.

The common property is called the discriminant, tag, or kind.

Example:

Code
type LoadingState = {
  status: "loading";
};

type SuccessState = {
  status: "success";
  data: string[];
};

type ErrorState = {
  status: "error";
  error: string;
};

type AsyncState = LoadingState | SuccessState | ErrorState;

status is the discriminant.

TypeScript can narrow the union based on status.

Code
function renderState(state: AsyncState) {
  switch (state.status) {
    case "loading":
      return "Loading...";

    case "success":
      return state.data.join(", ");

    case "error":
      return state.error;
  }
}

Inside each case, TypeScript knows the exact type.

Discriminated unions are useful for:

  • API loading states.
  • Reducer actions.
  • Component variants.
  • Form field variants.
  • Modal states.
  • Error states.
  • State machines.
  • Workflow status.
  • Notification types.
  • Message/event payloads.

They are especially helpful in React because UI often has finite states.

Discriminated Unions for React Loading State

Weak state model:

Code
type UsersState = {
  isLoading: boolean;
  data?: User[];
  error?: string;
};

Problems:

  • isLoading can be true while data exists.
  • error and data can exist at the same time.
  • The component must defensively check many combinations.
  • Invalid states are representable.

Better:

Code
type UsersState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; error: string };

Component:

Code
function UsersList({ state }: { state: UsersState }) {
  switch (state.status) {
    case "idle":
      return <p>Click search to load users.</p>;

    case "loading":
      return <p>Loading users...</p>;

    case "error":
      return <p>Error: {state.error}</p>;

    case "success":
      return (
        <ul>
          {state.data.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      );
  }
}

Benefits:

  • Invalid UI states are prevented.
  • Rendering logic is clearer.
  • TypeScript narrows properties safely.
  • Adding a new state can be caught with exhaustive checks.

Discriminated Unions for Component Props

Discriminated unions are powerful for React component props that have variants.

Example: a button that can render as a normal button or a link.

Code
type BaseActionProps = {
  children: React.ReactNode;
  className?: string;
};

type ButtonActionProps = BaseActionProps & {
  as: "button";
  onClick: () => void;
  href?: never;
};

type LinkActionProps = BaseActionProps & {
  as: "link";
  href: string;
  onClick?: never;
};

type ActionProps = ButtonActionProps | LinkActionProps;

function Action(props: ActionProps) {
  if (props.as === "link") {
    return (
      <a href={props.href} className={props.className}>
        {props.children}
      </a>
    );
  }

  return (
    <button onClick={props.onClick} className={props.className}>
      {props.children}
    </button>
  );
}

Usage:

Code
<Action as="button" onClick={() => console.log("clicked")}>
  Save
</Action>

<Action as="link" href="/settings">
  Settings
</Action>

Invalid:

Code
<Action as="link" onClick={() => console.log("wrong")}>
  Wrong
</Action>

A link requires href, not onClick.

The never properties help prevent passing props from the wrong variant.

Discriminated Unions for Reducer Actions

Reducers often benefit from discriminated unions.

Code
type CounterAction =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "set"; value: number }
  | { type: "reset" };

function counterReducer(state: number, action: CounterAction) {
  switch (action.type) {
    case "increment":
      return state + 1;

    case "decrement":
      return state - 1;

    case "set":
      return action.value;

    case "reset":
      return 0;
  }
}

Invalid action:

Code
const action: CounterAction = {
  type: "set",
};

TypeScript requires value for the "set" action.

This is much safer than:

Code
type BadAction = {
  type: string;
  value?: number;
};

With BadAction, TypeScript cannot know which actions require value.

Exhaustive Checking with never

Exhaustive checking ensures all union members are handled.

Code
function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}

Usage:

Code
function renderState(state: UsersState) {
  switch (state.status) {
    case "idle":
      return "Idle";

    case "loading":
      return "Loading";

    case "success":
      return state.data.length;

    case "error":
      return state.error;

    default:
      return assertNever(state);
  }
}

If a new union member is added:

Code
type UsersState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; error: string }
  | { status: "refreshing"; data: User[] };

TypeScript will complain because state in the default branch is no longer never.

This catches missing cases at compile time.

In React, exhaustive checks are useful for:

  • Component variants.
  • Reducer actions.
  • API states.
  • Form field types.
  • Workflow states.
  • Notification types.

Type Narrowing

Type narrowing is how TypeScript reduces a broad type to a more specific type based on code checks.

Example:

Code
function getLength(value: string | string[]) {
  if (typeof value === "string") {
    return value.length;
  }

  return value.length;
}

Both branches have .length, but TypeScript knows the first is string and the second is string[].

Narrowing with discriminant:

Code
type ApiResult =
  | { ok: true; data: User[] }
  | { ok: false; error: string };

function handleResult(result: ApiResult) {
  if (result.ok) {
    return result.data;
  }

  return result.error;
}

TypeScript understands that ok: true means the result has data.

Narrowing with in:

Code
type Cat = {
  meow: () => void;
};

type Dog = {
  bark: () => void;
};

function speak(animal: Cat | Dog) {
  if ("meow" in animal) {
    animal.meow();
    return;
  }

  animal.bark();
}

Narrowing is central to safe union handling.

User-Defined Type Guards

A user-defined type guard is a function that tells TypeScript how to narrow a value.

Syntax:

Code
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value
  );
}

Usage:

Code
function printUser(value: unknown) {
  if (isUser(value)) {
    console.log(value.email);
  }
}

Type guards are useful when:

  • Data comes from APIs.
  • Data comes from local storage.
  • You need runtime validation.
  • You work with unknown.
  • You need custom narrowing logic.

Important limitation:

Code
A type guard is only as correct as its implementation.

For serious API validation, teams often use runtime validation libraries or schema validation.

unknown, any, and Type Safety

any disables type checking for a value.

Code
let value: any = "hello";
value.not.a.real.method();

TypeScript does not complain, but runtime may fail.

unknown is safer because it forces narrowing before use.

Code
let value: unknown = "hello";

if (typeof value === "string") {
  console.log(value.toUpperCase());
}

Use unknown for untrusted data:

Code
async function loadData(): Promise<unknown> {
  const response = await fetch("/api/users");
  return response.json();
}

Then validate or narrow it before treating it as a known type.

Best practice:

Code
Prefer `unknown` over `any` when the value is not yet validated.
Use `any` only when you intentionally need to escape the type system.

Type Assertions

A type assertion tells TypeScript to treat a value as a specific type.

Code
const input = document.getElementById("email") as HTMLInputElement;

Type assertions do not perform runtime checks. They are a compile-time instruction.

Dangerous example:

Code
const user = responseData as User;

If responseData does not actually match User, TypeScript will not protect you at runtime.

Better for untrusted data:

Code
const responseData: unknown = await response.json();

if (isUser(responseData)) {
  console.log(responseData.email);
}

Use assertions when:

  • You know more than TypeScript can infer.
  • You have already validated the value.
  • You are working with DOM APIs.
  • The assertion is local and safe.

Avoid assertions when:

  • You are hiding real type errors.
  • You are using them to bypass compiler checks.
  • The data comes from an untrusted source.
  • A type guard or better model would be safer.

React Props with Union Types

Union types are very useful for component props.

Example: mutually exclusive props.

Code
type ControlledInputProps = {
  value: string;
  onChange: (value: string) => void;
  defaultValue?: never;
};

type UncontrolledInputProps = {
  defaultValue?: string;
  value?: never;
  onChange?: never;
};

type TextInputProps = {
  label: string;
} & (ControlledInputProps | UncontrolledInputProps);

function TextInput(props: TextInputProps) {
  if ("value" in props) {
    return (
      <label>
        {props.label}
        <input
          value={props.value}
          onChange={(event) => props.onChange(event.target.value)}
        />
      </label>
    );
  }

  return (
    <label>
      {props.label}
      <input defaultValue={props.defaultValue} />
    </label>
  );
}

This prevents a component from being both controlled and uncontrolled.

Invalid:

Code
<TextInput
  label="Name"
  value="Minh"
  defaultValue="Default"
  onChange={() => {}}
/>

This helps catch common React prop mistakes at compile time.

React Component State with Discriminated Unions

Discriminated unions are excellent for component state.

Code
type SearchState =
  | { status: "idle" }
  | { status: "searching"; query: string }
  | { status: "success"; query: string; results: User[] }
  | { status: "empty"; query: string }
  | { status: "error"; query: string; error: string };

Rendering:

Code
function SearchResults({ state }: { state: SearchState }) {
  switch (state.status) {
    case "idle":
      return <p>Start typing to search.</p>;

    case "searching":
      return <p>Searching for {state.query}...</p>;

    case "success":
      return (
        <ul>
          {state.results.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      );

    case "empty":
      return <p>No results for {state.query}.</p>;

    case "error":
      return <p>Search failed: {state.error}</p>;
  }
}

Benefits:

  • State transitions are clearer.
  • Rendering branches are safer.
  • Invalid combinations are avoided.
  • The UI becomes easier to reason about.

Discriminated Unions and Destructuring in React

A common pitfall is destructuring discriminated union props too early.

Problem:

Code
type Props =
  | { kind: "link"; href: string }
  | { kind: "button"; onClick: () => void };

function Action({ kind, href, onClick }: Props) {
  if (kind === "link") {
    return <a href={href}>Open</a>;
  }

  return <button onClick={onClick}>Open</button>;
}

Depending on TypeScript version and configuration, destructuring can make narrowing harder, especially for properties that do not exist on every union member.

Safer:

Code
function Action(props: Props) {
  if (props.kind === "link") {
    return <a href={props.href}>Open</a>;
  }

  return <button onClick={props.onClick}>Open</button>;
}

Best practice:

Code
When using discriminated unions, narrow on the whole object first.
Then access variant-specific properties.

You can destructure inside the narrowed branch:

Code
function Action(props: Props) {
  if (props.kind === "link") {
    const { href } = props;
    return <a href={href}>Open</a>;
  }

  const { onClick } = props;
  return <button onClick={onClick}>Open</button>;
}

never for Mutually Exclusive Props

never can be used to forbid props in a specific union branch.

Code
type IconButtonProps = {
  variant: "icon";
  icon: React.ReactNode;
  label?: never;
};

type TextButtonProps = {
  variant: "text";
  label: string;
  icon?: never;
};

type ButtonProps = IconButtonProps | TextButtonProps;

This prevents invalid combinations:

Code
<Button variant="icon" icon={<SaveIcon />} />
<Button variant="text" label="Save" />

Invalid:

Code
<Button variant="icon" icon={<SaveIcon />} label="Save" />

label is not allowed in the icon branch.

Use this pattern when:

  • Props are mutually exclusive.
  • One prop requires another prop.
  • Component mode changes required props.
  • You want invalid combinations to fail at compile time.

Avoid overusing it for simple optional props where the added complexity is not worth it.

Unions Over Enums for React Props

For many React props, string literal unions are simpler than enums.

Code
type ButtonVariant = "primary" | "secondary" | "danger";

Usage:

Code
function Button({ variant }: { variant: ButtonVariant }) {
  return <button className={`btn-${variant}`}>Save</button>;
}

This is lightweight and compile-time only.

Enums can be useful in some cases, but they introduce runtime values unless using const enum, and const enum can have build-tool caveats.

Alternative with object constants:

Code
const ButtonVariant = {
  Primary: "primary",
  Secondary: "secondary",
  Danger: "danger",
} as const;

type ButtonVariant = (typeof ButtonVariant)[keyof typeof ButtonVariant];

This provides reusable constants and a union type.

Practical React guidance:

Code
Use string literal unions for simple prop variants.
Use object constants when you want named reusable values.
Use enums only when the team has a clear reason.

Intersections with React Native HTML Props

Intersections are often used to combine custom props with native element props.

Example:

Code
type PrimaryButtonProps = {
  variant?: "primary" | "secondary";
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

function PrimaryButton({
  variant = "primary",
  className,
  ...buttonProps
}: PrimaryButtonProps) {
  return (
    <button
      className={`btn btn-${variant} ${className ?? ""}`}
      {...buttonProps}
    />
  );
}

This allows native button props such as:

Code
<PrimaryButton
  type="submit"
  disabled
  onClick={() => console.log("clicked")}
>
  Save
</PrimaryButton>

However, be careful when custom props conflict with native props.

For more precise control, use Omit:

Code
type AppButtonProps = {
  variant: "primary" | "secondary";
  onPress: () => void;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick">;

function AppButton({ onPress, variant, ...props }: AppButtonProps) {
  return (
    <button {...props} className={`btn-${variant}`} onClick={onPress} />
  );
}

This prevents exposing both onPress and onClick if your component wants a custom API.

Utility Types with Unions and Intersections

TypeScript utility types are often used with unions and intersections.

Common utility types:

Utility TypePurpose
Partial<T>Makes properties optional
Required<T>Makes properties required
Pick<T, K>Selects specific properties
Omit<T, K>Removes specific properties
Record<K, T>Creates an object type with keys and values
Extract<T, U>Extracts union members assignable to another type
Exclude<T, U>Removes union members assignable to another type
NonNullable<T>Removes null and undefined
ReturnType<T>Gets function return type
Parameters<T>Gets function parameter tuple type

Example with union:

Code
type Action =
  | { type: "create"; name: string }
  | { type: "update"; id: string; name: string }
  | { type: "delete"; id: string };

type DeleteAction = Extract<Action, { type: "delete" }>;

DeleteAction becomes:

Code
type DeleteAction = {
  type: "delete";
  id: string;
};

Example with Omit:

Code
type User = {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
};

type UserDto = Omit<User, "passwordHash">;

Utility types are powerful, but overly complex type transformations can hurt readability.

Runtime Validation vs Static Types

TypeScript does not validate data at runtime.

Example:

Code
type User = {
  id: string;
  name: string;
};

const user = await response.json() as User;

This compiles, but it does not verify the API response is actually a User.

If API data is untrusted, use runtime validation.

Simple manual validation:

Code
function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value
  );
}

Usage:

Code
const data: unknown = await response.json();

if (!isUser(data)) {
  throw new Error("Invalid user response");
}

console.log(data.name);

Important interview point:

Code
TypeScript helps at compile time, but it does not replace runtime validation for external data.

Common Mistakes

Common mistakes include:

  • Using any instead of modeling the type.
  • Using optional properties for mutually exclusive states.
  • Forgetting to narrow a union before accessing variant-specific properties.
  • Using intersection when union was intended.
  • Using union when all properties are required.
  • Creating intersections with conflicting property types.
  • Destructuring discriminated union props before narrowing.
  • Not using a discriminant property for object unions.
  • Using as assertions to silence real errors.
  • Assuming API data is valid because it is asserted as a type.
  • Forgetting exhaustive checks when switching over union members.
  • Using enums for simple React variants when string literal unions are simpler.
  • Overusing complex type-level programming for simple components.
  • Creating types that are technically correct but unreadable.
  • Using Partial<T> too broadly and making required data appear optional.
  • Forgetting that TypeScript types are erased at runtime.
  • Not aligning frontend types with backend contracts.

Best Practices

Use type aliases for unions, intersections, and reusable object shapes.

Use string literal unions for simple status and variant values.

Use discriminated unions for state that can be one of several known shapes.

Use a clear discriminant property such as type, kind, status, or variant.

Narrow union values before accessing variant-specific properties.

Prefer unknown over any for untrusted values.

Use runtime validation for API responses and user input when correctness matters.

Use intersections to combine compatible shared props or model requirements.

Avoid intersections with conflicting property names.

Use never for exhaustive checks and mutually exclusive props when appropriate.

Avoid destructuring discriminated union props before narrowing.

Keep React component prop types readable.

Avoid over-engineering simple components with complex unions.

Use utility types carefully and name complex derived types.

Prefer invalid states being unrepresentable.

Interview Practice

Previoustsconfig basics, strict mode, and module settingsNext UpUtility types and conditional types