TypeScript · 15 topics

TypeScript, from any to conditional types

Fifteen topics covering what interviewers actually ask about TypeScript, including why a typed API response guarantees nothing at runtime.

Published
Reading time
26 min read
Topics covered
15 topics

Sixteen topics, ten of them core. Every product company now expects TypeScript, and the questions go well past "what is an interface". They want to know whether you understand what the type system is actually doing.

The good news: you already write it. This note fills the gaps between using TypeScript and being able to explain it.

Why TypeScript, and structural typing

In simple words: TypeScript checks your code before it runs and then disappears. There is no TypeScript at runtime — it all compiles to plain JavaScript with the types stripped out.

The consequence people forget: types cannot validate data from outside your program. An API response typed as User is a promise you made to the compiler, not a check. If the server sends something else, nothing complains until it crashes.

// This is a lie the compiler happily believes
const user: User = await fetch("/api/user").then((r) => r.json());

For anything crossing a boundary — an API, a form, a config file, a database row — validate at runtime with Zod or similar, and derive the type from the validator so the two can never drift apart.

Structural typing

In simple words: TypeScript compares the shape of types, not their names. If it has the right properties, it fits.

type Point = { x: number; y: number };
type Coordinate = { x: number; y: number };
 
const p: Point = { x: 1, y: 2 };
const c: Coordinate = p;          // fine -- identical shape, different name
 
// A shape with extra properties is still assignable
const labelled = { x: 1, y: 2, label: "origin" };
const q: Point = labelled;        // fine

Java and C# use nominal typing, where the name must match. TypeScript is structural because JavaScript is structural — objects are just bags of properties.

When you genuinely need nominal typing — so a UserId cannot be passed where an OrderId is expected — use a branded type:

type UserId = string & { readonly __brand: "UserId" };
 
const toUserId = (id: string) => id as UserId;
 
function getUser(id: UserId) { }
getUser("123");            // Error
getUser(toUserId("123"));  // fine

any, unknown, never and void

In simple words: four types people mix up, and the difference between any and unknown is asked constantly.

TypeMeansCan you use it directly?
anyturn checking offyes — and that is the problem
unknowncould be anything, prove it firstno, you must narrow
neverthis can never happennothing is assignable to it
voidthe return value is not meaningfulyou may return undefined
let a: any = getData();
a.foo.bar.baz();          // compiles. Crashes at runtime. No warning.
 
let u: unknown = getData();
u.foo;                    // Error: 'u' is of type 'unknown'
 
if (typeof u === "object" && u !== null && "foo" in u) {
  u.foo;                  // now allowed -- you proved it
}

unknown is the safe any. Use it for anything you have not validated: JSON.parse results, request bodies, caught errors. It forces you to check before use, which is exactly what you should be doing anyway.

try {
  risky();
} catch (error) {         // typed `unknown` under modern settings
  if (error instanceof Error) console.error(error.message);
  else console.error(String(error));
}

never shows up in three places: a function that never returns, an impossible type like string & number, and — most usefully — exhaustiveness checking.

Interfaces and type aliases

In simple words: for describing an object they are almost interchangeable. The differences are real but narrow.

interfacetype
Object shapesyesyes
Unionsnoyes
Primitives, tuples, mapped, conditionalnoyes
Declaration mergingyesno
extendsyesvia & intersection
Error messagesoften clearercan expand into a wall of text
// Only `type` can do these
type Status = "idle" | "loading" | "done";
type Pair = [string, number];
type Keys = keyof User;
 
// Only `interface` can do this -- reopening to add members
interface Window { myAnalytics: Analytics; }   // augments the global Window

Declaration merging is the one genuinely unique interface feature, and it is how you extend types you do not own — Window, Express.Request, a library's options object.

The practical rule most teams settle on: interface for public object shapes that may be extended, type for everything else. Being consistent matters more than which you pick.

Unions, intersections and discriminated unions

In simple words: a union is "one of these". An intersection is "all of these at once". A discriminated union is the pattern that makes unions actually usable.

type Status = "idle" | "loading" | "error";       // union of literals
type Admin = User & { permissions: string[] };    // intersection

A discriminated union gives every member a shared literal field, so TypeScript can tell them apart:

type Result =
  | { status: "loading" }
  | { status: "success"; data: User[] }
  | { status: "error"; message: string };
 
function render(result: Result) {
  switch (result.status) {
    case "loading":
      return spinner();
    case "success":
      return list(result.data);       // `data` is known to exist here
    case "error":
      return alert(result.message);   // and `message` only here
  }
}

This single pattern removes a whole class of bug. Compare it to the version everyone writes first:

// The bad version -- every field optional, every combination "possible"
type Result = {
  loading?: boolean;
  data?: User[];
  error?: string;
};
// Now `loading: true, error: "x", data: [...]` typechecks. It should not.

Generics

In simple words: a type you fill in later. It lets a function work with many types while keeping the connection between what goes in and what comes out.

// Without generics you lose the type
function firstAny(items: any[]): any { return items[0]; }
const a = firstAny(["x"]);       // any -- no help
 
// With generics the relationship survives
function first<T>(items: T[]): T | undefined { return items[0]; }
const b = first(["x"]);          // string | undefined

Constraints

extends in a generic means "T must at least be this":

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}
 
longest("hello", "hi");        // works -- strings have length
longest([1, 2], [3]);          // works -- arrays have length
longest(1, 2);                 // Error -- numbers do not

The pattern you will use most, tying a key to its value type:

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}
 
const users = [{ name: "Subham", age: 26 }];
pluck(users, "name");   // string[]
pluck(users, "age");    // number[]
pluck(users, "email");  // Error -- "email" is not a key of the item type

Generics also take defaults, and can be used on interfaces and classes:

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
}
 
class Cache<K, V> {
  private store = new Map<K, V>();
  get(key: K): V | undefined { return this.store.get(key); }
}

Utility types

In simple words: built-in transformations over existing types. Learn these ten and you will rarely write a type by hand twice.

type User = { id: number; name: string; email: string; age?: number };
 
Partial<User>            // every property optional
Required<User>           // every property required
Readonly<User>           // every property readonly
Pick<User, "id" | "name">     // only those two
Omit<User, "email">           // everything except email
Record<string, User>          // { [key: string]: User }
 
Exclude<"a" | "b" | "c", "a">      // "b" | "c"
Extract<"a" | "b", "a" | "z">      // "a"
NonNullable<string | null>         // string
 
ReturnType<typeof getUser>         // whatever getUser returns
Parameters<typeof getUser>         // its parameters as a tuple
Awaited<Promise<User>>             // User -- unwraps nested promises too

Real combinations you will actually write:

// A form that starts empty and fills in
type UserForm = Partial<Omit<User, "id">>;
 
// A create payload -- no id yet, it comes from the database
type CreateUser = Omit<User, "id">;
 
// An update -- id required, everything else optional
type UpdateUser = Pick<User, "id"> & Partial<Omit<User, "id">>;
 
// The unwrapped type an async function resolves to
type LoadedUser = Awaited<ReturnType<typeof fetchUser>>;

Narrowing, type guards and predicates

In simple words: TypeScript watches your control flow. Once you have checked something, it remembers inside that branch.

function handle(value: string | number | null) {
  if (value === null) return;         // value: string | number below
  if (typeof value === "string") {
    value.toUpperCase();              // string
  } else {
    value.toFixed(2);                 // number
  }
}

Every way of narrowing:

typeof value === "string"        // primitives
value instanceof Date            // classes
"name" in value                  // property presence
Array.isArray(value)             // arrays
value.kind === "circle"          // discriminated union
value !== null                   // truthiness and equality

Custom type guards

When the check is too complex for TypeScript to follow, write a function that returns a type predicate:

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof (value as User).id === "number"
  );
}
 
const data: unknown = await response.json();
if (isUser(data)) {
  data.name;      // narrowed to User
}

value is User is the important part. Without it the function returns a plain boolean and TypeScript learns nothing.

Assertion functions

function assertDefined<T>(value: T | null | undefined, message: string): asserts value is T {
  if (value == null) throw new Error(message);
}
 
const user = users.find((u) => u.id === id);
assertDefined(user, "User not found");
user.name;      // narrowed for the rest of the scope, no if needed

Exhaustiveness with never

In simple words: make the compiler fail when someone adds a new case and forgets to handle it. One of the highest-value patterns in the language.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number };
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.size ** 2;
    default: {
      // If every case is handled, `shape` is `never` here and this compiles.
      // Add a new kind to Shape and this line becomes a compile error.
      const exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${exhaustive}`);
    }
  }
}

Add { kind: "triangle"; base: number; height: number } to Shape and the build breaks with a clear message, pointing straight at the switch. Without this, a triangle silently falls through to the default at runtime.

Typing React

// Props -- a plain type, and children when needed
type ButtonProps = {
  label: string;
  variant?: "primary" | "ghost";
  onClick: () => void;
  children?: React.ReactNode;
};
 
// Extending native element props -- very common and worth knowing
type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
  label: string;
  error?: string;
};
 
// Events
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => e.target.value;
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => e.preventDefault();
 
// State -- inferred when there is an initial value, explicit when there is not
const [count, setCount] = useState(0);              // number
const [user, setUser] = useState<User | null>(null); // must be explicit
 
// Refs
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<number | undefined>(undefined);
 
// A generic component
function List<T>({ items, render }: { items: T[]; render: (item: T) => React.ReactNode }) {
  return <ul>{items.map((item, i) => <li key={i}>{render(item)}</li>)}</ul>;
}
 
// A reducer with a discriminated union of actions
type Action =
  | { type: "increment"; by: number }
  | { type: "reset" };
 
function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment": return state + action.by;
    case "reset": return 0;
  }
}

Typing Express

The question that comes up most: how do you add req.user in a type-safe way?

// types/express.d.ts -- declaration merging into the Express namespace
import type { User } from "../models/user";
 
declare global {
  namespace Express {
    interface Request {
      user?: User;
    }
  }
}
export {};

Now req.user exists everywhere, typed, with no casting.

import type { Request, Response, NextFunction, RequestHandler } from "express";
 
// Generic parameters: params, response body, request body, query
const createUser = async (
  req: Request<{ orgId: string }, UserResponse, CreateUserBody>,
  res: Response<UserResponse>,
  next: NextFunction
) => {
  req.params.orgId;    // string
  req.body.email;      // typed from CreateUserBody
  res.json(result);    // must match UserResponse
};
 
// Typed error handler -- four parameters, error typed as unknown
const errorHandler = (err: unknown, _req: Request, res: Response, _next: NextFunction) => {
  const status = err instanceof HttpError ? err.status : 500;
  res.status(status).json({ message: "Something went wrong" });
};

keyof, typeof, indexed access and as const

In simple words: four small operators that let you derive types from things that already exist, instead of writing them twice.

type User = { id: number; name: string; address: { city: string } };
 
type Keys = keyof User;              // "id" | "name" | "address"
type Name = User["name"];            // string -- indexed access
type City = User["address"]["city"]; // string -- nested
 
const config = { retries: 3, url: "/api" };
type Config = typeof config;         // { retries: number; url: string }
 
// as const freezes literals into their narrowest types
const ROLES = ["admin", "editor", "viewer"] as const;
type Role = (typeof ROLES)[number];  // "admin" | "editor" | "viewer"

That last pattern is the one to remember: one array, used both as runtime data and as a union type, with no chance of them diverging.

// Without as const you would write the same list twice and hope they stay in sync
const ROLES = ["admin", "editor", "viewer"];   // string[]
type Role = "admin" | "editor" | "viewer";     // duplicated by hand

Enums versus const objects

In simple words: TypeScript enums are one of the few features that emit real JavaScript, and most teams now avoid them.

enum Status { Active = "active", Inactive = "inactive" }

The objections: numeric enums are not type-safe (any number is assignable), they generate extra runtime code, they do not tree shake well, they are nominal in a structural language, and const enum breaks with isolated modules.

The modern replacement:

const Status = {
  Active: "active",
  Inactive: "inactive",
} as const;
 
type Status = (typeof Status)[keyof typeof Status];   // "active" | "inactive"
 
// Or simply, when you do not need the runtime object:
type Status = "active" | "inactive";

tsconfig, the settings that matter

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "paths": { "@/*": ["./src/*"] }
  }
}
  • strict turns on the whole family, most importantly strictNullChecks. Without it, null is assignable to every type and TypeScript catches almost nothing worth catching.
  • noUncheckedIndexedAccess makes array[0] return T | undefined. Noisy, but it catches real bugs — index access genuinely can be undefined.
  • skipLibCheck skips checking .d.ts files in dependencies. Large speed win, essentially no downside.
  • paths gives you @/components/Button instead of ../../../components/Button.

If a codebase has strict: false, that is the first thing to fix. Turn it on, see the error count, and fix them file by file with a per-file // @ts-nocheck as a temporary bridge.

Conditional and mapped types

In simple words: types that compute other types. Senior territory — recognise them and know roughly what they do.

// Conditional -- a ternary for types
type IsString<T> = T extends string ? true : false;
 
// infer extracts a type from a position
type ElementOf<T> = T extends (infer U)[] ? U : never;
type A = ElementOf<string[]>;        // string
 
type Unwrap<T> = T extends Promise<infer U> ? U : T;
type B = Unwrap<Promise<number>>;    // number
// Mapped -- transform every key
type Optional<T> = { [K in keyof T]?: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };
type Mutable<T> = { -readonly [K in keyof T]: T[K] };   // minus removes a modifier
 
// Template literal types -- build strings at the type level
type EventName<T extends string> = `on${Capitalize<T>}`;
type Handlers<T> = { [K in keyof T & string as EventName<K>]: (value: T[K]) => void };
 
type UserHandlers = Handlers<{ name: string; age: number }>;
// { onName: (value: string) => void; onAge: (value: number) => void }

You will rarely write these. You will read them constantly in library types, and being able to say what infer does is a strong signal.

Declaration merging and .d.ts files

Already used above for Express.Request. The same technique covers anything you do not own:

// Extending the global Window
declare global {
  interface Window {
    dataLayer: unknown[];
  }
}
 
// Telling TypeScript about a non-code import
declare module "*.svg" {
  const content: React.FC<React.SVGProps<SVGSVGElement>>;
  export default content;
}
 
// Describing an untyped package
declare module "legacy-lib" {
  export function doThing(input: string): number;
}

export {} at the end of a .d.ts file matters: without at least one import or export, the file is treated as a script and its declare global block does nothing.

Ten questions

  1. 01
    What is the difference between any and unknown?
  2. 02
    What does structural typing mean, and how does it differ from Java or C#?
  3. 03
    When must you use a type rather than an interface, and vice versa?
  4. 04
    Model an API call that can be loading, successful or failed. Why not use optional fields?
  5. 05
    What does K extends keyof T achieve in a generic?
  6. 06
    How do you make the compiler fail when a new union member is added?
  7. 07
    What is a type predicate, and what is its limitation?
  8. 08
    How do you add a typed req.user to Express?
  9. 09
    Why do most teams avoid TypeScript enums now?
  10. 10
    Does typing an API response as User guarantee it is a User?

Seven exercises

  1. 01

    Replace every any in a real file

    Take a file from your own project and remove every any. Use unknown plus narrowing where the shape is genuinely unknown. Note which ones were hiding an actual bug.

  2. 02

    Model a fetch with a discriminated union

    Write the loading, success and error states as a union, then a render function using a switch. Add a fourth state and confirm the compiler tells you every place that needs updating.

  3. 03

    Write a typed get(object, path)

    Start with get<T, K extends keyof T>(obj: T, key: K): T[K]. Then attempt a two-level version where the second key is constrained to the keys of the first value.

  4. 04

    Rebuild the utility types

    Implement MyPartial, MyPick, MyOmit, MyReadonly and MyNonNullable from scratch using mapped and conditional types. Omit is the interesting one — work out why it needs Exclude.

  5. 05

    Add a typed req.user to an Express app

    Do it with declaration merging in a .d.ts file. Then write an auth middleware that sets it and a route that reads it, both with no casts anywhere.

  6. 06

    One list, two uses

    Define your roles once as a const array with as const, derive the union type from it, and use the array to render a dropdown and the type to constrain a function parameter. Add a role and confirm both update.

  7. 07

    Turn on strict

    Enable strict and noUncheckedIndexedAccess on a real project and count the errors. Fix ten of them properly. For each, write down whether it was a genuine bug or only noise.

Common questions

What is the difference between any and unknown in TypeScript?
any disables type checking for that value, so every operation is permitted and mistakes surface only at runtime. unknown accepts any value but permits nothing until you narrow it with a check. Use unknown for API responses, JSON.parse output and caught errors, because it forces the check you should be doing anyway.
What is structural typing in TypeScript?
TypeScript compares the shape of types rather than their names, so two differently named types with identical properties are interchangeable. Java and C# are nominal, requiring the declared name to match. TypeScript is structural because JavaScript objects are simply collections of properties. For nominal behaviour you add a brand property.
When must you use a type alias instead of an interface?
Only a type alias can express unions, tuples, primitives, mapped types and conditional types. Only an interface supports declaration merging, which is how you extend types you do not own such as Window or Express.Request. For plain object shapes either works, so consistency matters more than the choice.
How do you make TypeScript fail when a new union member is added?
Handle every case in a switch, then in the default branch assign the value to a variable typed never. If every case is covered the value is never and it compiles. Add a new union member and the assignment becomes a compile error pointing straight at the switch that needs updating.
Does typing an API response as User guarantee the data is a User?
No. Types are erased at compile time and there is no TypeScript at runtime, so annotating a fetch result is an assertion you made rather than a check the compiler performs. At any boundary - API, form, config or database - validate at runtime with a schema library such as Zod and infer the type from the schema, so the type and the check share one definition.