Generics & Types — Practice Tasks¶
12 exercises that turn weakly-typed code into code where the compiler does the reviewing. Each task gives you a scenario, the loose version, an instruction, and a full solution with the reasoning behind it. The thread running through all of them: push errors from runtime to compile time, and make illegal states unrepresentable. Difficulty climbs from "swap
anyfor a generic" to "redesign a boundary so bad data can't get past the front door."
Languages rotate across TypeScript, Go, Java, and Python so the ideas don't get welded to one type system. The principles are portable; the syntax is incidental.
Table of Contents¶
- Task 1 — Replace
anywith a generic (TypeScript) · Easy - Task 2 — Replace
interface{}with a constrained generic (Go) · Easy - Task 3 — Add a bound to an unbounded generic (Java) · Easy
- Task 4 — Replace a stringly-typed API with typed params (TypeScript) · Medium
- Task 5 — Introduce a branded type:
UserIdvsstring(TypeScript) · Medium - Task 6 — Model illegal states out of existence (TypeScript) · Medium
- Task 7 — Remove a lying
ascast with a type guard (TypeScript) · Medium - Task 8 — Add exhaustiveness checking with
never(TypeScript) · Medium - Task 9 — Replace overloads with one well-typed generic (TypeScript) · Hard
- Task 10 — Parse, don't validate, at a boundary (TypeScript) · Hard
- Task 11 — A newtype with operations in a nominal-by-default language (Go) · Hard
- Task 12 — Type-audit: find every escape hatch and close it (Python) · Hard
How to Use¶
- Read the scenario and the loose code first. Before opening the solution, write down the answer to one question: what bug does the current code allow that a better type would forbid?
- Try the instruction yourself in a real editor with the type checker on (
tsc --strict,go vet,javac,mypy --strict). The whole point is the squiggly red line; you only feel the payoff when the checker rejects the bad call. - Then open the solution and compare. The reasoning matters more than matching the exact code — there's usually more than one defensible design.
- The closing comment in each solution shows a call that used to compile and now doesn't. That line is the deliverable. If your version doesn't reject it, the type isn't doing its job.
Task 1 — Replace any with a generic (TypeScript)¶
Difficulty: Easy
Scenario: A small utility returns the first element of an array. Someone reached for any to "make it work with everything." It does — including in ways that erase every type downstream.
function first(arr: any): any {
return arr[0];
}
const names = ["Ada", "Linus", "Grace"];
const n = first(names); // n: any — TypeScript has given up on it
n.toFixed(2); // compiles. Crashes at runtime: strings have no toFixed.
Instruction: Rewrite first so the return type tracks the element type of the array passed in. No any. Handle the empty-array case honestly in the type.
Solution
function first<T>(arr: readonly T[]): T | undefined {
return arr[0];
}
const names = ["Ada", "Linus", "Grace"];
const n = first(names); // n: string | undefined
// n.toFixed(2); // Compile error: 'toFixed' does not exist on 'string'.
n?.toUpperCase(); // OK once you account for undefined.
Task 2 — Replace interface{} with a constrained generic (Go)¶
Difficulty: Easy
Scenario: Pre-generics Go code summed numbers through interface{} and a type switch. It compiles, but every caller can pass nonsense and only finds out by panicking.
func Sum(values []interface{}) float64 {
var total float64
for _, v := range values {
switch n := v.(type) {
case int:
total += float64(n)
case float64:
total += n
default:
panic("not a number") // discovered at runtime, with real data
}
}
return total
}
// Compiles fine, panics in production:
// Sum([]interface{}{1, 2, "three"})
Instruction: Replace interface{} with a type-parameter constrained to numeric types so non-numbers are rejected at compile time and the panic disappears.
Solution
import "golang.org/x/exp/constraints"
func Sum[T constraints.Integer | constraints.Float](values []T) T {
var total T
for _, v := range values {
total += v
}
return total
}
// Sum([]int{1, 2, 3}) => 6
// Sum([]float64{1.5, 2.5}) => 4.0
// Sum([]string{"a", "b"}) // Compile error: string does not satisfy the constraint.
Task 3 — Add a bound to an unbounded generic (Java)¶
Difficulty: Easy
Scenario: A max helper is generic over T, but the body needs to compare two Ts. With no bound, the only thing the compiler knows about T is that it's an Object, so the author cast their way out — reintroducing exactly the runtime risk generics were meant to remove.
static <T> T max(T a, T b) {
Comparable<T> ca = (Comparable<T>) a; // unchecked cast; warning suppressed and ignored
return ca.compareTo(b) >= 0 ? a : b;
}
// max("apple", "banana") => works
// max(new Object(), new Object()) // compiles, then ClassCastException at runtime
Instruction: Constrain T so that only comparable types can be passed, and delete the cast.
Solution
static <T extends Comparable<? super T>> T max(T a, T b) {
return a.compareTo(b) >= 0 ? a : b;
}
// max("apple", "banana") => "banana"
// max(LocalDate.now(), tomorrow) => tomorrow
// max(new Object(), new Object()); // Compile error: Object is not Comparable.
Task 4 — Replace a stringly-typed API with typed params (TypeScript)¶
Difficulty: Medium
Scenario: An HTTP client takes the method and a couple of options as free-form strings and booleans. Every value is a string, so the compiler can't tell a method from a typo, and the boolean pair encodes a state that should never both be true.
function request(
method: string, // "GET"? "get"? "FETCH"? all accepted
url: string,
cache: string, // "no-store" | "reload" | ...? who knows
retryOnFail: boolean,
throwOnFail: boolean, // both true is contradictory, both false is silent
): Promise<Response> {
// ...
return fetch(url, { method });
}
// All of these compile; some are bugs:
// request("GTE", "/users", "no-stroe", true, true);
Instruction: Replace the stringly-typed and boolean-flag parameters with a typed surface: a union of literal methods, a union for cache mode, and a single field that captures the on-failure behavior as a closed set instead of two contradictory booleans.
Solution
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type CacheMode = "default" | "no-store" | "reload" | "force-cache";
// One axis replaces two booleans whose 4 combinations were only 3 valid:
type OnFailure = "retry" | "throw" | "return-error";
interface RequestOptions {
method: HttpMethod;
url: string;
cache?: CacheMode; // optional, with a sane default
onFailure?: OnFailure;
}
function request(opts: RequestOptions): Promise<Response> {
const { method, url, cache = "default", onFailure = "throw" } = opts;
// ... onFailure drives a switch, not two ifs
return fetch(url, { method, cache });
}
// request({ method: "GTE", url: "/users" });
// Compile error: '"GTE"' is not assignable to HttpMethod.
// request({ method: "GET", url: "/users", cache: "no-stroe" });
// Compile error: '"no-stroe"' is not assignable to CacheMode.
request({ method: "GET", url: "/users", onFailure: "retry" }); // OK
Task 5 — Introduce a branded type: UserId vs string (TypeScript)¶
Difficulty: Medium
Scenario: IDs are passed around as plain strings. Because OrderId and UserId are both string, nothing stops you from passing one where the other is expected — and at runtime they look identical, so the bug is invisible until the wrong record is loaded.
function getUser(userId: string): User { /* ... */ }
function getOrder(orderId: string): Order { /* ... */ }
const userId = "u_123";
const orderId = "o_456";
getUser(orderId); // compiles. Loads nothing or the wrong thing. No warning.
Instruction: Introduce branded (nominal) types UserId and OrderId so the two cannot be swapped, while staying assignable to nothing by accident. Provide a single construction point for each.
Solution
// A brand is a phantom field that exists only at the type level.
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
// The ONLY way to mint one — a parse step that can also validate.
function toUserId(raw: string): UserId {
if (!raw.startsWith("u_")) throw new Error(`Not a user id: ${raw}`);
return raw as UserId; // the single sanctioned cast, behind a check
}
function toOrderId(raw: string): OrderId {
if (!raw.startsWith("o_")) throw new Error(`Not an order id: ${raw}`);
return raw as OrderId;
}
function getUser(userId: UserId): User { /* ... */ }
function getOrder(orderId: OrderId): Order { /* ... */ }
const userId = toUserId("u_123");
const orderId = toOrderId("o_456");
getUser(userId); // OK
// getUser(orderId); Compile error: OrderId not assignable to UserId.
// getUser("u_123"); Compile error: string not assignable to UserId.
Task 6 — Model illegal states out of existence (TypeScript)¶
Difficulty: Medium
Scenario: A remote-data container uses optional fields for every part of its lifecycle. The shape technically allows loading: true and a populated error and data all at once — a state that should be impossible, but the type permits it, so the rendering code is littered with defensive checks.
interface RemoteData<T> {
loading: boolean;
data?: T;
error?: Error;
}
// All of these typecheck, but most are nonsense:
const a: RemoteData<User> = { loading: true, data: someUser, error: new Error() };
const b: RemoteData<User> = { loading: false }; // done? failed? empty? unknowable
Instruction: Redesign RemoteData<T> as a discriminated union so that each lifecycle state carries exactly the fields it can have, and no other. The loading: true state must not be allowed to also carry data or an error.
Solution
type RemoteData<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "failure"; error: Error };
function render(rd: RemoteData<User>): string {
switch (rd.status) {
case "idle": return "Click to load";
case "loading": return "Spinner…";
case "success": return rd.data.name; // rd.data exists ONLY here
case "failure": return rd.error.message; // rd.error exists ONLY here
}
}
// const bad: RemoteData<User> = { status: "loading", data: someUser };
// Compile error: 'data' does not exist on the 'loading' variant.
Task 7 — Remove a lying as cast with a type guard (TypeScript)¶
Difficulty: Medium
Scenario: Data comes back from JSON.parse (type any) and is immediately asserted into a User with as. The assertion is a promise the compiler can't verify; when the payload is malformed, the lie surfaces three function calls away from the cast.
function loadUser(json: string): User {
const parsed = JSON.parse(json); // any
return parsed as User; // "trust me" — verified by nothing
}
// loadUser('{"naem": "Ada"}') // typo'd key; returns a "User" with undefined name.
// Later: user.name.toUpperCase() -> TypeError, far from the real cause.
Instruction: Replace the as User assertion with a real runtime type guard (a value is User predicate) so that a malformed payload is rejected at the parse site with a clear error, and the returned value is genuinely a User.
Solution
interface User {
id: string;
name: string;
age: number;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
typeof (value as Record<string, unknown>).id === "string" &&
typeof (value as Record<string, unknown>).name === "string" &&
typeof (value as Record<string, unknown>).age === "number"
);
}
function loadUser(json: string): User {
const parsed: unknown = JSON.parse(json); // unknown, not any
if (!isUser(parsed)) {
throw new Error("Malformed user payload"); // fails HERE, with context
}
return parsed; // narrowed to User by the guard — no cast needed
}
Task 8 — Add exhaustiveness checking with never (TypeScript)¶
Difficulty: Medium
Scenario: A switch over a union of shapes computes area. It's correct today. Next sprint someone adds a Triangle variant to the Shape union — and this function silently falls through to a wrong default, returning 0 for every triangle. Nothing flags it.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
default: return 0; // swallows any future variant silently
}
}
Instruction: Add a compile-time exhaustiveness check so that adding a new variant to Shape without handling it here becomes a build error rather than a silent 0.
Solution
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "triangle"; base: number; height: number }; // newly added
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle": return Math.PI * shape.radius ** 2;
case "square": return shape.side ** 2;
// case "triangle": return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
// ~~~~~ Compile error while 'triangle' is unhandled:
// Argument of type '{ kind: "triangle"; ... }' is not assignable
// to parameter of type 'never'.
}
}
Task 9 — Replace overloads with one well-typed generic (TypeScript)¶
Difficulty: Hard
Scenario: A getSetting function was written with three overload signatures so callers get the right return type per key. The overloads have drifted out of sync with the implementation, and adding a new setting means editing four places (three signatures + the impl) — a maintenance trap.
function getSetting(key: "theme"): "light" | "dark";
function getSetting(key: "fontSize"): number;
function getSetting(key: "notifications"): boolean;
function getSetting(key: string): unknown { // impl signature, loosely typed
return store[key];
}
// Add a "language" setting → must touch all four lines, or callers get 'unknown'.
Instruction: Collapse the overloads into a single generic signature driven by a settings map type, so the return type is derived from the key automatically and a new setting requires editing only one place.
Solution
interface Settings {
theme: "light" | "dark";
fontSize: number;
notifications: boolean;
language: string; // adding a setting = one line, here.
}
const store: Settings = {
theme: "dark",
fontSize: 14,
notifications: true,
language: "en",
};
function getSetting<K extends keyof Settings>(key: K): Settings[K] {
return store[key];
}
const t = getSetting("theme"); // t: "light" | "dark"
const f = getSetting("fontSize"); // f: number
const l = getSetting("language"); // l: string — no extra overload needed
// getSetting("nope"); // Compile error: not a key of Settings.
Task 10 — Parse, don't validate, at a boundary (TypeScript)¶
Difficulty: Hard
Scenario: A signup handler validates its input with a boolean-returning check, then proceeds to use the raw, still-untyped object. The validation result is thrown away — the type system never learns that the data is now safe, so every downstream function re-checks or, worse, trusts blindly.
function isValidSignup(body: any): boolean {
return typeof body.email === "string" && body.email.includes("@")
&& typeof body.age === "number" && body.age >= 18;
}
function handleSignup(body: any) {
if (!isValidSignup(body)) {
throw new Error("invalid");
}
// body is STILL 'any' here — validation proved nothing to the compiler.
createAccount(body.emial, body.age); // typo 'emial' compiles. Bug ships.
}
Instruction: Refactor to "parse, don't validate": a function that takes unknown and returns a typed Signup (or throws), so that past the parse line the data is statically known-good and the typo is caught.
Solution
interface Signup {
email: string;
age: number;
}
// Parse: unknown in, a typed value out (or an exception).
function parseSignup(body: unknown): Signup {
if (typeof body !== "object" || body === null) {
throw new Error("Body must be an object");
}
const b = body as Record<string, unknown>;
if (typeof b.email !== "string" || !b.email.includes("@")) {
throw new Error("email must be a valid address");
}
if (typeof b.age !== "number" || b.age < 18) {
throw new Error("age must be a number >= 18");
}
return { email: b.email, age: b.age }; // construct the trusted value
}
function handleSignup(body: unknown) {
const signup = parseSignup(body); // signup: Signup — known-good from here on
createAccount(signup.email, signup.age);
// createAccount(signup.emial, ...) // Compile error: no 'emial' on Signup.
}
Task 11 — A newtype with operations in a nominal-by-default language (Go)¶
Difficulty: Hard
Scenario: Temperatures and durations both flow through the code as float64 and int. Nothing stops you adding a Celsius value to a Fahrenheit value, or passing a count of seconds where milliseconds were expected. The units exist only in variable names and comments — invisible to the compiler.
func HeatIndex(tempF float64, humidity float64) float64 { /* ... */ }
func Sleep(ms int) { time.Sleep(time.Duration(ms) * time.Millisecond) }
// All compile; the second is a 1000x bug:
// HeatIndex(celsiusReading, humidity) // wrong unit, looks fine
// Sleep(seconds) // sleeps 1000x too long
Instruction: Introduce distinct newtypes for the units (Celsius, Fahrenheit, and use the standard library's time.Duration for time), with conversion methods, so that mixing units is a compile error and the only way between units is an explicit, named conversion.
Solution
type Celsius float64
type Fahrenheit float64
func (c Celsius) ToFahrenheit() Fahrenheit {
return Fahrenheit(c*9/5 + 32)
}
func (f Fahrenheit) ToCelsius() Celsius {
return Celsius((f - 32) * 5 / 9)
}
func HeatIndex(temp Fahrenheit, humidity float64) float64 { /* ... */ }
// HeatIndex(Celsius(20), 0.5) // Compile error: Celsius is not Fahrenheit.
HeatIndex(Celsius(20).ToFahrenheit(), 0.5) // explicit, correct, readable.
// For time, the standard library already provides the newtype:
func Sleep(d time.Duration) { time.Sleep(d) }
// Sleep(5) // Compile error: int is not time.Duration.
Sleep(5 * time.Second) // unambiguous — and 5*time.Millisecond is just as clear.
Task 12 — Type-audit: find every escape hatch and close it (Python)¶
Difficulty: Hard
Scenario: Below is a plausible service module that "has type hints," yet almost every hint is an escape hatch. List every way the types fail to constrain the code, then sketch the fix for each. This is the capstone — it touches every idea in the chapter.
from typing import Any
class EventProcessor:
def __init__(self, handlers: dict): # dict of what to what?
self.handlers = handlers
def process(self, event: Any) -> Any: # Any in, Any out
kind = event["type"] # event could be anything
handler = self.handlers.get(kind)
if handler:
return handler(event)
return None
def make_user(self, data: dict) -> "User":
return data # type: ignore # lies: returns a dict
def status_code(self, status: str) -> int: # status is a closed set, typed open
return {"ok": 200, "fail": 500}[status] # KeyError on any typo at runtime
Instruction: Produce an audit table naming each type weakness, the bug it permits, and the fix. Then show the corrected status_code and process signatures.
Solution
| # | Weakness | Where | Bug it permits | Fix | |---|----------|-------|----------------|-----| | 1 | Bare `dict` (no params) | `__init__(handlers: dict)` | Any keys, any values; `handlers42)` typechecks | `dict[EventKind, Callable[[Event], Result]]` — key and value types stated | | 2 | `Any` in / `Any` out | `process(event: Any) -> Any` | All type info erased through the call; callers get `Any`, the plague spreads | Type `event` as a `TypedDict`/dataclass union; return a concrete `Result` | | 3 | `event["type"]` on untyped dict | `process` | `KeyError` if absent; value is `Any`, used as a dict key blindly | A `TypedDict` with a `Literal` `type` field, or a discriminated dataclass union | | 4 | `return data` typed as `User` | `make_user` | Returns a `dict` while claiming `User`; `# type: ignore` silences the truth | Construct and return an actual `User`; never `type: ignore` a real mismatch | | 5 | `status: str` for a closed set | `status_code` | `status_code("OK")` (wrong case) or `"okay"` → runtime `KeyError` | `Literal["ok", "fail"]` so typos are compile-time (mypy) errors | | 6 | Dict-indexing as control flow | `status_code` | `KeyError` instead of a typed, total mapping | `Literal` argument makes the dict total over its domain; mypy verifies coverage | **Corrected `status_code` — closed input set via `Literal`:**from typing import Literal
Status = Literal["ok", "fail"]
def status_code(self, status: Status) -> int:
table: dict[Status, int] = {"ok": 200, "fail": 500}
return table[status]
# self.status_code("ok") # OK -> 200
# self.status_code("OK") # mypy error: "OK" not assignable to Literal["ok","fail"]
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class UserCreated:
user_id: str
@dataclass(frozen=True)
class UserDeleted:
user_id: str
Event = UserCreated | UserDeleted # closed, discriminated by class
Result = str # whatever your domain result is
def process(self, event: Event) -> Result:
match event:
case UserCreated(user_id):
return f"created {user_id}"
case UserDeleted(user_id):
return f"deleted {user_id}"
case _:
assert_never(event) # exhaustiveness: mypy flags a new unhandled variant
Self-Assessment¶
Rate yourself on each. If any is a "no," revisit the linked task.
- I can explain why
any/interface{}/Anyis contagious, not merely permissive. (Tasks 1, 2, 12) - Given a generic that casts inside its body, I can add the bound that deletes the cast. (Task 3)
- I reach for a literal union or enum instead of a
stringwhenever the values are a closed set. (Tasks 4, 12) - I can introduce a branded/newtype to stop two same-shaped types from being swapped. (Tasks 5, 11)
- Given an interface with optional fields, I can count representable vs. legal states and collapse it into a discriminated union. (Task 6)
- I never use
as/# type: ignoreto assert a type I haven't actually checked at runtime. (Tasks 7, 12) - I add
never/assert_neverexhaustiveness checks to everyswitch/matchover a union. (Tasks 8, 12) - I can tell when overloads are the right tool versus when a
keyof-driven generic is strictly better. (Task 9) - At every boundary I parse into a typed value rather than validating and discarding the proof. (Tasks 10, 12)
- My mental test for a type is: "what bug does this forbid?" — and if the answer is "none," I tighten it.
Scoring: 9–10 yes → you design types as specifications. 6–8 → solid; drill the boundary tasks (7, 10, 12). ≤5 → re-read the chapter README and redo Tasks 1, 6, and 10 in that order.
Related Topics¶
- Generics & Types — chapter README — the positive rules these tasks invert.
- junior.md — definitions and first examples of each anti-pattern.
- find-bug.md — buggy snippets where a missing or lying type hides the defect.
- optimize.md — tightening loose-but-working types in existing code.
- Functional Programming — discriminated unions, exhaustive matching, and "make illegal states unrepresentable" are core FP ideas; sum types and parse-don't-validate live here too.
- Refactoring — Primitive Obsession and stringly-typed APIs are also code smells; the type-driven fixes here pair with the structural refactorings there.
In this topic