Skip to main content
ASoc
Tutorial

Creating an Object From a TypeScript Interface: Literal, Spread, or Factory

Three call sites derive the same narrow WishlistEntry from a full catalog product, and none of them spread it. Here's why, plus the factory-function shape that spreading can't replace.

The ASoc Team8 min read

Creating an object that satisfies a TypeScript interface is usually a one-line object literal — declare the variable's type as the interface, supply matching properties, done. The part tutorials skip is what happens when the object isn't hand-authored but derived from another, larger typed value: picking three fields off a 20-field product record, four times, in four different files, with no compiler check that all four picks agree. This codebase has exactly that shape, twice, and the second example is a genuinely different problem the first one doesn't cover.

The short answer

To create an object matching an interface, either write an object literal typed as that interface (const x: Shape = { … }), spread an existing compatible object ({ ...source }), or write a function that returns the interface type and builds the object inside — a factory. Interfaces are erased at compile time, so there is never a runtime step; the only real decision is which of the three shapes to reach for, and that decision usually comes down to whether you're deriving a smaller object from a bigger one (write it out field by field) or duplicating a matching one (spread it).

Three shapes, and which one this codebase reaches for

ShapeWhat it looks likeBest forWhere this repo uses it
Literal, hand-typedconst p: Person = { name, age }Small, one-off valuesConfig-like constants throughout src/lib
Spread from a compatible sourceconst copy: Person = { ...existing }Cloning or lightly extending an object with the same shapeNot used here — see below
Field-by-field pick from a different, larger shapeconst entry: Small = { a: big.a, b: big.b }Deriving a narrow public interface from a large internal oneWishlistEntry, built from TemplateProduct, four separate times
Factory function returning the interface typefunction make(tier): Shape[] { switch (tier) { … } }The values depend on a runtime input, not just an existing objecttierSlots() in src/lib/entitlements.ts

Field-by-field pick: WishlistEntry from TemplateProduct

src/lib/wishlist.ts declares a narrow interface for what the wishlist is allowed to persist — three fields, deliberately fewer than the 20-odd on a full catalog product:

// src/lib/wishlist.ts
export interface WishlistEntry {
  slug: string;
  name: string;
  /** Thumbnail path under `public/` — always root-relative. */
  image: string;
}

{ ...product } would satisfy this structurally (TypeScript's structural typing doesn't care about extra properties on an assignment, only on a literal), but it would also serialize the product's price, editions, changelog and screenshots array into localStorage for something that is supposed to be a lightweight save-for-later list. So three call sites — TemplateCard, EditionPicker, and UseCaseCard's parent organism, UseCases — each build the narrower object explicitly rather than spreading:

// src/components/molecules/TemplateCard.tsx
const wishlistEntry: WishlistEntry = {
  slug: product.slug,
  name: product.name,
  image: product.screenshots[0],
};

src/components/organisms/UseCases.tsx has the more interesting variant, because its product is optional and its screenshot might not exist yet (a coming-soon product with no cover shot):

// src/components/organisms/UseCases.tsx
const wishlistEntry: WishlistEntry | null =
  product && product.screenshots[0]
    ? {
        slug: product.slug,
        name: product.name,
        image: product.screenshots[0],
      }
    : null;

The type annotation WishlistEntry | null is doing real work here: without it, an object literal that's missing image because the ternary's condition already guarantees it exists would still need TypeScript's narrowing to trust that guarantee. Annotating the target type up front, rather than letting inference guess it from the literal, is what makes a typo in one of the four call sites (prodcut.slug, say) a compile error instead of a wishlist.ts runtime surprise three components away.

Factory function: tierSlots()

The second shape shows up when the object being built isn't a subset of one you already have — it depends on a runtime value:

// src/lib/entitlements.ts
export function tierSlots(
  tier: "t1" | "t2" | "t3",
): { kind: SlotKind; count: number }[] {
  switch (tier) {
    case "t1":
      return [{ kind: "template_single", count: 1 }];
    case "t2":
      // All-Access: one auto-granted slot covering every template (no
      // per-product redemption — nothing to pick).
      return [{ kind: "all_templates", count: 1 }];
    case "t3":
      return [{ kind: "all_access", count: 1 }];
  }
}

There is no existing object to derive { kind, count } from — a tier is just a string literal, and the slots it grants are business logic, not data transformation. The function's return type annotation, { kind: SlotKind; count: number }[], is what catches a mistyped kind ("all_template" instead of "all_templates") at the return statement rather than at the first place something reads .kind and gets undefined.

What the return-type annotation actually buys you

It's worth being precise about what changes when tierSlots declares { kind: SlotKind; count: number }[] as its return type instead of letting TypeScript infer it from the switch's branches. Without the annotation, TypeScript would still infer a correct type from the three return statements — inference is not the problem inference solves here. What the explicit annotation adds is a check that runs at the declaration, not at every call site: if a future edit to the t2 branch returns { kind: "all_template", count: 1 } (singular, a typo of the real "all_templates" union member), the error surfaces on that line, inside the function, rather than downstream wherever slotCovers reads .kind and finds it doesn't match any case in its own switch. The further a mistake travels from its cause before the compiler catches it, the more of the codebase you have to hold in your head to find it — an explicit return type on a small, business-logic-heavy function like this one keeps that distance at zero.

Why not spread?

The spread shape ({ ...existingObject }) is conspicuously absent from both examples, and that's not an accident: spread is for copying an object into one of the same shape, or a strict superset of it. Both real cases here go the other direction — narrower (WishlistEntry from TemplateProduct) or synthesized from a primitive (tierSlots from a tier string) — and spread doesn't help with either. If your interface is narrower than your source object, TypeScript's structural typing will happily let a spread through (extra fields on an existing, already-typed value aren't an error), which is exactly how a wishlist entry could silently end up carrying a full product's price and changelog into localStorage if someone reached for { ...product, image: product.screenshots[0] } instead of naming the three fields.

Mistakes and how they show up

SymptomCauseFix
An object satisfies its interface type-check but carries extra runtime data{ ...source } used to narrow, which doesn't actually drop fieldsName the target fields explicitly instead of spreading
A field is undefined at runtime despite the interface saying it's requiredThe source value can legitimately be missing (e.g., an optional screenshot) and nothing guarded for itGuard first (product && product.screenshots[0] ? {...} : null) and type the result as `Shape
A typo in a returned object's key isn't caught until something reads itThe function or variable has no explicit return/target type, so TypeScript infers a wider type from the literalAnnotate the function's return type or the variable's type explicitly, not just the object literal
Several places construct "the same" object slightly differentlyNo shared factory function — each call site writes the literal by handEither accept the duplication (it's cheap when the interface has 3 fields, as here) or extract one function once a fourth call site appears
Object.assign() or spread used to "convert" an interface into a class instanceInterfaces don't instantiate anything — there's no runtime representation to assign intoIf you need instance methods, reach for a class or a factory function instead; the interface stays a compile-time-only shape

Frequently asked questions

Can I create an instance of a TypeScript interface with new? No — interfaces are erased before the code runs, so there's nothing to instantiate. new SomeInterface() is a compile error. If you need new, you need a class; typescript-interface-vs-class on this blog covers when this codebase reaches for one (once, in its whole tree).

Is spreading an object into an interface type-safe? It type-checks whenever the source structurally satisfies the target — but "type-checks" and "does what you meant" diverge the moment your target is narrower than your source. Spread doesn't drop fields; it copies all of them, and only the type annotation on the variable limits what TypeScript will let you read back off it, not what's actually stored on the object at runtime.

What's the difference between a factory function and a plain object literal for this? A literal is for a value you can write out immediately. A factory is for a value whose shape depends on an argument — tierSlots("t2") returns something different than tierSlots("t3"), decided by a switch the compiler checks for exhaustiveness because the parameter is a closed union.

Do I need Partial<Interface> if I'm building the object in multiple steps? Only if you're genuinely assembling it incrementally with some fields still missing at points in between. Both real examples here build the full object in one literal — Partial would just be typing around a problem (incomplete construction) that a single expression avoids entirely.

Templates in this post

ASoc Ignite, ASoc Iris and ASoc Keystone all use this exact WishlistEntry derivation on their product cards — save one, and the object written to localStorage is precisely the three fields shown above.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial8 min read

A Waitlist Landing Page Is One Server Action and a Honeypot

This storefront's real waitlist form: the Resend Audience API's deprecated field a copy-pasted snippet would miss, and firing one analytics event per successful subscribe, not per render.

Read more
Tutorial9 min read

Web Accessibility Tools: Which Ones Caught This Site's Real Defects

Accessibility 100 on all 8 pages, and two real defects no scanner flagged. Four defects sorted by which tool found them — and why two were structurally invisible.

Read more
Tutorial10 min read

Is Webflow Good for Ecommerce? What the SKU Cap Actually Costs

Webflow's ecommerce plans cap items and charge a transaction fee on top. This storefront's entire commerce stack — entitlements, checkout, webhook — is 843 lines with no cap at all.

Read more