4. Generics
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Code: the files examples/l04_* and errors/l04_*, and the C# and Java sides in compare/ (l04_generics.cs, l04_variance.cs, L04Erasure.java, L04Variance.java) and compare_fail/ (l04_invariant_list.cs, l04_variance_annotation.cs, L04NewT.java, L04Wildcards.java).
The syntax of generics is the one you know, function first<T>(items: T[]): T, and so is the purpose. The differences are in three places, which this lesson takes in order: what exists at run time, how tsc decides that one generic type is assignable to another, and what a type parameter can range over, since in TypeScript it can be the property names of another type.
Type parameters, inference and constraints
Section titled “Type parameters, inference and constraints”import { show } from './show.ts';
// A type parameter, inferred from the argumentfunction first<T>(items: readonly T[]): T | undefined { return items[0];}const note = first(['E', 'A', 'D']); // T is stringconst fret = first([0, 2, 2]); // T is numbershow('note, fret', [note, fret]);show('first<string>([])', first<string>([])); // an explicit type argument
// A constraint: T must have a length, and keeps its own typefunction longest<T extends { length: number }>(a: T, b: T): T { return b.length > a.length ? b : a;}show("longest('capo', 'strings')", longest('capo', 'strings'));show('longest([1, 2], [1, 2, 3])', longest([1, 2], [1, 2, 3]));
// keyof and indexed access: the key is checked, and the result has the type of that propertyinterface Tuning { name: string; notes: string[]; capo: number;}function get<T, K extends keyof T>(value: T, key: K): T[K] { return value[key];}const standard: Tuning = { name: 'standard', notes: ['E', 'A', 'D', 'G', 'B', 'E'], capo: 0 };const notes = get(standard, 'notes'); // string[]const capo = get(standard, 'capo'); // numbershow("get(standard, 'notes').join('')", notes.join(''));show("get(standard, 'capo') + 2", capo + 2);
// A generic type, with a defaultinterface Page<T, Cursor = number> { items: T[]; next?: Cursor;}const page: Page<Tuning> = { items: [standard], next: 2 };const byName: Page<string, string> = { items: ['drop D'], next: 'open G' };show('page.items.length, byName.next', [page.items.length, byName.next]);note, fret [ 'E', 0 ]first<string>([]) undefinedlongest('capo', 'strings') 'strings'longest([1, 2], [1, 2, 3]) [ 1, 2, 3 ]get(standard, 'notes').join('') 'EADGBE'get(standard, 'capo') + 2 2page.items.length, byName.next [ 1, 'open G' ]tscinfersTfrom the arguments, as C# and Java infer a method’s type arguments, and an explicitfirst<string>([])is there when nothing can be inferred.- A constraint,
T extends { length: number }, is written withextends, like Java’s bounds, and can be any type, including a shape: strings and arrays both have alength, andlongestreturns the type it was given,stringornumber[], not{ length: number }. In C#, the same function needs an interface that both types implement (constraints on type parameters). keyof Tis the union ofT’s property names, andT[K]the type of the propertyK.get(standard, 'notes')returns astring[]andget(standard, 'capo')anumber, from the same function: the return type depends on the value of an argument, which C# and Java generics can’t express.- A type parameter can have a default,
Cursor = number, like a C# optional parameter but for types.
The constraints are checked at the call:
function longest<T extends { length: number }>(a: T, b: T): T { return b.length > a.length ? b : a;}interface Tuning { name: string; capo: number;}function get<T, K extends keyof T>(value: T, key: K): T[K] { return value[key];}
const standard: Tuning = { name: 'standard', capo: 0 };console.log(longest(10, 20), longest('capo', [1, 2]));console.log(get(standard, 'nmae'));> npx tsc -p out/tsconfig.l04_constraints.json --prettyerrors/l04_constraints.ts:14:21 - error TS2345: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
14 console.log(longest(10, 20), longest('capo', [1, 2])); ~~
errors/l04_constraints.ts:14:46 - error TS2345: Argument of type 'number[]' is not assignable to parameter of type '"capo"'.
14 console.log(longest(10, 20), longest('capo', [1, 2])); ~~~~~~
errors/l04_constraints.ts:15:27 - error TS2345: Argument of type '"nmae"' is not assignable to parameter of type 'keyof Tuning'.
15 console.log(get(standard, 'nmae')); ~~~~~~
Found 3 errors in the same file, starting at: errors/l04_constraints.ts:14> node errors/l04_constraints.ts10 capoundefinedThe second message shows how inference works: tsc took T from the first argument, the literal type "capo", and then reported the second argument against it, instead of looking for a type that fits both. The third turns a typo into a compile error, which a string key would not. Without tsc, longest(10, 20) returns 10, because (20).length is undefined and undefined > undefined is false, and get reads a property that doesn’t exist.
No T at run time
Section titled “No T at run time”C# generics are reified: the runtime creates a List<int> distinct from a List<string>, and typeof(T), new T() and is T work. Java generics are erased to their bound, and javac inserts casts where a value comes out. TypeScript goes one step further: the whole type is removed, and there is nothing left to cast to.
> dotnet run l04_generics.cs440Int32, default 0FalseTrue> java L04Erasure.javatruecounts: [twelve]counts.get(0): ClassCastExceptionThe C# program creates a Tuner with new T(), prints Int32 for typeof(T), and tells the two list types apart. Java’s ArrayList<Integer> and ArrayList<String> are the same class, and the string "twelve" sits in a List<Integer> until the cast that javac inserted at counts.get(0) fails. In TypeScript, the same code doesn’t compile, and wouldn’t run if it did:
function create<T>(): T { return new T();}function isOf<T>(value: unknown): value is T { return value instanceof T;}
console.log(create<Date>(), isOf<Date>(new Date()));> npx tsc -p out/tsconfig.l04_erasure.json --prettyerrors/l04_erasure.ts:3:14 - error TS2693: 'T' only refers to a type, but is being used as a value here.
3 return new T(); ~
errors/l04_erasure.ts:6:27 - error TS2693: 'T' only refers to a type, but is being used as a value here.
6 return value instanceof T; ~
Found 2 errors in the same file, starting at: errors/l04_erasure.ts:3> node errors/l04_erasure.tserrors/l04_erasure.ts:3 return new T(); ^
ReferenceError: T is not defined
Node.js v24.21.0> javac L04NewT.javaL04NewT.java:4: error: unexpected type return new T(); ^ required: class found: type parameter T where T is a type-variable: T extends Object declared in method <T>create()L04NewT.java:8: error: Object cannot be safely cast to T return value instanceof T; ^ where T is a type-variable: T extends Object declared in method <T>isOf(Object)2 errorsJava refuses new T() too, and allows instanceof T only where the cast can be checked. What replaces them, in both languages, is to pass a value that exists at run time:
import { attempt, show } from './show.ts';
// T appears only in the return type: the caller chooses it, and nothing checks itfunction parse<T>(json: string): T { return JSON.parse(json);}const count = parse<number>('"twelve"');show('typeof count', typeof count);attempt('count.toFixed(1)', () => count.toFixed(1));
// There is no T at run time: pass what the function needs as a value, here a constructorclass Tuner { reference = 440;}function create<T>(ctor: new () => T): T { return new ctor();}show('create(Tuner)', create(Tuner));
// Or a type guard, which carries the check to run timefunction parseArray<T>(json: string, isItem: (value: unknown) => value is T): T[] { const value: unknown = JSON.parse(json); if (!Array.isArray(value) || !value.every(isItem)) throw new TypeError(`not the expected array: ${json}`); return value;}const isNumber = (value: unknown): value is number => typeof value === 'number';show("parseArray('[0, 2, 2]', isNumber)", parseArray('[0, 2, 2]', isNumber));attempt("parseArray('[0, \"2\"]', isNumber)", () => parseArray('[0, "2"]', isNumber));
// GA's musicService.ts, lines 17-23: a generic guard checks the shape, never the Tinterface ApiResponse<T> { success: boolean; data: T;}const isApiResponse = <T>(value: unknown): value is ApiResponse<T> => typeof value === 'object' && value !== null && 'success' in value && 'data' in value;const json: unknown = JSON.parse('{"success": true, "data": "C major"}');if (isApiResponse<string[]>(json)) { attempt('json.data.map((n) => n.length)', () => json.data.map((n) => n.length));}typeof count 'string'count.toFixed(1) TypeError: count.toFixed is not a functioncreate(Tuner) Tuner { reference: 440 }parseArray('[0, 2, 2]', isNumber) [ 0, 2, 2 ]parseArray('[0, "2"]', isNumber) TypeError: not the expected array: [0, "2"]json.data.map((n) => n.length) TypeError: json.data.map is not a functionparse<T>is the most dangerous signature in TypeScript:Tappears only in the return type, so the caller picks it and nothing can check it.parse<number>('"twelve"')returns a string typednumber. It compiles without anasbecauseJSON.parsereturnsany, and it is an assertion all the same, hidden in a generic.create(ctor: new () => T)takes the constructor, a value, where C# would usewhere T : new();Tis inferred from it.parseArray(json, isItem)takes a type guard for the elements. The check runs, and a"2"among the numbers is rejected at the border, the pattern of lesson 3 made generic.
In GuitarAlchemist/ga: a generic guard that doesn’t check its T
Section titled “In GuitarAlchemist/ga: a generic guard that doesn’t check its T”The last part of the example comes from Apps/ga-client/src/services/musicService.ts, lines 17-49, which every call to the music theory API goes through:
const isApiResponse = <T>(value: unknown): value is ApiResponse<T> => { if (!value || typeof value !== 'object') { return false; }
return 'success' in value && 'data' in value;};
const parseJson = async <T>(response: Response): Promise<T> => { // […] an error for a status other than 2xx const json = await response.json(); if (isApiResponse<T>(json)) { // […] an error when success is false or data is null return json.data; }
return json as T;};isApiResponse<T> promises an ApiResponse<T> and checks two property names: T is the caller’s choice, as in parse<T>. When the check fails, json as T returns the body anyway, whatever it is. fetchKeyNotes then returns a Promise<KeyNotes> that holds what the server sent, and a change in the API’s shape shows up as a TypeError in a component, like the one the example printed for json.data.map, far from this function. That’s a common pattern, and a reasonable one when the server and its client are built together; the second exercise writes the version that checks. The same repository’s CourseViewer.tsx, line 152, has function loadQueue<T>(key: string): T[], which returns JSON.parse(raw) from localStorage without even an as, since the any of lesson 2 converts to T[] silently.
Variance
Section titled “Variance”Variance answers one question: if a Guitar is an Instrument, is a list of guitars a list of instruments? A function that takes guitars, a function that takes instruments?
import { attempt, show } from './show.ts';
interface Instrument { name: string;}interface Guitar extends Instrument { strings: number; tune(): string;}const guitar: Guitar = { name: 'guitar', strings: 6, tune: () => 'EADGBE' };const piano: Instrument = { name: 'piano' };
// Arrays are covariant: a Guitar[] is accepted as an Instrument[], and the alias can add a pianoconst guitars: Guitar[] = [guitar];const instruments: Instrument[] = guitars;instruments.push(piano);show('guitars.length', guitars.length);attempt('guitars[1].tune()', () => guitars[1].tune());
// A function property is checked contravariantly (strictFunctionTypes); a method is notinterface WithProperty { play: (instrument: Instrument) => string;}interface WithMethod { play(instrument: Instrument): string;}const tuneGuitar = (g: Guitar) => g.tune();const withMethod: WithMethod = { play: tuneGuitar }; // accepted: method parameters are bivariantattempt('withMethod.play(piano)', () => withMethod.play(piano));const withProperty: WithProperty = { play: (i: Instrument) => i.name }; // a function of Instrument is fineshow('withProperty.play(piano)', withProperty.play(piano));
// Variance annotations: out for a type that only produces T, in for one that only consumes itinterface Source<out T> { next(): T;}interface Sink<in T> { accept(value: T): void;}const guitarSource: Source<Guitar> = { next: () => guitar };const instrumentSource: Source<Instrument> = guitarSource; // out: Source<Guitar> is a Source<Instrument>const names: string[] = [];const instrumentSink: Sink<Instrument> = { accept: (i) => names.push(i.name) };const guitarSink: Sink<Guitar> = instrumentSink; // in: Sink<Instrument> is a Sink<Guitar>guitarSink.accept(guitar);instrumentSink.accept(piano);show('instrumentSource.next().name', instrumentSource.next().name);show('names', names);guitars.length 2guitars[1].tune() TypeError: guitars[1].tune is not a functionwithMethod.play(piano) TypeError: g.tune is not a functionwithProperty.play(piano) 'piano'instrumentSource.next().name 'guitar'names [ 'guitar', 'piano' ]Arrays are covariant, and unchecked. tsc accepts a Guitar[] as an Instrument[], as C# and Java accept arrays, and the alias pushes a piano into the guitars. C# and Java check each write into an array at run time; JavaScript has no element type to check, so the piano gets in, and the error comes later, from the code that trusted guitars[1]:
> dotnet run l04_variance.csinstruments[0] = piano: ArrayTypeMismatchExceptionplaying guitarsequence: Guitar { Name = guitar }> java L04Variance.javaobjects[0] = 440: ArrayStoreExceptionproducer.get(0): EC# generic classes such as List<T> are invariant, and Java’s are too, where TypeScript would compare List<Guitar> and List<Instrument> member by member:
> dotnet run l04_invariant_list.cscompare_fail/l04_invariant_list.cs(4,32): error CS0029: Cannot implicitly convert type 'System.Collections.Generic.List<Guitar>' to 'System.Collections.Generic.List<Instrument>'
The build failed. Fix the build errors and run again.> javac L04Wildcards.javaL04Wildcards.java:7: error: incompatible types: ArrayList<String> cannot be converted to List<Object> List<Object> objects = new ArrayList<String>(); ^1 errorFunction parameters: properties are checked, methods are not. A function that needs a Guitar can’t safely stand in for one that accepts any Instrument: parameters must be contravariant. With strictFunctionTypes, part of strict, tsc checks that for properties whose type is a function, play: (instrument: Instrument) => string. It doesn’t for methods, play(instrument: Instrument): string, whose parameters stay bivariant: withMethod accepts tuneGuitar, and calling it with a piano throws. The exception is deliberate: in the words of the TypeScript 2.6 release notes, methods are excluded “to ensure generic classes and interfaces (such as Array<T>) continue to mostly relate covariantly”, which push(item: T) would otherwise prevent. Write callback types as properties when you want them checked.
Variance annotations. C# declares variance on interfaces and delegates, IEnumerable<out T>, Action<in T>, and checks it (covariance and contravariance). Java declares it where a type is used, List<? extends Object> (wildcards). TypeScript computes it from the structure, and since 4.7 accepts optional out and in annotations, as in C#:
interface Instrument { name: string;}interface Guitar extends Instrument { strings: number; tune(): string;}
interface WithProperty { play: (instrument: Instrument) => string;}const tuneGuitar = (g: Guitar) => g.tune();const withProperty: WithProperty = { play: tuneGuitar };
interface Source<out T> { next(): T;}interface Sink<in T> { accept(value: T): void;}declare const instrumentSource: Source<Instrument>;declare const guitarSink: Sink<Guitar>;const guitarSource: Source<Guitar> = instrumentSource;const instrumentSink: Sink<Instrument> = guitarSink;
// in promises that T is only consumed, and next returns itinterface Mislabeled<in T> { next(): T;}
console.log(withProperty, guitarSource, instrumentSink);> npx tsc -p out/tsconfig.l04_variance.json --prettyerrors/l04_variance.ts:14:38 - error TS2322: Type '(g: Guitar) => string' is not assignable to type '(instrument: Instrument) => string'. Types of parameters 'g' and 'instrument' are incompatible. Type 'Instrument' is missing the following properties from type 'Guitar': strings, tune
14 const withProperty: WithProperty = { play: tuneGuitar }; ~~~~
errors/l04_variance.ts:11:3 - The expected type comes from property 'play' which is declared here on type 'WithProperty' 11 play: (instrument: Instrument) => string; ~~~~
errors/l04_variance.ts:24:7 - error TS2322: Type 'Source<Instrument>' is not assignable to type 'Source<Guitar>'. Type 'Instrument' is missing the following properties from type 'Guitar': strings, tune
24 const guitarSource: Source<Guitar> = instrumentSource; ~~~~~~~~~~~~
errors/l04_variance.ts:25:7 - error TS2322: Type 'Sink<Guitar>' is not assignable to type 'Sink<Instrument>'. Type 'Instrument' is missing the following properties from type 'Guitar': strings, tune
25 const instrumentSink: Sink<Instrument> = guitarSink; ~~~~~~~~~~~~~~
errors/l04_variance.ts:28:22 - error TS2636: Type 'Mislabeled<super-T>' is not assignable to type 'Mislabeled<sub-T>' as implied by variance annotation. The types returned by 'next()' are incompatible between these types. Type 'super-T' is not assignable to type 'sub-T'.
28 interface Mislabeled<in T> { ~~~~
Found 4 errors in the same file, starting at: errors/l04_variance.ts:14The first error is strictFunctionTypes on a property. The next two are the annotations at work: a source of instruments isn’t a source of guitars, and a sink of guitars can’t accept any instrument. The last one catches an annotation that contradicts the structure: in T on an interface that returns T. The same mistake in C#:
> dotnet run l04_variance_annotation.cscompare_fail/l04_variance_annotation.cs(6,17): error CS1961: Invalid variance: The type parameter 'T' must be contravariantly valid on 'IMislabeled<T>.Accept(T)'. 'T' is covariant.
The build failed. Fix the build errors and run again.The C# check is complete, and TypeScript’s isn’t. The opposite mistake, interface Mislabeled<out T> { accept(value: T): void }, compiles with tsc 7.0.2 without a message, because accept is a method and its parameter is bivariant, so T in that position is compatible with both annotations. The handbook is blunt about them: annotations don’t change how types are compared structurally, should be written only when they match the structure, and are useful mainly while debugging a type or, after profiling, to speed up the check of extraordinarily complex types.
The node run of that snippet shows type stripping at work. declare const instrumentSource exists only for tsc and was removed, so line 24 throws a ReferenceError, and the line that Node.js prints has spaces where the type annotation was: types are replaced with whitespace so that line and column numbers stay those of the source (lesson 1).
> node errors/l04_variance.tserrors/l04_variance.ts:24const guitarSource = instrumentSource; ^
ReferenceError: instrumentSource is not defined
Node.js v24.21.0| C# | Java | TypeScript | |
|---|---|---|---|
| Generic types at run time | reified: typeof(T), new T(), is T |
erased to the bound, casts inserted by javac |
erased completely |
| Constraints | where T : IComparable<T>, new() |
<T extends Comparable<T>> |
<T extends Shape>, any type, shapes included |
| Arrays | covariant, writes checked (ArrayTypeMismatchException) |
covariant, writes checked (ArrayStoreException) |
covariant, not checked |
| Generic classes | invariant, variance on interfaces and delegates | invariant, variance at the use site with ? |
structural: computed from the members |
| Function parameters | contravariant | — | contravariant for function-type properties, bivariant for methods |
| Annotations | out T, in T, fully checked |
? extends T, ? super T |
out T, in T, optional, partly checked |
keyof, indexed access and mapped types
Section titled “keyof, indexed access and mapped types”TypeScript types can be computed from other types. keyof and indexed access appeared in get<T, K extends keyof T>; a mapped type goes through the keys of a type and builds a property for each, { [K in keyof T]: … }.
JavaScript lesson 4 looked at GA’s scene options, merged from defaults, a URL and localStorage with Object.assign, which let unknown keys and wrong types through. The typed version needs one validator per option, and a mapped type derives the type of that table from SceneOptions:
import { show } from './show.ts';
interface SceneOptions { stars: boolean; tower: boolean; skyboxMode: 'milky-way' | 'nebula';}
// keyof lists the property names as a union; T[K] is the type of one propertytype OptionName = keyof SceneOptions; // 'stars' | 'tower' | 'skyboxMode'type Skybox = SceneOptions['skyboxMode']; // 'milky-way' | 'nebula'const name: OptionName = 'skyboxMode';const skybox: Skybox = 'nebula';show('name, skybox', [name, skybox]);
// A mapped type builds one property for each key of another typetype Validators<T> = { [K in keyof T]: (value: unknown) => value is T[K];};
const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean';const sceneValidators: Validators<SceneOptions> = { stars: isBoolean, tower: isBoolean, skyboxMode: (value): value is Skybox => value === 'milky-way' || value === 'nebula',};
// One generic merge for every options type: known keys only, valid values only, and the URL lastfunction merge<T extends object>(defaults: T, saved: unknown, validators: Validators<T>): T { const result = { ...defaults }; if (typeof saved !== 'object' || saved === null) return result; for (const key of Object.keys(validators) as (keyof T)[]) { const value: unknown = (saved as Record<PropertyKey, unknown>)[key]; if (Object.hasOwn(saved, key) && validators[key](value)) result[key] = value; } return result;}
const defaults: SceneOptions = { stars: true, tower: false, skyboxMode: 'milky-way' };const saved: unknown = JSON.parse('{"stars":"yes","tower":true,"bloom":3,"skyboxMode":"nebula","__proto__":{"isAdmin":true}}');const state = merge(defaults, saved, sceneValidators);if (new URLSearchParams('?tower=0').has('tower')) state.tower = false;show('state', state);show("'isAdmin' in state", 'isAdmin' in state);
// The library's mapped types: Partial, Readonly, Pick and Recordconst patch: Partial<SceneOptions> = { tower: true };const frozen: Readonly<SceneOptions> = Object.freeze({ ...defaults, ...patch });const toggles: Pick<SceneOptions, 'stars' | 'tower'> = frozen;const labels: Record<Skybox, string> = { 'milky-way': 'Milky Way', nebula: 'Nebula' };show('toggles, labels[frozen.skyboxMode]', [toggles, labels[frozen.skyboxMode]]);name, skybox [ 'skyboxMode', 'nebula' ]state { stars: true, tower: false, skyboxMode: 'nebula' }'isAdmin' in state falsetoggles, labels[frozen.skyboxMode] [ { stars: true, tower: true, skyboxMode: 'milky-way' }, 'Milky Way' ]Validators<SceneOptions>is{ stars: (value: unknown) => value is boolean; tower: …; skyboxMode: (value: unknown) => value is 'milky-way' | 'nebula' }, written once for every options type.mergeis generic overT, andvalidators[key](value)narrowsvaluetoT[keyof T], soresult[key] = valuecompiles. The"stars": "yes"fails its validator, the unknownbloomis never read, and the__proto__key of the JSON, whichJSON.parsecreates as an ordinary property, isn’t in the validators either:isAdmindoesn’t reach the result.- Two assertions remain, and each says why.
Object.keysreturnsstring[], not(keyof T)[], because structural typing lets an object have more keys than its type lists; here the object is the validators table, whose keys are exactly those ofT. Andsavedis only known to be anobject, which has no index signature, so it is read as aRecord<PropertyKey, unknown>, whose values are stillunknown. Partial,Readonly,PickandRecordare mapped types from the standard library (utility types);lib.es5.d.tsdefinesPartial<T>as{ [P in keyof T]?: T[P] }. The last line of the output shows thatReadonlyandPickare views, as in lesson 2:togglesis typed with two properties and holds three.
The type follows the options. Add one, and the validators table is incomplete until someone writes its check:
// A new option: the validators object no longer matches, so tsc points at itinterface SceneOptions { stars: boolean; tower: boolean; skyboxMode: 'milky-way' | 'nebula'; weather: boolean;}type Validators<T> = { [K in keyof T]: (value: unknown) => value is T[K];};
const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean';const sceneValidators: Validators<SceneOptions> = { stars: isBoolean, tower: isBoolean, skyboxMode: (value): value is 'milky-way' | 'nebula' => value === 'milky-way' || value === 'nebula',};console.log(Object.keys(sceneValidators));> npx tsc -p out/tsconfig.l04_mapped.json --prettyerrors/l04_mapped.ts:14:7 - error TS2741: Property 'weather' is missing in type '{ stars: (value: unknown) => value is boolean; tower: (value: unknown) => value is boolean; skyboxMode: (value: unknown) => value is "milky-way" | "nebula"; }' but required in type 'Validators<SceneOptions>'.
14 const sceneValidators: Validators<SceneOptions> = { ~~~~~~~~~~~~~~~
errors/l04_mapped.ts:7:3 - 'weather' is declared here. 7 weather: boolean; ~~~~~~~
Found 1 error in errors/l04_mapped.ts:14C# would reach the same guarantee with a source generator or reflection, and Java with an annotation processor. In TypeScript, the relation between the two types is a type, and tsc checks it on every build.
Key takeaways
Section titled “Key takeaways”tscinfers type arguments from the call, and reports a conflicting argument against what it inferred first; constraints useextendsand can be shapes.keyof TandT[K]let a return type depend on a key passed as a value.- Types are erased entirely: no
new T(), noinstanceof T, notypeof(T). Pass a constructor or a type guard instead. - A
Tthat appears only in the return type,parse<T>(json): T, is an unchecked assertion; so is a generic guard that doesn’t checkT. - Arrays are covariant and unchecked. Function-type properties are checked contravariantly under
strictFunctionTypes; method parameters stay bivariant. inandoutannotations are optional, andtscdoesn’t catch every wrong one.- A mapped type,
{ [K in keyof T]: … }, derives one type from another, and keeps the two in step as the first one changes.
Exercises
Section titled “Exercises”- Write
groupBy(items, keyOf), which groups the elements of an array by the key thatkeyOfreturns, with a return type in which grouping chords by their quality gives the propertiesmajor,minoranddiminished, each possibly absent.
Solution
solutions/l04_ex1_group_by.ts:
function groupBy<T, K extends PropertyKey>(items: readonly T[], keyOf: (item: T) => K): Partial<Record<K, T[]>> { const groups: Partial<Record<K, T[]>> = {}; for (const item of items) { const key = keyOf(item); (groups[key] ??= []).push(item); } return groups;}
interface Chord { name: string; quality: 'major' | 'minor' | 'diminished';}const chords: Chord[] = [ { name: 'C', quality: 'major' }, { name: 'Dm', quality: 'minor' }, { name: 'Em', quality: 'minor' }, { name: 'F', quality: 'major' }, { name: 'Bdim', quality: 'diminished' },];const byQuality = groupBy(chords, (chord) => chord.quality); // K is 'major' | 'minor' | 'diminished'console.log(byQuality.minor?.map((chord) => chord.name));console.log(Object.keys(groupBy(chords, (chord) => chord.name.length)));[ 'Dm', 'Em' ][ '1', '2', '4' ]K extends PropertyKey, which is string | number | symbol, lets K be inferred as the literal union of the qualities, and Partial says that a quality may have no chords, hence byQuality.minor?.map. The second call groups by a number, and prints the keys as strings: JavaScript object keys are strings, and Record<number, …> describes how they are written, not what Object.keys returns. A Map<K, T[]> keeps the numbers; Object.groupBy, in ES2024, returns the same Partial<Record<K, T[]>> as this solution.
- Write
isApiResponseOf(value, isData), a version of GA’sisApiResponsewhoseTis checked, and use it for a response whosedatamust be an array of strings.
Solution
solutions/l04_ex2_api_response.ts:
interface ApiResponse<T> { success: boolean; data: T; error?: string;}type Guard<T> = (value: unknown) => value is T;
// The guard for T is a parameter: the check on data runs, instead of being promisedfunction isApiResponseOf<T>(value: unknown, isData: Guard<T>): value is ApiResponse<T> { return ( typeof value === 'object' && value !== null && 'success' in value && typeof value.success === 'boolean' && 'data' in value && isData(value.data) );}
const isStringArray: Guard<string[]> = (value): value is string[] => Array.isArray(value) && value.every((item) => typeof item === 'string');
for (const text of ['{"success": true, "data": ["C", "E", "G"]}', '{"success": true, "data": "C major"}']) { const json: unknown = JSON.parse(text); if (isApiResponseOf(json, isStringArray)) { console.log('notes:', json.data.join(' ')); } else { console.log('rejected:', text); }}notes: C E Grejected: {"success": true, "data": "C major"}The guard for data is a parameter, so T is inferred from it, and the caller can’t choose a T without providing the check. In parseJson, the fallback json as T would have to become a thrown error: a response that isn’t an ApiResponse of the expected data is an error of the API, and saying so where it happens saves looking for it in a component.
- Write
pick(value, keys), which returns an object with only the listed properties, typed withPick, so that a key that doesn’t exist and a property that wasn’t picked are both compile errors.
Solution
function pick<T extends object, K extends keyof T>(value: T, keys: readonly K[]): Pick<T, K> { const result = {} as Pick<T, K>; // an assertion: the loop below fills every key of K for (const key of keys) { result[key] = value[key]; } return result;}
interface SceneOptions { stars: boolean; tower: boolean; skyboxMode: 'milky-way' | 'nebula';}const options: SceneOptions = { stars: true, tower: false, skyboxMode: 'nebula' };const toggles = pick(options, ['stars', 'tower']); // Pick<SceneOptions, 'stars' | 'tower'>console.log(toggles, Object.keys(toggles));
// Never called: each line shows a mistake that tsc rejectsfunction mistakes() { // @ts-expect-error: 'weather' is not a key of SceneOptions pick(options, ['weather']); // @ts-expect-error: skyboxMode was not picked return toggles.skyboxMode;}console.log(typeof mistakes);{ stars: true, tower: false } [ 'stars', 'tower' ]function{} is not a Pick<T, K> until the loop has run, and tsc can’t follow a loop that fills every key of a union, so the function starts with an assertion and a comment that says what makes it true. That is the usual shape of a generic utility: a small, checked signature outside, and one justified assertion inside.
Sources
Section titled “Sources”- TypeScript handbook — Generics, Keyof type operator, Indexed access types, Mapped types, Utility types
- TSConfig reference — strictFunctionTypes; release notes 2.6 — strict function types, 4.7 — variance annotations
- Microsoft — Generics, Constraints on type parameters, Covariance and contravariance in generics
- The Java Tutorials — Type erasure, Wildcards, Restrictions on generics