Definition
A factory function creates and returns a value for its caller and owns the rules for making it, so the caller says what it needs rather than how to build it.
01Recognize the request
You already ask functions to create things.
A repository has a CLI that reads flags, a watch task that asks for development mode, and a release task that builds a production library. Each needs a configuration for the same build runner, and at first each one simply writes it out. The release task turns minify off on purpose: a readable production bundle is how the team debugs a production bug.
// The CLI, the watch task and the release task each write the whole configuration.
// CLI: the app, into the directory its --out flag names.
runBuild({ mode: 'development', target: 'es2022', format: 'iife', minify: false, outDir: 'build' });
// Watch: the library, in development.
runBuild({ mode: 'development', target: 'es2022', format: 'esm', minify: false, outDir: 'dist/library' });
// Release: the library in production, kept readable (minify: false) to debug production bugs.
runBuild({ mode: 'production', target: 'es2022', format: 'esm', minify: false, outDir: 'dist/library' }); That is clear while there are three of them. Repeating the defaults in each caller works until a changed directory or format leaves one of them behind, and every caller has to know every field, including the ones it does not care about.
A factory function creates something for its caller. It owns the presets, the defaults and the validation, so each caller says only what it needs.
You already make requests like this:
| A familiar request | What comes back | What creation owns |
|---|---|---|
| React · createElement(Profile, props) | An element description. | Type, props, children, and key. |
| Svelte · writable(0) | A subscribable, writable store. | Initial value and subscriptions. |
| new URL(path, base) | A parsed URL, or an error. | Resolving the path against the base, and rejecting a bad one. |
02Give creation one home
One build runner. Several ways to arrive.
The factory takes a preset and the caller’s overrides. It decides the format from the preset, fills in every default the caller left out, and keeps every choice the caller made. This is the whole program the lab runs: the factory and its three callers.
type Overrides = { mode?: string; minify?: boolean; outDir?: string };
type BuildConfig = { mode: string; target: string; format: string; minify: boolean; outDir: string };
// The factory: the preset's rules, the defaults and the caller's overrides, in one place.
function resolveBuildConfig(preset: string, overrides: Overrides = {}): BuildConfig {
const mode = overrides.mode ?? 'development';
return {
mode,
target: 'es2022',
format: preset === 'library' ? 'esm' : 'iife',
minify: overrides.minify ?? mode === 'production',
outDir: overrides.outDir ?? `dist/${preset}`,
};
}
// Three callers, each saying only what it knows.
const cli = resolveBuildConfig('app', { outDir: 'build' });
const watch = resolveBuildConfig('library');
const release = resolveBuildConfig('library', { mode: 'production', minify: false });
const show = (name: string, config: BuildConfig) =>
console.log(`${name}: ${config.mode} ${config.target} ${config.format} minify=${config.minify} ${config.outDir}`);
show('cli', cli);
show('watch', watch);
show('release', release); | Role | Responsibility | What it handles |
|---|---|---|
| Caller | Knows the situation | CLI flags, watch mode, release intent. |
| Factory | Knows how to create it | Presets, defaults and overrides (and, in production, validation). |
| Build runner | Uses the result | Receives a configuration and runs the build. |
What a production factory addsValidation and a result the caller owns
The same factory, checked. An unknown preset or mode is an error, and so is an output directory that is empty or only whitespace. The preset is checked first; then the mode and the output directory are each settled, the caller’s choice or the default, and checked. Every call returns a new result. In TypeScript it is also frozen, which matters once a result is shared, say by a cache: no holder can change it under the others. In Go and Rust each caller gets its own value.
type Overrides = { mode?: string; minify?: boolean; outDir?: string };
export type BuildConfig = Readonly<{
mode: 'development' | 'production';
target: string;
format: 'esm' | 'iife';
minify: boolean;
outDir: string;
}>;
export function resolveBuildConfig(preset: string, overrides: Overrides = {}): BuildConfig {
if (preset !== 'library' && preset !== 'app') throw new Error(`unknown preset: ${preset}`);
const mode = overrides.mode ?? 'development';
if (mode !== 'development' && mode !== 'production') throw new Error(`unknown mode: ${mode}`);
const outDir = overrides.outDir ?? `dist/${preset}`;
// Blank means only whitespace (Unicode's, plus a byte-order mark), the same rule in every language.
if (/^[\p{White_Space}\uFEFF]*$/u.test(outDir)) throw new Error('output directory must not be empty');
return Object.freeze({
mode,
target: 'es2022',
format: preset === 'library' ? 'esm' : 'iife',
minify: overrides.minify ?? mode === 'production',
outDir
});
} The factory checks the preset and mode strings before returning known values. Validate arbitrary JSON at its input boundary; a factory’s checks assume callers already sent the declared types.
03Predict what comes back
A default is an offer. Does the caller’s answer survive it?
Experiment
Three callers, one factory
// Lines 7–13 (the return statement) become a spread:
const defaults = {
mode,
target: 'es2022',
format: preset === 'library' ? 'esm' : 'iife',
minify: mode === 'production',
outDir: `dist/${preset}`,
};
return { ...defaults, ...overrides };
// And the CLI, run without its --out flag, passes outDir: undefined (line 17):
const cli = resolveBuildConfig('app', { outDir: undefined }); Reading the TypeScriptOptional fields, ??, spreads and freezing
minify?: boolean lets the caller omit the choice. ?? uses a fallback
only for null or undefined, so false survives; || would replace it, because false is falsy. A spread is the other
trap: it copies a key that is present but undefined, so merge field by field.
Readonly describes the result to the type checker. Object.freeze also
prevents changes to the returned object’s own fields at runtime.
04Meet it in your language
A factory also chooses what to make.
A factory can also decide which implementation to make. A CMS page is a list of blocks, and the published page and the editor preview both turn them into components: one table says which component each block type makes, with which defaults, so a new block type is a new row and neither renderer changes. An unknown type makes nothing, and each renderer decides what to show instead. A test can render one block type through the same table, without a CMS.
import type { ReactElement } from 'react';
function Quote({ text, showAuthor }: { text: string; showAuthor: boolean }) {
return <blockquote>{text}{showAuthor && <cite>The author</cite>}</blockquote>;
}
function Hero({ title }: { title: string }) {
return <h1>{title}</h1>;
}
// One variant per block type: the type says which fields come with it.
type CmsBlock =
| { id: string; type: 'quote'; text?: string; showAuthor?: boolean }
| { id: string; type: 'hero'; title?: string };
type BlockType = CmsBlock['type'];
// The factory's table: which component each block type makes, with which
// defaults. It needs a row for every type, and each row's props are checked
// against its component, so a missing row or a wrong prop does not compile.
// Read the type as: for each block type T, a function that takes the block
// of type T (Extract picks that variant out of CmsBlock) and returns an element.
const blockTypes: { [T in BlockType]: (block: Extract<CmsBlock, { type: T }>) => ReactElement } = {
quote: (block) => <Quote text={block.text ?? ''} showAuthor={block.showAuthor ?? true} />,
hero: (block) => <Hero title={block.title ?? 'Untitled'} />,
};
// The CMS sends JSON, so a type it adds tomorrow arrives as a plain string:
// make nothing for it, and let each renderer decide what to show instead.
function makeBlock(block: { id: string; type: string }): ReactElement | null {
if (!Object.hasOwn(blockTypes, block.type)) return null;
// TypeScript cannot tie block.type to its row, so three casts here, all trusting the CMS's
// JSON; the table above is fully checked. Validate CMS data at its input boundary (a schema
// check where the JSON arrives), so these casts only ever see blocks of the declared shape.
const make = blockTypes[block.type as BlockType] as (block: CmsBlock) => ReactElement;
return make(block as CmsBlock);
}
// The published page; the editor preview calls makeBlock the same way.
function Page({ blocks }: { blocks: { id: string; type: string }[] }) {
return blocks.map((block) => (
<section key={block.id}>{makeBlock(block) ?? <p>Unsupported block: {block.type}</p>}</section>
));
} Keep the table the only place that knows the block types, check with Object.hasOwn so a name like toString is not mistaken for one, and key
rendered blocks by their own id, not their position.
05Decide when it helps
Give the code a change to absorb.
Give the code a plausible change. Follow which callers need to know about it, and whether they still need the same creation rules.
| The change | Repeated inline construction | A shared factory |
|---|---|---|
| The default output directory moves | Find and update each copy of the default. | Change the shared rule once. Existing overrides still win. |
| Empty directories must be rejected | Each creation path needs the check or another shared boundary. | Validate at the creation boundary. |
| One caller needs an unrelated configuration | That caller can describe its own complete value. | A special option may make everyone’s API harder to understand. |
Shared defaults, validation, or setup can give creation a responsibility worth naming. When one caller already has the complete, obvious value, a literal can express it clearly.
A constructor can also own defaults and validation. Keep it when it already gives callers a clear API. A factory earns its extra name through the creation decisions it gathers.
Its neighbours: Builder assembles one complex value step by step; Abstract Factory creates families that must fit together; dependency injection decides who supplies the collaborators, which is often a factory’s result.
What happens after the factory returns?Mutation, I/O, and lifetime
Our result is frozen and its fields are primitives. Freezing an outer object with a plugins array would leave that array mutable.
A client factory might load credentials or open a connection. Its contract needs to make failure, cancellation, and cleanup clear. Decide who owns the resource and how long it lives.
06Make the call
A value, or a way to create one?
A consumer can receive a finished value, showPreview(config), or a way to make
one, runWorker(jobs, resolveBuildConfig): the factory function itself, handed over to be called
later. The difference is when creation happens. An API client that captures a token when it is
created sends a stale one after the next login; one that reads the current token for each request
does not.
React draws the same line. useState(createStore()) calls the factory on every
render and keeps only the first result; useState(() => createStore()) hands React
the creator, and it calls it once.
Before you open the answer below, say it in your own words: what does the caller get to stop knowing, and when does a consumer need a way to create rather than a finished value?
One way to say itWho knows what, and when creation happens
The caller knows the situation and says only what it needs; the factory knows how to create it, owning presets, defaults and validation, so every caller gets the same rules and a deliberate choice always beats a default. A factory can also choose which implementation to return, so callers depend on what it does rather than which type it is. Pass the finished configuration when the consumer only uses that value. Pass a creation function when the consumer must decide when or how often to create another one: a queued build worker resolves each job’s options when that job starts. (Resolving when a job is enqueued and storing the result on the job is also defensible: bad jobs are rejected at submission, but each job then runs with the defaults of the moment it was queued.) And when one caller needs one obvious value that nobody shares, write it out: a factory earns its name through the decisions it gathers.
Practice in the lab
The repository from the lesson: a CLI, a watch task and a release task each need a configuration for the same build runner, and the team keeps one setting, the output root, that decides where builds go by default. Each level starts from the previous level’s finished code, so you can begin at any of them.
- FACTORY-101 (easy) The output root moved and two callers did not
- FACTORY-102 (medium) The worker bundle has nowhere to go Pro
- FACTORY-103 (hard) Review: queued jobs come out with another job’s settings Pro