← Concepts & practices
Concept Design principles and language mechanisms

Polymorphism

Let the value pick its own code.

You already loop over arrays, maps, and strings with the same for...of. Let’s follow a survey builder whose questions are handled by switching on their type, until another team’s date question has to be added to every switch in the codebase.

TypeScriptGo One survey, two implementations.

01 / The idea

A switch on the question type is a fair start.

You’re building a survey builder. A question is a text box, a single choice, or a 1-to-5 rating, stored as plain data with a type. Three functions handle them: validateAnswer, summarizeAnswers for the results page, and csvCell for the export. Each one switches on field.type. Three kinds and three operations, all in one file, and TypeScript checks every switch covers every type.

Read the first versionTypeScript · the version this lesson starts from
fields.ts
// The first version: questions are plain data, and each operation switches on the type.
export type FieldSpec =
	| { type: 'text'; id: string; label: string; required: boolean; maxLength: number }
	| { type: 'choice'; id: string; label: string; required: boolean; options: readonly string[] }
	| { type: 'rating'; id: string; label: string; required: boolean; max: number };

export function validateAnswer(field: FieldSpec, answer: Answer): string | null {
	if (answer === '') return field.required ? 'This question is required.' : null;
	switch (field.type) {
		case 'text':
			return answer.length > field.maxLength ? `Use at most ${field.maxLength} characters.` : null;
		case 'choice':
			return field.options.includes(answer) ? null : 'Pick one of the options.';
		case 'rating': {
			const value = Number(answer);
			return Number.isInteger(value) && value >= 1 && value <= field.max
				? null
				: `Choose 1 to ${field.max}.`;
		}
	}
}

export function summarizeAnswers(field: FieldSpec, answers: readonly Answer[]): string {
	const given = answered(answers);
	switch (field.type) {
		case 'text':
			return plural(given.length, 'written answer');
		case 'choice':
			return field.options
				.map((option) => `${option} ${given.filter((answer) => answer === option).length}`)
				.join(' · ');
		case 'rating': {
			const total = given.reduce((sum, answer) => sum + Number(answer), 0);
			return given.length
				? `average ${(total / given.length).toFixed(1)} of ${field.max}`
				: 'no ratings';
		}
	}
}

export function csvCell(field: FieldSpec, answer: Answer): string {
	switch (field.type) {
		case 'text':
		case 'choice':
			return quoted(answer);
		case 'rating':
			return answer;
	}
}

Go’s FieldSpec is a struct with a Type string and the same three switches. Both languages meet again at the Field interface in section 02.

Then the product grows. The scheduling team wants a date question. The results page, the export, and the validator belong to three different people, so the date question is a change to all three switches, and a review from each owner. The next team wants an NPS question and does it again. In Go, a switch that nobody updated returns an empty message for the new type, so the date question accepts anything.

Polymorphism lets one call site work with values of different types, each running its own code for the same operation. With an interface, the value decides which code runs: field.validate(answer) runs the rating’s code for a rating and the date’s code for a date, and the caller never asks which it has. The switch was already a form of it; the question is who holds the decision.

Section 05 builds a survey form that renders each question’s own input component, in React and Svelte.

02 / See the shape

Describe what every kind does, and let each kind do it.

The basic form declares the Field interface, implements it for two kinds, and writes the one loop that uses it. In the wild adds a choice question, a date question that no caller had to learn about, and the report and export. At the call site runs three responses through both versions.

Both languages produce the same messages, summaries, and CSV cells.

The Field interface, two kinds that implement it, and problems(): one loop that checks required answers and asks each question to validate the rest.

TypeScriptReading
fields.ts
// Each kind of question brings its own behavior. Callers use the interface and never ask which kind.
export interface Field {
	readonly id: string;
	readonly label: string;
	readonly kind: string;
	readonly required: boolean;
	// Called only with a non-empty answer. Returns a message for the respondent, or null.
	validate(answer: Answer): string | null;
	summarize(answers: readonly Answer[]): string;
	csvCell(answer: Answer): string;
}

export function textField(
	id: string,
	label: string,
	options: { required: boolean; maxLength: number }
): Field {
	return {
		id,
		label,
		kind: 'text',
		required: options.required,
		validate: (answer) =>
			answer.length > options.maxLength ? `Use at most ${options.maxLength} characters.` : null,
		summarize: (answers) => plural(answered(answers).length, 'written answer'),
		csvCell: quoted
	};
}

export function ratingField(
	id: string,
	label: string,
	options: { required: boolean; max: number }
): Field {
	return {
		id,
		label,
		kind: 'rating',
		required: options.required,
		validate(answer) {
			const value = Number(answer);
			return Number.isInteger(value) && value >= 1 && value <= options.max
				? null
				: `Choose 1 to ${options.max}.`;
		},
		summarize(answers) {
			const given = answered(answers);
			const total = given.reduce((sum, answer) => sum + Number(answer), 0);
			return given.length
				? `average ${(total / given.length).toFixed(1)} of ${options.max}`
				: 'no ratings';
		},
		csvCell: (answer) => answer
	};
}

// The call site: one loop for every kind. The required check is shared, so it stays here.
export function problems(fields: readonly Field[], response: Response): Record<string, string> {
	const found: Record<string, string> = {};
	for (const field of fields) {
		const answer = response[field.id] ?? '';
		const problem =
			answer === ''
				? field.required
					? 'This question is required.'
					: null
				: field.validate(answer);
		if (problem) found[field.id] = problem;
	}
	return found;
}
GoAlongside
fields.go
// Field is what every kind of question does. Callers use it and never ask which kind.
type Field interface {
	ID() string
	Label() string
	Kind() string
	Required() bool
	// Validate is called only with a non-empty answer. It returns a message, or "".
	Validate(answer string) string
	Summarize(answers []string) string
	CSVCell(answer string) string
}

// question holds what every kind shares; each kind embeds it.
type question struct {
	id, label string
	required  bool
}

func (q question) ID() string     { return q.id }
func (q question) Label() string  { return q.label }
func (q question) Required() bool { return q.required }

type TextField struct {
	question
	MaxLength int
}

func NewTextField(id, label string, required bool, maxLength int) TextField {
	return TextField{question{id, label, required}, maxLength}
}

func (TextField) Kind() string { return "text" }
func (f TextField) Validate(answer string) string {
	if len([]rune(answer)) > f.MaxLength {
		return fmt.Sprintf("Use at most %d characters.", f.MaxLength)
	}
	return ""
}
func (TextField) Summarize(answers []string) string {
	return plural(len(answered(answers)), "written answer")
}
func (TextField) CSVCell(answer string) string { return quoted(answer) }

type RatingField struct {
	question
	Max int
}

func NewRatingField(id, label string, required bool, max int) RatingField {
	return RatingField{question{id, label, required}, max}
}

func (RatingField) Kind() string { return "rating" }
func (f RatingField) Validate(answer string) string {
	if value, err := strconv.Atoi(answer); err != nil || value < 1 || value > f.Max {
		return fmt.Sprintf("Choose 1 to %d.", f.Max)
	}
	return ""
}
func (f RatingField) Summarize(answers []string) string {
	return ratingSummary(f.Max, answered(answers))
}
func (RatingField) CSVCell(answer string) string { return answer }

// The compiler checks each kind satisfies Field; there is no "implements" declaration.
var (
	_ Field = TextField{}
	_ Field = RatingField{}
)

// Problems is the call site: one loop for every kind.
func Problems(fields []Field, response Response) map[string]string {
	found := map[string]string{}
	for _, field := range fields {
		answer := response[field.ID()]
		problem := ""
		if answer == "" {
			if field.Required() {
				problem = "This question is required."
			}
		} else {
			problem = field.Validate(answer)
		}
		if problem != "" {
			found[field.ID()] = problem
		}
	}
	return found
}
Reading the TypeScriptStructural types and object literals

Each kind is a function that returns an object literal, and nothing says implements Field. TypeScript’s handbook: “Type compatibility in TypeScript is based on structural subtyping.” The return type Field is what checks the object has every method.

kind is there for display. The callers never branch on it, and the film checks that by reading their source.

Reading the GoImplicit interfaces and a compile-time check

The Go FAQ: “A Go type implements an interface by implementing the methods of that interface, nothing more.” RatingField never names Field.

var _ Field = RatingField{} asks the compiler to check anyway, the way the FAQ suggests. The shared ID, Label, and Required methods come from an embedded question struct.

03 / Follow the decision

Watch who picks the code, and what each change costs.

Five steps. The messages come from running the lesson’s functions on the third response; the lists of switches, kinds, and callers are read from the lesson’s source file. Before steps 2, 4, and 5, guess how many functions the change touches.

In Try it, answer the survey yourself and compare what each version says.

Polymorphism

Who decides which code runs?

The switch decides. What should we improve? (text): Dark mode gives accepted; decided by validateAnswer → case 'text'. How often do you plan? (choice): (blank) gives This question is required.; decided by validateAnswer → required check. How useful is the planner? (rating): 7 gives Choose 1 to 5.; decided by validateAnswer → case 'rating'. 3 functions switch on field.type: validateAnswer, summarizeAnswers, csvCell. Each operation looks at the type and picks a branch. Three kinds, three operations, all readable in one file.

01/ 05
Validate the third response with the switch

The caller looks at the type.

validateAnswer, summarizeAnswers, and csvCell each switch on field.type.

Reduced motion: choose a scene to see its completed state.

Read this scene

validateAnswer, summarizeAnswers, and csvCell each switch on field.type.

The switch decides. What should we improve? (text): Dark mode gives accepted; decided by validateAnswer → case 'text'. How often do you plan? (choice): (blank) gives This question is required.; decided by validateAnswer → required check. How useful is the planner? (rating): 7 gives Choose 1 to 5.; decided by validateAnswer → case 'rating'. 3 functions switch on field.type: validateAnswer, summarizeAnswers, csvCell. Each operation looks at the type and picks a branch. Three kinds, three operations, all readable in one file.

Watch restarts when you return. Step through keeps your selected step. Try it starts with the third response each time you open it.

What an interface buys you

Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at something on this page.

New kinds without editing callers
dateField arrived; problems, summaryReport, and csvRow didn’t change.
Behavior next to its data
Everything a rating does is in ratingField, not spread across three switches.
Kinds other teams can own
The scheduling team ships a date question without a review from the export’s owner.
Callers that read as the task
problems is a loop that asks each question about its answer.
One contract to check
The spec runs both versions on the same answers and gets the same results.

The review words are polymorphism, interface or contract for Field, implementation for each kind, dynamic dispatch for field.validate(answer) finding the right code at run time, open for extension for adding a kind without editing callers, and the expression problem for step 5: making kinds easy to add makes operations harder. Section 08 covers the rest of the costs.

04 / Try a decision

A date question that throws.

The scheduling team wrote their own date question. It implements Field and type-checks. The code is in strict-date.ts, and the lesson’s tests pin what happens.

What does Ana see when she submits?

The scheduling team ships strictDateField. Its validate throws a RangeError for a date that doesn’t exist. Ana leaves the required plan question blank, types 2026-02-31 as her start date, and presses Send. submit calls problems and shows a generic error if anything throws.

05 / Give it a real job

A survey form that renders any question.

In the real app, each kind of question needs its own input as well as its own rules: a text box, a select, a row of radio buttons, a date field. The form has to render them all, show each message next to its input, and take new kinds from other teams without changes.

Question

Brings rules and an input

rules.validate and an Input component.

Input

Takes the same props

question, value, invalid, onchange.

Form

Treats them all alike

Checks required answers once, then asks each question.

The example leaves out loading questions from the server, multi-page surveys, and saving partial answers.

Build UIs?Every component that accepts the same props as its siblings is already an implementation of an interface.

Where it already is in your components

A form that renders whatever input it’s given is polymorphism with components. The textbook form takes each question’s Input and passes it the same props. In React it copies question.Input into a capitalized variable first, because React’s components “names must start with a capital letter or they won't work!”

Svelte 5 renders a component stored in a variable directly. Its migration guide explains that in Svelte 4 “if you render <Thing>, and the value of Thing changes, nothing happens”, and that you needed <svelte:component>; now <Thing /> is enough. The Svelte form uses {@const Input = question.Input}.

When you have to own it

Now it’s the whole survey. Four kinds of question, each message tied to its input with aria-describedby, and problemsFor checking required answers once before asking each question’s rules. The date question and its input came from another team; the form didn’t change to take it.

The contract is InputProps. An input that needs something else, like a list of time zones, gets it through question, not through a special case in the form.

survey-questions.ts
// What every kind of question does, whatever framework draws it. Each question brings its own
// rules and its own input component; the form treats them all the same way.
export interface Rules {
	// Called only with a non-empty answer. Returns a message for the respondent, or null.
	validate(answer: string): string | null;
}

export type Question<Input = unknown> = {
	id: string;
	label: string;
	required: boolean;
	options?: readonly string[];
	rules: Rules;
	Input: Input;
};

// The props every input component accepts, so the form can render any of them.
export type InputProps = {
	question: Question;
	value: string;
	invalid: boolean;
	onchange: (value: string) => void;
};

export const anyText = (maxLength: number): Rules => ({
	validate: (answer) => (answer.length > maxLength ? `Use at most ${maxLength} characters.` : null)
});

export const oneOf = (options: readonly string[]): Rules => ({
	validate: (answer) => (options.includes(answer) ? null : 'Pick one of the options.')
});

export const calendarDate: Rules = {
	validate(answer) {
		const date = new Date(`${answer}T00:00:00Z`);
		return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === answer
			? null
			: `${answer} isn’t a date on the calendar.`;
	}
};

export function problemsFor(
	questions: readonly Question[],
	answers: Readonly<Record<string, string>>
): Record<string, string> {
	const found: Record<string, string> = {};
	for (const question of questions) {
		const answer = answers[question.id] ?? '';
		const problem =
			answer === ''
				? question.required
					? 'This question is required.'
					: null
				: question.rules.validate(answer);
		if (problem) found[question.id] = problem;
	}
	return found;
}

A form that renders a text question and a rating question by passing each question’s own input the same props.

ReactAlready in your code
SurveyForm.tsx
import { useState } from 'react';
import { anyText, oneOf, type InputProps, type Question } from './survey-questions';

function TextInput({ question, value, onchange }: InputProps) {
	return <textarea id={question.id} value={value} onChange={(e) => onchange(e.target.value)} />;
}

function RatingInput({ question, value, onchange }: InputProps) {
	return (
		<div role="radiogroup" aria-labelledby={`${question.id}-label`}>
			{question.options?.map((option) => (
				<label key={option}>
					<input
						type="radio"
						name={question.id}
						checked={value === option}
						onChange={() => onchange(option)}
					/>
					{option}
				</label>
			))}
		</div>
	);
}

const questions: Question<(props: InputProps) => unknown>[] = [
	{
		id: 'feedback',
		label: 'What should we improve?',
		required: false,
		rules: anyText(40),
		Input: TextInput
	},
	{
		id: 'score',
		label: 'How useful is the planner?',
		required: true,
		options: ['1', '2', '3', '4', '5'],
		rules: oneOf(['1', '2', '3', '4', '5']),
		Input: RatingInput
	}
];

export function SurveyForm() {
	const [answers, setAnswers] = useState<Record<string, string>>({});

	return (
		<form>
			{questions.map((question) => {
				// Each question brings its own component; the form renders them all the same way.
				const Input = question.Input;
				return (
					<fieldset key={question.id}>
						<legend id={`${question.id}-label`}>{question.label}</legend>
						<Input
							question={question}
							value={answers[question.id] ?? ''}
							invalid={false}
							onchange={(value: string) => setAnswers({ ...answers, [question.id]: value })}
						/>
					</fieldset>
				);
			})}
		</form>
	);
}

06 / Recognize it elsewhere

Anywhere one call works on many kinds.

You’ve used all of these. For each one, find the interface and who implements it.

Familiar code, the interface it relies on, and the types that implement it
Where you’ve seen itThe interfaceSome implementations
for...ofA [Symbol.iterator]() methodArray, String, Map, Set
addEventListenerEventTargetElements, Document, Window, AudioContext
io.Copy in Goio.Reader’s Read*os.File, *strings.Reader, *bytes.Buffer
fmt.Println in Gofmt.Stringer’s StringAny type with a String() string method
A form in section 05InputPropsText, choice, rating, and date inputs

MDN says String, Array, Map, and Set “are all built-in iterables, because each of their prototype objects implements a [Symbol.iterator]() method.” Go’s docs say Stringer “is implemented by any value that has a String method.” When a function takes an interface instead of a concrete type, you’ve found the seam where a new kind can plug in.

07 / Already in your toolbox

Your languages already describe how it works.

Three places to look. For each one, find how a type comes to satisfy an interface.

Go · Why no “implements” declarations?

Why Go types satisfy interfaces without naming them, and how to ask the compiler to check one anyway.

Read the FAQ ↗

TypeScript · Type Compatibility

Structural typing, and why an object literal can be a Field without a class or an implements clause.

Read the handbook ↗

MDN · Iteration protocols

A small interface every JavaScript developer relies on, and how to make your own type work with for...of.

Read the reference ↗
A useful counterexample: an import’s statusWhen the kinds are fixed

An import is queued, running, finished, or failed, and that list won’t grow because another team asked. What keeps growing is what you do with the status: labels, icons, retry buttons. A union and a switch fit that better; Discriminated unions builds it, with the compiler checking every case.

08 / The parts to watch

An interface is a promise every kind has to keep.

These are the places it still goes wrong.

A kind that breaks the promise breaks every caller

Type-checking only proves the methods exist. If Field says validate returns a message, a kind that throws can’t stand in for the others. That’s the Liskov substitution principle; write the promise down, and test each kind against it.

A new operation touches every kind

Step 5 in the film: preview() has to be written four times, possibly by four teams. If operations grow faster than kinds, the switch was the better shape. Visitor is one way to add operations over a fixed set of kinds.

Checking the kind at the call site undoes it

if (field.kind === 'date') inside problems puts the switch back, one special case at a time. When a caller needs to know, the interface is missing a method.

Go’s switch doesn’t check every case

A Go switch on a string with no matching case runs nothing. In this lesson’s first version, a date question falls through and ValidateAnswer returns "", accepting any answer.

Wide interfaces are hard to implement

Every method on Field is work for every kind, forever. Keep what only one caller needs out of it, or give that caller a smaller interface.

Shared behavior belongs outside the kinds

The required check is the same for every question, so it lives in problems. Copied into four kinds, it would drift into four slightly different messages.

09 / Make the call

What would you have to change tomorrow?

Give both versions a plausible change and follow the work it creates.

How a change affects a switch on the question type and a Field interface
The changeSwitch on the typeField interface
Add a date questionA case in three functions.One new implementation.
Add a preview for the builderOne new function.A method in every kind.
Another team owns a kindThey edit your files.They ship their own.
Read everything about validationOne function.Spread across the kinds.
Catch a forgotten kindTypeScript checks the switch; Go doesn’t.Both check each kind has every method.

Use an interface when new kinds keep arriving, especially from people who don’t own the callers. A survey builder that other teams extend is the moment.

Keep the switch when the kinds are fixed and the operations keep growing.

The question I’d leave beside the code is: which will I add more often, new kinds or new operations?

10 / Take the idea with you

Explain the date question without saying “polymorphism.”

“Every function that handled questions checked the type and picked a branch, so a new kind of question meant editing all of them. Now each kind of question carries its own validation, summary, and export, and the code that uses them just asks. The scheduling team added a date question without touching our files. The catch is that adding a new operation now means updating every kind.” In a review, the words are polymorphism, interface, and substitutability.

Before moving on, jot down what a new kind cost in each version, what a new operation cost, and one switch in your own code that more than one team adds cases to.

Connections to follow nextRelated lessons

Take the survey into your editor. Fix strictDateField so it keeps the promise, add a preview() to every kind, and notice how many files that took.

Back to Concepts & practices →