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:
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:
ButtonVariantis a union of string literal types.Useris an object type.LoadingStateis a discriminated union.- The
statusproperty 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:
type BadState = {
loading: boolean;
data?: User[];
error?: string;
};
This allows confusing states:
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:
type UsersState =
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: string };
Now TypeScript knows:
- If
statusis"loading", there is nodataorerror. - If
statusis"success",dataexists. - If
statusis"error",errorexists.
This is powerful in React because UI rendering often depends on state shape.
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
typeandinterface. - Use unions instead of overly broad optional props.
- Use intersections to combine shared props.
- Use
neverfor exhaustiveness checks. - Avoid over-engineering simple components.
A strong answer should be practical:
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:
let name: string = "Minh";
let age: number = 30;
let isActive: boolean = true;
let tags: string[] = ["react", "typescript"];
Object type example:
type User = {
id: string;
name: string;
email: string;
isActive: boolean;
};
Function type example:
type SaveUser = (user: User) => Promise<void>;
React prop type example:
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:
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.
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:
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.
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:
type User = {
id: string;
name: string;
};
Using interface:
interface User {
id: string;
name: string;
}
They are similar for simple object models, but they differ in some important ways.
Examples where type is required or clearer:
type Status = "loading" | "success" | "error";
type Id = string | number;
type UserWithRole = User & {
role: string;
};
Example where interface can be useful:
interface ButtonProps {
label: string;
onClick: () => void;
}
interface IconButtonProps extends ButtonProps {
icon: React.ReactNode;
}
Practical guidance:
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.
type Direction = "left" | "right" | "up" | "down";
let direction: Direction = "left";
This is different from string.
let anyString: string = "anything";
let fixed: "success" = "success";
fixed can only be "success".
Literal types are commonly used in React props:
type AlertProps = {
variant: "success" | "warning" | "error";
message: string;
};
function Alert({ variant, message }: AlertProps) {
return <div className={`alert alert-${variant}`}>{message}</div>;
}
Usage:
<Alert variant="success" message="Saved successfully" />
<Alert variant="error" message="Failed to save" />
Invalid:
<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:
type Id = string | number;
Example:
function formatId(id: string | number) {
return String(id);
}
Valid:
formatId("user-1");
formatId(123);
Invalid:
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:
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:
function printValue(value: string | number) {
console.log(value.toUpperCase());
}
This is invalid because number does not have toUpperCase.
You must narrow first:
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:
typeofinstanceofin- Equality checks
- Truthiness checks
- Discriminant property checks
- User-defined type guards
- Assertion functions
switchstatements
Example with in:
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:
type Theme = "light" | "dark";
They can also be object-shape unions:
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.
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:
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:
type DialogProps = {
title?: string;
message?: string;
error?: string;
loading?: boolean;
};
This allows invalid or unclear combinations:
const props: DialogProps = {
title: "User",
error: "Failed",
loading: true,
};
Is it loading or error?
Better with a union:
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:
type A = { id: string };
type B = { createdAt: string };
type AAndB = A & B;
AAndB must satisfy both A and B.
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:
type Entity = {
id: string;
};
type Timestamped = {
createdAt: string;
updatedAt: string;
};
type User = Entity &
Timestamped & {
name: string;
email: string;
};
A User must have:
idcreatedAtupdatedAtnameemail
React example:
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.
type Union = A | B;
type Intersection = A & B;
Conceptually:
Example:
type HasName = {
name: string;
};
type HasEmail = {
email: string;
};
type NameOrEmail = HasName | HasEmail;
type NameAndEmail = HasName & HasEmail;
Valid NameOrEmail:
const a: NameOrEmail = { name: "Minh" };
const b: NameOrEmail = { email: "[email protected]" };
const c: NameOrEmail = { name: "Minh", email: "[email protected]" };
Valid NameAndEmail:
const d: NameAndEmail = {
name: "Minh",
email: "[email protected]",
};
Invalid NameAndEmail:
const e: NameAndEmail = {
name: "Minh",
};
Important interview point:
Union widens possible shapes.
Intersection combines requirements.
Intersections with Conflicting Properties
Intersections can become confusing when the same property exists with incompatible types.
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.
const value: C = {
id: "1",
};
This is invalid.
This often happens accidentally when combining props.
Example:
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:
type ActionProps = LinkProps | ButtonProps;
Best practice:
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:
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.
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:
type UsersState = {
isLoading: boolean;
data?: User[];
error?: string;
};
Problems:
isLoadingcan betruewhiledataexists.erroranddatacan exist at the same time.- The component must defensively check many combinations.
- Invalid states are representable.
Better:
type UsersState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: string };
Component:
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.
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:
<Action as="button" onClick={() => console.log("clicked")}>
Save
</Action>
<Action as="link" href="/settings">
Settings
</Action>
Invalid:
<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.
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:
const action: CounterAction = {
type: "set",
};
TypeScript requires value for the "set" action.
This is much safer than:
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.
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(value)}`);
}
Usage:
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:
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:
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:
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:
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:
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value &&
"email" in value
);
}
Usage:
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:
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.
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.
let value: unknown = "hello";
if (typeof value === "string") {
console.log(value.toUpperCase());
}
Use unknown for untrusted data:
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:
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.
const input = document.getElementById("email") as HTMLInputElement;
Type assertions do not perform runtime checks. They are a compile-time instruction.
Dangerous example:
const user = responseData as User;
If responseData does not actually match User, TypeScript will not protect you at runtime.
Better for untrusted data:
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.
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:
<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.
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:
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:
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:
function Action(props: Props) {
if (props.kind === "link") {
return <a href={props.href}>Open</a>;
}
return <button onClick={props.onClick}>Open</button>;
}
Best practice:
When using discriminated unions, narrow on the whole object first.
Then access variant-specific properties.
You can destructure inside the narrowed branch:
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.
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:
<Button variant="icon" icon={<SaveIcon />} />
<Button variant="text" label="Save" />
Invalid:
<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.
type ButtonVariant = "primary" | "secondary" | "danger";
Usage:
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:
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:
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:
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:
<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:
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:
Example with union:
type Action =
| { type: "create"; name: string }
| { type: "update"; id: string; name: string }
| { type: "delete"; id: string };
type DeleteAction = Extract<Action, { type: "delete" }>;
DeleteAction becomes:
type DeleteAction = {
type: "delete";
id: string;
};
Example with Omit:
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:
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:
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
Usage:
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error("Invalid user response");
}
console.log(data.name);
Important interview point:
TypeScript helps at compile time, but it does not replace runtime validation for external data.
Common Mistakes
Common mistakes include:
- Using
anyinstead 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
asassertions 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.