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
// 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.
// 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;
} // 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.
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.
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
dateFieldarrived;problems,summaryReport, andcsvRowdidn’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
problemsis 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.
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.
Brings rules and an input
rules.validate and an Input component.
Takes the same props
question, value, invalid, onchange.
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.
// 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.
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.
| Where you’ve seen it | The interface | Some implementations |
|---|---|---|
for...of | A [Symbol.iterator]() method | Array, String, Map, Set |
addEventListener | EventTarget | Elements, Document, Window, AudioContext |
io.Copy in Go | io.Reader’s Read | *os.File, *strings.Reader, *bytes.Buffer |
fmt.Println in Go | fmt.Stringer’s String | Any type with a String() string method |
| A form in section 05 | InputProps | Text, 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.
MDN · Iteration protocols
A small interface every JavaScript developer relies on, and how to make your own type work
with for...of.
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.
| The change | Switch on the type | Field interface |
|---|---|---|
| Add a date question | A case in three functions. | One new implementation. |
| Add a preview for the builder | One new function. | A method in every kind. |
| Another team owns a kind | They edit your files. | They ship their own. |
| Read everything about validation | One function. | Spread across the kinds. |
| Catch a forgotten kind | TypeScript 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
- Discriminated unions is the switch done well, for kinds that don’t grow.
- Composition over inheritance covers sharing behavior between kinds without a class hierarchy.
- Dependency injection passes an implementation in, so the caller doesn’t choose it either.
- Strategy is polymorphism with one job: swapping a policy.