Skip to main content

TypeScript's Record Type

A Structured Approach to Lookup Objects

Published Markdown
Part of seriesFull Stack 🚀
TypeScript's Record Type

Introduction

A couple of weeks ago, I was working on redesigning our notifications UI. Notifications are a primary way applications communicate with end users—whether to reflect application state or to acknowledge a user’s actions. Because of that, their visual clarity and consistency matter more than we often realise.

During this redesign, I was introduced to a TypeScript utility type I didn’t know I needed at the time. This article explores what the Record type is, how it enables strongly typed lookup objects, and where it fits best in real-world applications.

What is a Record Type

Record is a built-in TypeScript utility type used to define object types with a fixed set of keys and a uniform value type. Its general form/syntax is:

Record<K, T>

Where:

  • K represents the allowed keys (typically a union of string or number literals)

  • T represents the type of each corresponding value

A Record expresses the idea that for every key in K, there must exist a value of type T.

Unlike index signatures (e.g. { [key: string]: string }), Record is intentionally restrictive. It does not describe “any possible key,” but rather a fixed, known set of keys, making it ideal for predictable mappings.

What are lookup objects?

A lookup object is an object used to retrieve values by key rather than deriving those values through control flow. Rather than evaluating conditions at runtime, lookup objects encode decisions directly into data structures.

For example, UI states, status labels, colour mappings, and configuration values are often better represented as mappings than as executable logic. This approach improves readability and reduces branching complexity while making the relationship between keys and values explicit.

Record formalises this pattern by ensuring that the lookup object is both complete and type-safe. When used together, Record lookup objects provide a declarative alternative to conditional logic. By defining both the valid keys and the expected value type, the compiler can enforce correctness at compile time rather than allowing inconsistencies to surface at runtime.


Practical example: Styling a notification UI by severity (Next.js)

In my case, I was styling a notification component where the visual presentation changed based on severity. The design system was already defined, and the variables were known upfront:

  • The icon

  • The border colour

  • The title colour

Each of these needed to change depending on whether the notification was a success, error, warning, or info message. I found it ideal to utilise a Record-based lookup object.

Defining the notification types

First, define the allowed severity levels. This establishes a closed set of values that the UI can meaningfully respond to.

type ToastType = "success" | "error" | "warning" | "info";

Defining the style contract

Instead of mapping each severity to a single value, we define a structured style object that represents everything the UI needs. This ensures that every severity provides a complete and consistent styling configuration

type NotificationStyle = {
  icon: React.ReactNode;
  borderColour: string;
  titleColour: string;
};

Creating the lookup object with Record

Now we define the lookup object

const notificationStyles: Record<
  NotificationSeverity,
  NotificationStyle
> = {
  success: {
    icon: <CheckCircleIcon />,
    borderColor: "#16a34a",
    titleColor: "#15803d",
  },
  error: {
    icon: <XCircleIcon />,
    borderColor: "#dc2626",
    titleColor: "#b91c1c",
  },
  warning: {
    icon: <ExclamationTriangleIcon />,
    borderColor: "#d97706",
    titleColor: "#b45309",
  },
  info: {
    icon: <InformationCircleIcon />,
    borderColor: "#2563eb",
    titleColor: "#1d4ed8",
  },
};

At this point, TypeScript enforces several important constraints:

  • Every NotificationSeverity value must be present. ✅

  • No additional severities can be introduced accidentally. ✅

  • Each severity must define all required UI properties. ✅

  • The shape of the styling object is guaranteed across all cases. ✅

If a new severity is added later (for example, "neutral"), the compiler will immediately surface every place that requires an update.

Consuming the lookup in a Next.js component

With the lookup object in place, the component logic becomes trivial. There is no conditional rendering logic here. No if, no switch, no fallback cases. The component simply declares what it needs and retrieves it directly from the lookup.

type NotificationProps = {
  severity: NotificationSeverity;
  title: string;
  message: string;
};

export function Notification({
  severity,
  title,
  message,
}: NotificationProps) {
  const { icon, borderColor, titleColor } = notificationStyles[severity];

  return (
    <div
      style={{ borderColor }}
      className="border-l-4 rounded-md p-4"
    >
      <div className="flex items-start gap-3">
        {icon}
        <div>
          <h4 style={{ color: titleColor }}>{title}</h4>
          <p className="text-sm text-gray-600">{message}</p>
        </div>
      </div>
    </div>
  );
}

Pros

  • Type safety: Ensures thorough mappings and prevents undefined access

  • Improved readability: Replaces conditional logic with declarative data

  • Refactor-friendly: Adding or removing keys surfaces necessary changes immediately

  • Clear intent: The mapping between keys and values is explicit and centralised

Cons

Despite its benefits:

  • It is not well-suited for dynamic or user-defined keys

  • It can introduce unnecessary rigidity in loosely structured data

  • For very small or throwaway mappings, it may feel verbose

As with any abstraction, its value depends on context and intent.

Use Cases

Record is most effective when applied to:

  • UI state-to-message mappings

  • Status or error code lookups

  • Configuration objects with fixed options

  • Permission or role-based access mappings

  • Internationalization dictionaries

In each case, the domain is finite, and correctness depends on completeness.

Conclusion

The Record utility type is not a replacement for logic, but a tool for eliminating logic where it is unnecessary or predictable. By encoding known relationships directly into types, it reduces ambiguity and shifts error detection earlier in the development process. It represents a move toward more declarative, intentional code, code that communicates constraints clearly and fails early when those constraints are violated.

As I grow more and more technically, I come to appreciate TypeScript and the safety it provides in our codebases. I look forward to discovering more types and to documenting these patterns in my career.

About the author

  • Full Stack Software Engineer | Developer Advocate | Community Manager | Contant Creator | Technical Writer | Tech Blogger | Open Source Contributor

No comments yet. Be the first to comment.