← Design patterns
Creation Factory functions

Factory

Let the caller say what it needs.

A UI element, a store, an HTTP request: each begins with a creation request. Follow the rules behind that request through a build workflow, and decide where creation should live.

TypeScriptGoRust The same factory in each language.

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.

build.ts
// 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:

What you ask for, and what creation handles
A familiar requestWhat comes backWhat 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.

config.ts
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);
Who owns what
RoleResponsibilityWhat it handles
CallerKnows the situationCLI flags, watch mode, release intent.
FactoryKnows how to create itPresets, defaults and overrides (and, in production, validation).
Build runnerUses the resultReceives 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.

config.ts
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

config.ts
// 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 });
Suppose the factory merged with a spread like this, and the CLI, run without its --out flag, passed { outDir: undefined }. What output directory does the CLI get?
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.

blocks.tsx
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.

What would you have to change tomorrow?
The changeRepeated inline constructionA shared factory
The default output directory movesFind and update each copy of the default.Change the shared rule once. Existing overrides still win.
Empty directories must be rejectedEach creation path needs the check or another shared boundary.Validate at the creation boundary.
One caller needs an unrelated configurationThat 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.

A preview panel displays the resolved configuration for one build. A batch worker takes jobs from a queue; each job brings its own preset and overrides when it starts. Both use the same creation rules. What would you pass to each consumer?
A benchmark script builds one fixed configuration that no other caller shares and that never changes. What should it use?

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