01 / The idea
At some point, “just make an object” gets a little less simple.
You’re setting up a build. Library builds need one output format, app builds need another. Production usually means minified output. Sometimes you deliberately turn that off because you’re trying to understand a problem.
None of that is especially complicated. The awkward part arrives when three different callers each have their own version of those decisions. A default changes. Two callers get updated. The third still works, just differently.
A factory is a function whose job is to create something. It gives those creation decisions a home, so a caller can describe what it needs without assembling every detail itself.
If you write components, you call one on every line of markup. <section> and <Profile /> are both requests to create something, and the first letter
tells the compiler which: an HTML element or your component. That is why component names start
with a capital letter.
That’s the part I want you to notice: who has to know the rules? If the answer is “everyone who needs one of these,” a factory might make the code easier to live with.
Why “Factory” can mean a few different thingsNames in the family
This lesson starts with factory functions: a broad, everyday creation technique. Factory Method is a more specific pattern where a creation method can be overridden by subclasses. Abstract Factory groups the creation of related products that need to fit together.
You don’t need to memorize the family tree before using a creation function. It is useful to know which conversation a name belongs to.
02 / See the shape
Start small. Then give it a job.
The basic form puts a target default in one place and accepts a minification choice. It’s small on purpose. Follow the return value, then switch to In the wild to see what happens when creation has more decisions to make.
Every language in the comparison solves the same problem. We keep the contract lined up, while letting each language say it in its own way.
One function, one small default. This is already a factory. Whether it deserves a name in your codebase is the more useful question.
export function createBasicConfig(minify = false) {
return { target: 'es2022', minify };
}
const basic = createBasicConfig(false); type BasicConfig struct {
Target string
Minify bool
}
func CreateBasicConfig(minify bool) BasicConfig {
return BasicConfig{Target: "es2022", Minify: minify}
}
var basic = CreateBasicConfig(false) Reading the TypeScript??, optional fields, Readonly
minify?: boolean lets the caller leave the choice out. ?? uses a fallback only for null or undefined, so false survives. That one distinction carries a lot of this example.
Readonly describes the result to the type checker. Object.freeze also prevents changes to its own properties at runtime.
The preset and mode arrive as strings. We check them before returning a narrower, known set of values. This still assumes callers satisfy the other declared input types. An arbitrary JSON payload needs validation at its boundary.
Reading the Go*bool, nil, & and error
A plain bool starts at false. On its own it cannot tell us
whether the caller chose false or supplied nothing. A *bool gives us three
cases: nil, a pointer to false, or a pointer to true.
&minify takes the address of the caller’s value. *overrides.Minify reads that value after we check the pointer is not nil. The
returned configuration holds the boolean itself, so it does not keep that pointer.
(BuildConfig, error) makes failure part of the function’s signature. A caller
checks the error before using the result. We use an options struct here; functional options
are another API design to compare once there is a reason for them. On failure, the zero-valued
struct returned beside the error is a placeholder, not a valid configuration.
Reading the PythonDataclasses, optional values, and a small validator
Python represents the optional override with None. The expression chooses
the default only when the field is None, so an explicit False survives just as it does in TypeScript and Go.
Frozen dataclasses give the returned configuration value-style fields and reject later assignment. This is shallow immutability: it is useful here because every field is a scalar, not a promise that nested objects could never change.
What counts as an empty directory?One shared validation rule
An empty string, whitespace-only input, or a byte-order mark on its own is rejected. Every implementation uses the same Unicode whitespace rule, with the byte-order mark included explicitly. A nonblank directory keeps its exact text; the factory does not normalize paths or check the filesystem.
03 / Follow the values
Before you change it, make a guess.
The build below starts in production mode. What should happen if you explicitly turn minification off? Try it. Then remove the override and watch the default take over again.
Watch where the returned value comes from, or step through at your own pace. In Try it, your inputs run the same function you just read. The diagram and table follow the returned fields back to the decisions that supplied them.
A default is an offer.
librarymode: productionminify
trueThe mode supplies this value.
Preset library; mode production; minify true, from the mode rule. Target es2022; format esm; output directory dist/library.
Let the mode supply it.
Production supplies true when the caller leaves minification out.
Reduced motion: choose a scene to see its completed state.
Read this scene
Production supplies true when the caller leaves minification out.
Preset library; mode production; minify true, from the mode rule. Target es2022; format esm; output directory dist/library.
Watch restarts when you return. Step through keeps your selected step. Try it starts with fresh inputs each time you open it.
04 / Try a decision
There’s a tiny bug hiding in a very reasonable-looking line.
Someone simplified the minification rule. It still passes a test where no override is supplied. The interesting test is the one where the caller disagrees with the default.
05 / Give it a real job
One build runner. Several ways to arrive.
Imagine a repository with a CLI command, a watch task, and a release task. All three need a configuration for the same build engine. The CLI reads flags, the watch task chooses development mode, and the release task asks for a production library build.
Each caller turns its inputs into options and calls resolveBuildConfig. The
runner receives the finished value. If the default output directory changes, we change it
where creation happens. If a release task needs readable output while investigating a bug,
it can still say so explicitly.
Our example stops at the configuration; a real build engine would receive that result and do
the work. Its presets would reflect your project’s needs, so esm, iife, and es2022 are this example’s choices rather than Vite’s API.
Knows the situation
CLI flags, watch mode, release intent.
Knows how to create it
Defaults, presets, overrides, validation.
Uses the result
Receives a configuration and runs the build.
Notice that the runner does not need a factory parameter just because factories are easy to replace in tests. If it only needs one finished configuration, pass that value. Pass a factory when the runner needs to decide when or how often to create another one. That decision is our first bridge to dependency injection.
Build UIs?Every component tag you write is a creation request, and one day you will write the factory yourself.
Where it already is in your components
JSX and Svelte markup turn every tag into a request to create something, and the compiler
decides what from one detail: the tag’s first letter. <section> asks for an HTML element. <Profile /> asks for the component called Profile. Both React and Svelte document this as the capitalization
rule. Underneath it is a factory choosing its product.
The rule bites when a variable holds the component. In React, const component = kinds[type] followed by <component /> compiles to jsx("component", …), a string, while <Component /> compiles to jsx(Component, …), the function
itself. Given the string, React asks the browser’s own factory, document.createElement, for a <component> element, gets an unknown
element back, and warns in development that the tag “is unrecognized in this browser.” Your
component never runs.
Svelte makes the same decision while compiling, and the difference is in what it catches.
Import a component under a lowercase name and the compiler warns that the tag “will be
treated as an HTML element unless it begins with a capital letter.” Keep it in a lowercase
variable instead and the compiler says nothing: the page renders an empty <component> element. The earliest clue in either framework is your linter reporting component as unused, because the tag never reads the variable. In a React .tsx file,
TypeScript also rejects the tag: “Property 'component' does not exist on type
'JSX.IntrinsicElements'.” In both frameworks a hyphenated tag such as <profile-card> takes a third path and becomes a custom element.
When you have to own it
Now the page comes from a CMS. Each block arrives as data: a type such as "hero" and whatever props the editor filled in. Something has to turn that string into a component,
fill in the defaults the editor left blank, and decide what an unknown type does. Written inline,
that switch lands in the page, the preview pane, and the email renderer, each a little different:
the drift from section 01, with components in place of build options.
Give it one home. The factory below maps each type to a component and its defaults. It
uses ??, so an editor’s explicit false for “show author” survives the default,
the same decision as section 04. It returns nothing for a type this build doesn’t know, so the
page can show that block as unsupported instead of guessing. And the component it hands back
still goes into a capitalized name before it reaches a tag.
// A CMS sends each page block as data: a type string and the props an editor filled in.
export type BlockData = { id: string; type: string; props: Record<string, unknown> };
export type BlockKind<C> = { component: C; defaults: Record<string, unknown> };
export type MadeBlock<C> = { Component: C; props: Record<string, unknown> };
// One home for what a block becomes: which component, which defaults, and what
// an unknown type does. Framework-free, so React and Svelte pages share it.
export function createBlockFactory<C>(kinds: Record<string, BlockKind<C>>) {
return function createBlock(block: BlockData): MadeBlock<C> | null {
// Own keys only, so a type called "toString" is unknown rather than inherited.
if (!Object.hasOwn(kinds, block.type)) return null;
const { component, defaults } = kinds[block.type];
const props: Record<string, unknown> = { ...defaults };
for (const [key, value] of Object.entries(block.props)) {
// null means the editor left it unset. false is a choice, and ?? keeps it.
props[key] = value ?? defaults[key];
}
// Capitalized, because this value is headed for a tag.
return { Component: component, props };
};
}
The same component held under a lowercase and a capitalized name. Only the capitalized tag creates it: React warns at runtime, the Svelte compiler stays silent for a variable, and a linter flags the unused name in both.
import { Hero, Quote } from './components/react';
const kinds = { hero: Hero, quote: Quote };
export function BlockView({ type, title }: { type: 'hero' | 'quote'; title: string }) {
// The same component, held under two names. JSX decides what a tag
// creates from its first letter and never looks at what the variable holds.
// Your linter already sees the mistake: it reports component as unused,
// because the lowercase tag below never reads it. Silenced only to show it.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const component = kinds[type];
const Component = kinds[type];
return (
<>
{/* Compiles to jsx("component", …): a string. React asks the browser for a
<component> element, Hero never runs, and development builds warn:
"The tag <component> is unrecognized in this browser. If you meant to
render a React component, start its name with an uppercase letter." */}
{/* TypeScript stops it sooner, reporting "Property 'component' does not
exist on type 'JSX.IntrinsicElements'."
@ts-expect-error this sample keeps that error on purpose */}
<component title={title} />
{/* Compiles to jsx(Component, …): the function itself, so Hero renders. */}
<Component title={title} />
</>
);
}
06 / Recognize it elsewhere
The same idea wears a few different outfits.
Configuration is one useful example. These are other creation situations you’ll recognize. Treat them as ways to look at the idea, rather than four more names to memorize.
| What you’re creating | A familiar call | What creation owns |
|---|---|---|
| A valid value | parseAmount(input) | Acceptable input and the guarantees of the returned value. |
| A configured client | createApiClient(options) | Setup, defaults, and the collaborators it needs to work. |
| Independent state | createStore(initial) | A fresh instance and the operations that act on its state. |
| An implementation | createStorage(kind) | Choosing a concrete implementation behind a shared contract. |
A fixture helper belongs here too. makeUser({ email }) can give a test
a valid starting point. It earns its place when it makes the relevant difference easy to see;
it gets in the way when its hidden defaults are the reason a test passes.
07 / Already in your toolbox
You may have been using the idea before you had a name for it.
Here are three concrete APIs to look at. For each one, follow what the function creates and which details it takes care of.
React · createElement
Creates a React element object from a type, props, and children. The returned element is a description React can render; creation has its own rules, including special treatment of keys.
Open the API explanation ↗Svelte · writable
Creates a store with a small interface for reading and changing its value. The caller gets an object it can use without assembling its subscription machinery.
Look at the store contract ↗Go · http.NewRequest
Creates a request from a method, URL, and body. The API handles setup details and returns an error when creation fails. A request value is what the next part of the HTTP client needs.
Follow the standard-library API ↗A useful counterexample: Vite’s defineConfigA name can mislead
It looks like our configuration factory, but Vite v6’s defineConfig simply returns
its input. It exists as a type helper; the resolution work lives elsewhere. That is a useful
reminder to inspect the behavior before assigning a pattern to a familiar-looking name.
Read the pinned Vite v6.0.0 source. Our example is called resolveBuildConfig to keep that distinction visible.
08 / The parts to watch
Creation is only one moment in a value’s life.
A factory can gather important decisions. It cannot make every decision that follows disappear. These are the edges worth keeping in view.
Valid when created does not always mean valid forever
If callers can later mutate a value, they may break the rule the factory established. Decide whether that matters for this type. A factory does not automatically promise immutability.
Our TypeScript result is frozen, and every field is a primitive. Add an array of plugins and freezing only the outer object would leave that array mutable. Our Go result is a mutable copy with scalar fields. Add a map, slice, or pointer and some underlying data may be shared. Python's frozen dataclass rejects field assignment. Each visible implementation still follows its language’s ordinary mutability rules.
JavaScript’s shallow freeze and Go’s value representation explain the limits.
The factory starts collecting everyone’s special case
One new option can be useful. A growing collection of unrelated flags is a reason to pause. Are these callers still asking for the same thing? Sometimes a named preset helps; sometimes the responsibilities have separated enough to deserve different creation functions.
Judge the callers together. Counting options alone won’t tell you whether the API is clear.
Creation quietly starts doing I/O
Fetching credentials, opening a socket, or loading a file changes what a caller must account for: latency, failure, cancellation, and cleanup. Make that work visible in the name and contract. In TypeScript, asynchronous creation returns a promise; in Go, callers may need a context and an error path.
For a client used by a frontend, ask which values belong to the instance and which must be read per request. A token captured once at creation may be stale later. The factory’s lifetime is part of the design.
A factory was added only to make a test easier
Being able to substitute creation is useful when creation is a real dependency. If a function only uses a finished value, passing that value can be simpler. If it needs a collaborator, a small interface or function parameter may be enough.
The interesting test here is a behavior claim: an explicit false survives a production default, and every implementation runs that same case.
09 / Make the call
What would you have to change tomorrow?
This is how I’d decide whether to keep the factory. Give the code a plausible change and follow the work it creates.
| The change | Repeated inline construction | A shared factory |
|---|---|---|
| The shared output-directory default changes | Find and update each copy of that default. | Update the rule once. Existing overrides still win. |
| An empty directory must be rejected | Each creation path needs the check, or another shared boundary. | Add the check at the creation boundary. |
| One caller needs an unrelated configuration | That caller can describe its own value directly. | Adding a special option may complicate everyone’s API. |
Reach for it when creation has a responsibility worth naming. Shared defaults that must stay consistent, a meaningful invariant, choosing an implementation, or assembling a working object can each be a reason.
Keep the literal when the caller already has the complete, obvious value. A single clear construction site does not need a helper just to earn a pattern name. And keep a constructor when it already provides a clear API for the job.
The question I’d leave beside the code is: what does the caller get to stop knowing because this function exists? If you can answer that plainly, you can usually explain the design plainly too.
10 / Take the idea with you
Try explaining the decision without saying “factory.”
“I put the build defaults here so the release and watch tasks agree. Callers can still override the parts they own.” That says more about the design than the pattern name alone.
Before moving on, jot down what creation owns in this example, why false needed special
care, and one place in your own code where a creation function would be useful, or unnecessary.
Connections to follow nextRelated lessons
- Dependency injection asks who supplies the collaborators. A factory may assemble them, but the two ideas answer different questions.
- Copying, identity, and equality explains what remains shared after creation.
- Builder explores creation over several steps; Abstract factory explores creation of related families.
Practice the pattern
Three factory tasks from everyday code.
Choose from three common creation tasks: centralize build defaults, select a notification sender, or choose a storage adapter. Open the workspace and switch exercises from its toolbar; each one keeps its own TypeScript and Go draft, checks, hints, and worked solution.
Factory practice 8 min
Complete resolveBuildConfig
This is an experiment with ticket-style exercises, giving beginners a feel for how tasks may be described in the workplace. Leave feedback
This practice workspace is open to everyone. A free account syncs your lesson progress across devices.