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.
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
| Shape | What it looks like | Best for | Where this repo uses it |
|---|---|---|---|
| Literal, hand-typed | const p: Person = { name, age } | Small, one-off values | Config-like constants throughout src/lib |
| Spread from a compatible source | const copy: Person = { ...existing } | Cloning or lightly extending an object with the same shape | Not used here — see below |
| Field-by-field pick from a different, larger shape | const entry: Small = { a: big.a, b: big.b } | Deriving a narrow public interface from a large internal one | WishlistEntry, built from TemplateProduct, four separate times |
| Factory function returning the interface type | function make(tier): Shape[] { switch (tier) { … } } | The values depend on a runtime input, not just an existing object | tierSlots() 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
| Symptom | Cause | Fix |
|---|---|---|
| An object satisfies its interface type-check but carries extra runtime data | { ...source } used to narrow, which doesn't actually drop fields | Name the target fields explicitly instead of spreading |
A field is undefined at runtime despite the interface saying it's required | The source value can legitimately be missing (e.g., an optional screenshot) and nothing guarded for it | Guard 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 it | The function or variable has no explicit return/target type, so TypeScript infers a wider type from the literal | Annotate the function's return type or the variable's type explicitly, not just the object literal |
| Several places construct "the same" object slightly differently | No shared factory function — each call site writes the literal by hand | Either 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 instance | Interfaces don't instantiate anything — there's no runtime representation to assign into | If 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.
