WebPiki
tutorial

12 TypeScript Tips for Writing Better Types

Practical TypeScript patterns: unions, generics, type guards, infer, Zod, and more. No more 'any' everywhere.

TypeScript type system pieces fitting together like a puzzle

If you are using TypeScript and reaching for any regularly, you are giving up most of what the type system offers. On the other hand, over-engineering types until the types are more complex than the code is not great either. Finding the right balance is the skill. Here are 12 patterns that come up constantly in real projects.

1. Lock Down Literals with as const

// ❌ Inferred as string[]
const ROLES = ["admin", "user", "guest"];

// ✅ Inferred as readonly ["admin", "user", "guest"]
const ROLES = ["admin", "user", "guest"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "user" | "guest"

Slap as const on constant arrays and objects to freeze them as literal types. No need to declare a separate union type — extract it from the array directly.

2. Be Specific Instead of Record

// ❌ Too wide — accepts any key
type Config = Record<string, unknown>;

// ✅ Reflects actual structure
type Config = {
  apiUrl: string;
  timeout: number;
  retries: number;
};

Record<string, any> lets every key through, so typos go unnoticed. Spell out actual keys whenever possible. Save Record for genuinely dynamic keys, and even then, make the value type specific.

3. Exhaustiveness Checking with never

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

function handleStatus(status: Status) {
  switch (status) {
    case "idle": return "Waiting";
    case "loading": return "Loading";
    case "success": return "Done";
    case "error": return "Failed";
    default: {
      const _exhaustive: never = status;
      return _exhaustive;
    }
  }
}

Add "retrying" to Status later and the switch statement throws a compile error for the unhandled case. This catches missing handlers every time you extend a union. Simple but incredibly effective.

4. Type Guard Functions

// ❌ Type assertion — no runtime check
const user = data as User;

// ✅ Type guard — runtime check + type narrowing
function isUser(data: unknown): data is User {
  return (
    typeof data === "object" &&
    data !== null &&
    "id" in data &&
    "name" in data
  );
}

if (isUser(data)) {
  // data is User here
  console.log(data.name);
}

as assertions skip runtime validation entirely. Type guard functions (using the is keyword) verify at runtime and narrow the type simultaneously. Especially valuable when processing API responses.

5. Discriminated Unions

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

function render(res: ApiResponse) {
  if (res.status === "success") {
    // res.data is accessible
  } else if (res.status === "error") {
    // res.message is accessible
  }
}

When a common field (status) distinguishes union members, TypeScript narrows the type automatically. Works like pattern matching for API responses, event handling, and state management.

6. Generics — Only When Needed

// ❌ Unnecessary generic
function getFirst<T>(arr: T[]): T {
  return arr[0];
}

// ✅ Generic that expresses input-output relationship
function groupBy<T, K extends string>(
  items: T[],
  keyFn: (item: T) => K
): Record<K, T[]> {
  // ...
}

Generics express relationships between inputs and outputs. If the goal is just "accept anything," a union or overload might be cleaner. Do not add generics reflexively.

7. Extract Types with infer

// Unwrap Promise inner type
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type A = Unwrap<Promise<string>>; // string
type B = Unwrap<number>;          // number

// Extract function return type (how ReturnType works internally)
type Return<T> = T extends (...args: never[]) => infer R ? R : never;

infer captures a type variable inside conditional types. Handy for extracting specific parts from library types without recreating them.

8. The satisfies Operator

Added in TypeScript 4.9, satisfies validates a type constraint while preserving the inferred type.

type ColorMap = Record<string, string | number[]>;

// satisfies validates but keeps the inferred types intact
const colors = {
  red: "#ff0000",
  green: [0, 255, 0],
} satisfies ColorMap;

colors.red.toUpperCase();    // ✅ Inferred as string
colors.green.map(v => v);    // ✅ Inferred as number[]

With as, the original type information is lost. satisfies gives you validation without that trade-off.

9. Readonly for Immutability

type User = {
  readonly id: string;
  name: string;
  settings: Readonly<{
    theme: "light" | "dark";
    language: string;
  }>;
};

Mark fields that should not change with readonly. This is not runtime enforcement — it catches accidental mutations at compile time. When accepting arrays as function parameters, readonly T[] prevents the function from mutating the original.

10. Template Literal Types

type EventName = "click" | "hover" | "focus";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onHover" | "onFocus"

type CSSUnit = `${number}${"px" | "rem" | "em" | "%"}`;
const width: CSSUnit = "100px"; // ✅

Express string patterns as types. Useful for API paths, CSS values, event names — anything with a predictable string format. Catches typos that plain string would miss.

11. Combining Utility Types

// Make specific fields required
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;

// No id when creating, id required when reading
type CreateUser = Omit<User, "id" | "createdAt">;
type UserResponse = RequireFields<User, "id" | "createdAt">;

Pick, Omit, Partial, Required — combine these built-in utility types to derive variations from a base type. Instead of declaring separate "create," "update," and "response" types for the same entity, derive them all from one base.

12. Unify Types and Runtime Validation (Zod)

import { z } from "zod";

const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().int().positive(),
});

// Extract type from schema
type User = z.infer<typeof UserSchema>;

// Runtime validation
const result = UserSchema.safeParse(apiResponse);
if (result.success) {
  // result.data is typed as User
}

Schema libraries like Zod unify type definitions and runtime validation into a single source of truth. No more type definitions drifting out of sync with validation logic. Particularly effective at API boundaries where external data enters your system.


The TypeScript type system goes arbitrarily deep if you want it to, but these 12 patterns cover the vast majority of what you encounter in day-to-day work. The core principle: make your types express the intent of your code. When types are right, you get documentation, error prevention, and refactoring safety as natural byproducts.

#TypeScript#Types#Frontend#JavaScript#Development

관련 글