← Concepts & practices
Choice Data modeling and type design

Wide constructors

How should callers supply a growing set of choices?

An export job starts with a name, format, and destination. Then timeout, compression, and format-specific settings arrive. Positional arguments, an options object, a builder, and named factories all can work—but they make different tradeoffs visible at different places.

The judgment to keep

Choose the constructor shape from how requirements grow, how callers assemble data, and where invalid combinations should be rejected.

TypeScriptGo More fields make the call carry more meaning.
Start with one export job

A long signature is a design signal.

An export service needs to create a job with a name, format, and destination. A positional call is compact while that list is small and stable. But once timeoutMs and compress become optional settings, a caller has to remember which empty or boolean value occupies which slot.

The constructor is not only an object factory. It is the first explanation a caller reads. A good shape makes required data, defaults, variants, and the validation boundary easy to see.

Our shared contractCreate a validated export job.

Keep the domain object fixed while changing how callers supply its data.

Required
Name, format, and destination must be present and valid.
Optional
Timeout defaults to 5000 ms; compression defaults to false.
Combination
Compression is allowed only for archive exports; a compressed download is rejected.
Boundary
One constructor or build() call validates before the job is used.
Put the choices on the same table

Four shapes, four places to pay.

These alternatives are not mutually exclusive. A named factory can delegate to an options constructor. A builder can gather data and return the same validated job. Compare what each shape makes easier—and what surface it adds.

A

Positional arguments

Compact and direct when the list is short, stable, and ordered naturally.

Order and arity carry meaning; optional growth makes calls harder to review.
B

Options object

Names each field at the call site and lets defaults evolve without shifting slots.

Still needs validation; a large unstructured bag can hide an invalid combination.
C

Builder

Collects conditional or staged pieces, then creates one object at build().

Pays state, mutability, and ceremony; worthwhile only when assembly earns it.
D

Named factories

Names a small set of meaningful variants, such as CSV and JSON exports.

The factory surface grows quickly when variants are really just option combinations.
Where does each shape make the constraint visible?
ShapeBest clueValidation pointMain cost
PositionalTiny stable listConstructor callOrder and optional slots
OptionsNamed, evolving fieldsConstructor callBag can grow without a domain model
BuilderStaged or conditional assemblybuild()Mutable draft and API ceremony
FactoriesSmall closed variant setEach factory or delegateRepeated options and surface growth
Keep the export job fixed

Now change the requirements.

Start with a growing set of optional settings and test each shape. Then try a stable list or conditional construction. The lab is a design model, not a fake compiler: its job is to make the judgment and its assumptions explicit.

Constraint lab

Does the constructor still explain the call?

Keep the export job fixed. Change the requirements or the public shape.

Requirement profile
Constructor shape
Current constraint More optional settings

Timeout and compression join the original fields.

Choose a requirement profile and constructor shape, then evaluate it.

Why not always use a builder?Ceremony has to earn itself

A builder can improve a complicated assembly flow, but it also introduces a mutable draft, more methods, and another lifecycle to understand. If callers already possess a complete configuration object, an options constructor often says the same thing more directly.

Separate the two comparisons

Hold the decision steady. Change the language.

Read the constructor shapes in TypeScript and Go.

The examples create the same export job and enforce the same rules. The browser lab above is an authored decision model, not this code; these panes show the implementation side by side.

Name the public shapes

TypeScriptConstructor alternatives
export.ts · public shapes
export function createPositional(
	name: string,
	format: ExportFormat,
	destination: ExportDestination,
	timeoutMs = 5000,
	compress = false
): Result<ExportJob> {
	return createFromOptions({ name, format, destination, timeoutMs, compress });
}

export function createCsvExport(
	name: string,
	destination: ExportDestination = 'download',
	options: Pick<ExportJobOptions, 'timeoutMs' | 'compress'> = {}
): Result<ExportJob> {
	return createFromOptions({ name, format: 'csv', destination, ...options });
}

export function createJsonExport(
	name: string,
	destination: ExportDestination = 'download',
	options: Pick<ExportJobOptions, 'timeoutMs' | 'compress'> = {}
): Result<ExportJob> {
	return createFromOptions({ name, format: 'json', destination, ...options });
}
GoConstructor alternatives
export.go · constructors
func NewExportJobPositional(name string, format ExportFormat, destination ExportDestination, timeoutMs int, compress bool) (ExportJob, error) {
	return NewExportJob(ExportOptions{
		Name: name, Format: format, Destination: destination, TimeoutMs: timeoutMs, Compress: compress,
	})
}

// ExportSettings holds the optional fields a named factory still accepts.
type ExportSettings struct {
	TimeoutMs int
	Compress  bool
}

func NewCSVExport(name string, destination ExportDestination, settings ExportSettings) (ExportJob, error) {
	return NewExportJob(ExportOptions{
		Name: name, Format: FormatCSV, Destination: destination,
		TimeoutMs: settings.TimeoutMs, Compress: settings.Compress,
	})
}

func NewJSONExport(name string, destination ExportDestination, settings ExportSettings) (ExportJob, error) {
	return NewExportJob(ExportOptions{
		Name: name, Format: FormatJSON, Destination: destination,
		TimeoutMs: settings.TimeoutMs, Compress: settings.Compress,
	})
}

Names at the call site are part of the API. Notice how factories narrow a variant while options preserve a general shape.

Validate once at the boundary

TypeScriptValidated constructors
export.ts · validation
export function createFromOptions(options: ExportJobOptions): Result<ExportJob> {
	return validateJob(options);
}

/** The one validation boundary: every entry point, including build(), ends here. */
function validateJob(options: Partial<ExportJobOptions>): Result<ExportJob> {
	const { name = '', format, destination, timeoutMs = 5000, compress = false } = options;
	if (!name.trim()) return invalid('An export name is required.');
	if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60_000) {
		return invalid('Timeout must be between 100 and 60000 ms.');
	}
	if (format !== 'csv' && format !== 'json') return invalid('Format must be csv or json.');
	if (destination !== 'download' && destination !== 'archive') {
		return invalid('Destination must be download or archive.');
	}
	// A cross-field rule: no single field is wrong, but the combination is.
	if (compress && destination !== 'archive') {
		return invalid('Compression is only available for archive exports.');
	}
	return { ok: true, value: { name: name.trim(), format, destination, timeoutMs, compress } };
}

export class ExportJobBuilder {
	private options: Partial<ExportJobOptions> = {};

	name(name: string) {
		this.options.name = name;
		return this;
	}

	format(format: ExportFormat) {
		this.options.format = format;
		return this;
	}

	destination(destination: ExportDestination) {
		this.options.destination = destination;
		return this;
	}

	timeout(timeoutMs: number) {
		this.options.timeoutMs = timeoutMs;
		return this;
	}

	compressed(compress = true) {
		this.options.compress = compress;
		return this;
	}

	build(): Result<ExportJob> {
		// No silent defaults for required fields: a missing format or destination fails here.
		return validateJob(this.options);
	}
}
GoValidated constructors
export.go · validation
type ExportOptions struct {
	Name        string
	Format      ExportFormat
	Destination ExportDestination
	TimeoutMs   int
	Compress    bool
}

func NewExportJob(options ExportOptions) (ExportJob, error) {
	if strings.TrimSpace(options.Name) == "" {
		return ExportJob{}, errors.New("an export name is required")
	}
	if options.TimeoutMs == 0 { // Go's zero value means "not set": use the default.
		options.TimeoutMs = 5000
	}
	if options.TimeoutMs < 100 || options.TimeoutMs > 60000 {
		return ExportJob{}, errors.New("timeout must be between 100 and 60000 ms")
	}
	if options.Format != FormatCSV && options.Format != FormatJSON {
		return ExportJob{}, errors.New("format must be csv or json")
	}
	if options.Destination != DestinationDownload && options.Destination != DestinationArchive {
		return ExportJob{}, errors.New("destination must be download or archive")
	}
	// A cross-field rule: no single field is wrong, but the combination is.
	if options.Compress && options.Destination != DestinationArchive {
		return ExportJob{}, errors.New("compression is only available for archive exports")
	}

	options.Name = strings.TrimSpace(options.Name)
	return ExportJob{
		Name: options.Name, Format: options.Format, Destination: options.Destination,
		TimeoutMs: options.TimeoutMs, Compress: options.Compress,
	}, nil
}

type ExportBuilder struct {
	options ExportOptions
}

func NewExportBuilder() *ExportBuilder {
	return &ExportBuilder{options: ExportOptions{TimeoutMs: 5000}}
}

func (builder *ExportBuilder) Name(name string) *ExportBuilder {
	builder.options.Name = name
	return builder
}

func (builder *ExportBuilder) Format(format ExportFormat) *ExportBuilder {
	builder.options.Format = format
	return builder
}

func (builder *ExportBuilder) Destination(destination ExportDestination) *ExportBuilder {
	builder.options.Destination = destination
	return builder
}

func (builder *ExportBuilder) Timeout(timeoutMs int) *ExportBuilder {
	builder.options.TimeoutMs = timeoutMs
	return builder
}

func (builder *ExportBuilder) Compressed(compress bool) *ExportBuilder {
	builder.options.Compress = compress
	return builder
}

func (builder *ExportBuilder) Build() (ExportJob, error) {
	return NewExportJob(builder.options)
}

All four entry points converge on one validation rule instead of letting each caller invent its own defaults. That includes the cross-field rule: no single field is wrong in a compressed download, but the combination is, so only the shared boundary can reject it.

See the call siteVariant choice without positional slots
TypeScriptExport call site
export.ts · call site
export function startExport(request: {
	name: string;
	format: ExportFormat;
	destination: ExportDestination;
	compress?: boolean;
}) {
	const options = { compress: request.compress };
	return request.format === 'csv'
		? createCsvExport(request.name, request.destination, options)
		: createJsonExport(request.name, request.destination, options);
}
GoExport call site
export.go · call site
func StartExport(name string, format ExportFormat, destination ExportDestination, compress bool) (ExportJob, error) {
	// Compression goes in before validation, never onto the job afterwards.
	settings := ExportSettings{Compress: compress}
	if format == FormatCSV {
		return NewCSVExport(name, destination, settings)
	}
	return NewJSONExport(name, destination, settings)
}

The format-specific factory is useful when format is a meaningful product variant, not merely an arbitrary field.

Copy the complete examplesStandard library only

These files are complete and copyable. The browser lab stays focused on the design tradeoff rather than accepting arbitrary code.

TypeScriptComplete export example
export.ts
export type ExportFormat = 'csv' | 'json';
export type ExportDestination = 'download' | 'archive';

export type ExportJob = Readonly<{
	name: string;
	format: ExportFormat;
	destination: ExportDestination;
	timeoutMs: number;
	compress: boolean;
}>;

export type ExportJobOptions = {
	name: string;
	format: ExportFormat;
	destination: ExportDestination;
	timeoutMs?: number;
	compress?: boolean;
};

export type Result<T> = { ok: true; value: T } | { ok: false; error: ValidationError };

export class ValidationError extends Error {
	constructor(message: string) {
		super(message);
		this.name = 'ValidationError';
	}
}

function invalid(message: string): Result<ExportJob> {
	return { ok: false, error: new ValidationError(message) };
}

export function createPositional(
	name: string,
	format: ExportFormat,
	destination: ExportDestination,
	timeoutMs = 5000,
	compress = false
): Result<ExportJob> {
	return createFromOptions({ name, format, destination, timeoutMs, compress });
}

export function createCsvExport(
	name: string,
	destination: ExportDestination = 'download',
	options: Pick<ExportJobOptions, 'timeoutMs' | 'compress'> = {}
): Result<ExportJob> {
	return createFromOptions({ name, format: 'csv', destination, ...options });
}

export function createJsonExport(
	name: string,
	destination: ExportDestination = 'download',
	options: Pick<ExportJobOptions, 'timeoutMs' | 'compress'> = {}
): Result<ExportJob> {
	return createFromOptions({ name, format: 'json', destination, ...options });
}

export function createFromOptions(options: ExportJobOptions): Result<ExportJob> {
	return validateJob(options);
}

/** The one validation boundary: every entry point, including build(), ends here. */
function validateJob(options: Partial<ExportJobOptions>): Result<ExportJob> {
	const { name = '', format, destination, timeoutMs = 5000, compress = false } = options;
	if (!name.trim()) return invalid('An export name is required.');
	if (!Number.isInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 60_000) {
		return invalid('Timeout must be between 100 and 60000 ms.');
	}
	if (format !== 'csv' && format !== 'json') return invalid('Format must be csv or json.');
	if (destination !== 'download' && destination !== 'archive') {
		return invalid('Destination must be download or archive.');
	}
	// A cross-field rule: no single field is wrong, but the combination is.
	if (compress && destination !== 'archive') {
		return invalid('Compression is only available for archive exports.');
	}
	return { ok: true, value: { name: name.trim(), format, destination, timeoutMs, compress } };
}

export class ExportJobBuilder {
	private options: Partial<ExportJobOptions> = {};

	name(name: string) {
		this.options.name = name;
		return this;
	}

	format(format: ExportFormat) {
		this.options.format = format;
		return this;
	}

	destination(destination: ExportDestination) {
		this.options.destination = destination;
		return this;
	}

	timeout(timeoutMs: number) {
		this.options.timeoutMs = timeoutMs;
		return this;
	}

	compressed(compress = true) {
		this.options.compress = compress;
		return this;
	}

	build(): Result<ExportJob> {
		// No silent defaults for required fields: a missing format or destination fails here.
		return validateJob(this.options);
	}
}

export function startExport(request: {
	name: string;
	format: ExportFormat;
	destination: ExportDestination;
	compress?: boolean;
}) {
	const options = { compress: request.compress };
	return request.format === 'csv'
		? createCsvExport(request.name, request.destination, options)
		: createJsonExport(request.name, request.destination, options);
}

export function example() {
	return createFromOptions({
		name: 'orders-today',
		format: 'csv',
		destination: 'archive',
		compress: true
	});
}

console.log(example());
GoComplete export example
export.go
package main

import (
	"errors"
	"fmt"
	"strings"
)

type ExportFormat string

const (
	FormatCSV  ExportFormat = "csv"
	FormatJSON ExportFormat = "json"
)

type ExportDestination string

const (
	DestinationDownload ExportDestination = "download"
	DestinationArchive  ExportDestination = "archive"
)

type ExportJob struct {
	Name        string
	Format      ExportFormat
	Destination ExportDestination
	TimeoutMs   int
	Compress    bool
}

func NewExportJobPositional(name string, format ExportFormat, destination ExportDestination, timeoutMs int, compress bool) (ExportJob, error) {
	return NewExportJob(ExportOptions{
		Name: name, Format: format, Destination: destination, TimeoutMs: timeoutMs, Compress: compress,
	})
}

// ExportSettings holds the optional fields a named factory still accepts.
type ExportSettings struct {
	TimeoutMs int
	Compress  bool
}

func NewCSVExport(name string, destination ExportDestination, settings ExportSettings) (ExportJob, error) {
	return NewExportJob(ExportOptions{
		Name: name, Format: FormatCSV, Destination: destination,
		TimeoutMs: settings.TimeoutMs, Compress: settings.Compress,
	})
}

func NewJSONExport(name string, destination ExportDestination, settings ExportSettings) (ExportJob, error) {
	return NewExportJob(ExportOptions{
		Name: name, Format: FormatJSON, Destination: destination,
		TimeoutMs: settings.TimeoutMs, Compress: settings.Compress,
	})
}


type ExportOptions struct {
	Name        string
	Format      ExportFormat
	Destination ExportDestination
	TimeoutMs   int
	Compress    bool
}

func NewExportJob(options ExportOptions) (ExportJob, error) {
	if strings.TrimSpace(options.Name) == "" {
		return ExportJob{}, errors.New("an export name is required")
	}
	if options.TimeoutMs == 0 { // Go's zero value means "not set": use the default.
		options.TimeoutMs = 5000
	}
	if options.TimeoutMs < 100 || options.TimeoutMs > 60000 {
		return ExportJob{}, errors.New("timeout must be between 100 and 60000 ms")
	}
	if options.Format != FormatCSV && options.Format != FormatJSON {
		return ExportJob{}, errors.New("format must be csv or json")
	}
	if options.Destination != DestinationDownload && options.Destination != DestinationArchive {
		return ExportJob{}, errors.New("destination must be download or archive")
	}
	// A cross-field rule: no single field is wrong, but the combination is.
	if options.Compress && options.Destination != DestinationArchive {
		return ExportJob{}, errors.New("compression is only available for archive exports")
	}

	options.Name = strings.TrimSpace(options.Name)
	return ExportJob{
		Name: options.Name, Format: options.Format, Destination: options.Destination,
		TimeoutMs: options.TimeoutMs, Compress: options.Compress,
	}, nil
}

type ExportBuilder struct {
	options ExportOptions
}

func NewExportBuilder() *ExportBuilder {
	return &ExportBuilder{options: ExportOptions{TimeoutMs: 5000}}
}

func (builder *ExportBuilder) Name(name string) *ExportBuilder {
	builder.options.Name = name
	return builder
}

func (builder *ExportBuilder) Format(format ExportFormat) *ExportBuilder {
	builder.options.Format = format
	return builder
}

func (builder *ExportBuilder) Destination(destination ExportDestination) *ExportBuilder {
	builder.options.Destination = destination
	return builder
}

func (builder *ExportBuilder) Timeout(timeoutMs int) *ExportBuilder {
	builder.options.TimeoutMs = timeoutMs
	return builder
}

func (builder *ExportBuilder) Compressed(compress bool) *ExportBuilder {
	builder.options.Compress = compress
	return builder
}

func (builder *ExportBuilder) Build() (ExportJob, error) {
	return NewExportJob(builder.options)
}


func StartExport(name string, format ExportFormat, destination ExportDestination, compress bool) (ExportJob, error) {
	// Compression goes in before validation, never onto the job afterwards.
	settings := ExportSettings{Compress: compress}
	if format == FormatCSV {
		return NewCSVExport(name, destination, settings)
	}
	return NewJSONExport(name, destination, settings)
}


func main() {
	job, err := NewExportJob(ExportOptions{
		Name: "orders-today", Format: FormatCSV, Destination: DestinationArchive, Compress: true,
	})
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s export: %s -> %s (compress=%t)\n", job.Format, job.Name, job.Destination, job.Compress)
}

TypeScriptnode --experimental-strip-types export.ts

Gogo run export.go

Set the validation boundary

Make invalid combinations somebody's problem.

An options object does not remove the need for a domain boundary. A builder does not make an incomplete draft safe. A factory does not eliminate validation. Each can make the intended boundary easier to locate, but the final job should be valid before it reaches the queue.

Keep transport parsing, defaults, and domain validation close enough that callers do not need to know which omitted setting is safe. If different formats gain genuinely different rules, that is evidence for a named variant or a richer domain type—not automatically for more methods.

Build UIs?See where this shows up in your components.

A form can assemble; the constructor decides.

A UI may reveal compression only for an archive destination and collect settings across steps. Let the component manage draft state, then pass a complete options object or builder result to one domain boundary. Do not make translated labels or disabled controls the only source of the invariant.

Make a conditional recommendation

Choose the smallest shape that tells the truth.

For a handful of fields that are already available together, start with an options object. Move to a builder when conditional, staged construction or many optional settings make a complete object difficult to form in one expression. Add named factories when a small, closed set of variants deserves names and different invariants. Keep positional arguments for tiny stable constructors where the order is genuinely obvious.

Small and stable

Positional can be fine.

Let the short signature stay direct while its order remains obvious.

Growing independent fields

Prefer an options object.

Give new settings names, defaults, and one validation boundary.

Staged or closed variants

Builder or factory.

Use a builder for assembly; use factories for a small set of meaningful variants.

Decision practice

A constructor has two required fields, six optional fields, and invalid combinations.

Callers assemble it conditionally from several branches. Which shape is the best starting point?

Leave yourself a useful note

Record why the shape exists.

“The constructor got wide” is a symptom, not a decision. Record what callers know, which combinations are invalid, and what cost the chosen shape is paying.

Why
Callers need to create a valid export job as requirements evolve.
What
Options names independent fields; a builder stages assembly; factories name closed variants.
Constraint
Defaults and invalid combinations must be enforced before the job is queued.
Fallback
When a builder or a factory stops buying clarity, return to a validated options object.
Reconsider when
The variant set, construction steps, or validation rules change materially.

A decision note to adapt to your own API. Nothing here is saved to an account.

Explore more concepts & practices →