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.
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.
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.
Positional arguments
Compact and direct when the list is short, stable, and ordered naturally.
Options object
Names each field at the call site and lets defaults evolve without shifting slots.
Builder
Collects conditional or staged pieces, then creates one object at build().
Named factories
Names a small set of meaningful variants, such as CSV and JSON exports.
| Shape | Best clue | Validation point | Main cost |
|---|---|---|---|
| Positional | Tiny stable list | Constructor call | Order and optional slots |
| Options | Named, evolving fields | Constructor call | Bag can grow without a domain model |
| Builder | Staged or conditional assembly | build() | Mutable draft and API ceremony |
| Factories | Small closed variant set | Each factory or delegate | Repeated options and surface growth |
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.
Does the constructor still explain the call?
Keep the export job fixed. Change the requirements or the public shape.
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.
Hold the decision steady. Change the language.
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
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 });
} 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
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);
}
} 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
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);
} 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.
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());
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
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.
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.
Positional can be fine.
Let the short signature stay direct while its order remains obvious.
Prefer an options object.
Give new settings names, defaults, and one validation boundary.
Builder or factory.
Use a builder for assembly; use factories for a small set of meaningful variants.
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?
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 →