├── .gitignore ├── og-image-new.png ├── src ├── helpers │ ├── Brand.ts │ └── type-utils.ts ├── fake-external-lib │ ├── index.ts │ ├── nonNullable.ts │ ├── typePredicates.ts │ └── fetches.ts ├── 02-globals │ ├── 10-challenge-custom-jsx-element.problem.tsx │ ├── 10-challenge-custom-jsx-element.solution.tsx │ ├── 09-custom-theme-object.problem.2.ts │ ├── 09-custom-theme-object.solution.2.ts │ ├── 08-adding-to-process-env.solution.ts │ ├── 08-adding-to-process-env.problem.ts │ ├── 07-add-to-window.problem.ts │ ├── 09-custom-theme-object.solution.1.ts │ ├── 06-add-function-to-global-scope.solution.ts │ ├── 06-add-function-to-global-scope.problem.ts │ ├── 07-add-to-window.solution.ts │ └── 10-custom-theme-object.problem.1.ts ├── 01-branded-types │ ├── 01-what-is-a-branded-type.explanation.ts │ ├── 03-reusable-valid-brand.problem.ts │ ├── 03-reusable-valid-brand.solution.ts │ ├── 02-entity-fetching.problem.ts │ ├── 02-entity-fetching.solution.ts │ ├── 05-index-signatures.problem.ts │ ├── 05-index-signatures.solution.ts │ ├── 01-form-validation.solution.ts │ ├── 01-form-validation.problem.ts │ ├── 04-currency-conversion.problem.ts │ └── 04-currency-conversion.solution.ts ├── 03-type-predicates-assertion-functions │ ├── 11-type-predicates-with-filter.problem.ts │ ├── 11-type-predicates-with-filter.solution.1.ts │ ├── 11-type-predicates-with-filter.solution.2.ts │ ├── 15-brands-and-type-predicates.problem.ts │ ├── 16-brands-and-assertion-functions.problem.ts │ ├── 12-assertion-functions.solution.ts │ ├── 15-brands-and-type-predicates.solution.ts │ ├── 16-brands-and-assertion-functions.solution.ts │ ├── 12-assertion-functions.problem.ts │ ├── 13-typescripts-worst-error.solution.ts │ ├── 13-typescripts-worst-error.problem.ts │ ├── 14-type-predicates-with-generics.problem.ts │ └── 14-type-predicates-with-generics.solution.ts ├── 06-identity-functions │ ├── 30-no-generics-on-objects.solution.1.ts │ ├── 30-no-generics-on-objects.problem.ts │ ├── 30-no-generics-on-objects.solution.2.ts │ ├── 27-as-const-alternative.solution.ts │ ├── 29-finite-state-machine.solution.ts │ ├── 28-constraints-with-f.narrow.problem.ts │ ├── 28-constraints-with-f.narrow.solution.ts │ ├── 29-finite-state-machine.problem.ts │ └── 27-as-const-alternative.problem.ts ├── 04-builder-pattern │ ├── 17-reusable-context.solution.ts │ ├── 19-type-safe-map.solution.ts │ ├── 18-working-around-partial-inference.solution.ts │ ├── 17-reusable-context.problem.ts │ ├── 20-importance-of-default-generic.problem.ts │ ├── 19-type-safe-map.problem.ts │ ├── 18-working-around-partial-inference.problem.ts │ ├── 20-importance-of-default-generic.solution.ts │ ├── 21-dynamic-middleware.solution.ts │ └── 21-dynamic-middleware.problem.ts ├── 05-external-libraries │ ├── 22-extract-external-lib-types.solution.ts │ ├── 23-wrap-external-lib.problem.ts │ ├── 22-extract-external-lib-types.problem.ts │ ├── 26-usage-with-zod.solution.3.ts │ ├── 26-usage-with-zod.solution.4.ts │ ├── 26-usage-with-zod.solution.2.ts │ ├── 26-usage-with-zod.solution.1.ts │ ├── 24-lodash-groupby.problem.ts │ ├── 24-lodash-groupby.solution.ts │ ├── 25-usage-with-express.solution.ts │ ├── 26-usage-with-zod.problem.ts │ └── 25-usage-with-express.problem.ts └── 07-challenges │ ├── 31-narrow-with-arrays.problem.ts │ ├── 31-narrow-with-arrays.solution.ts │ ├── 32-dynamic-reducer.solution.ts │ └── 32-dynamic-reducer.problem.ts ├── vite.config.ts ├── scripts ├── script.js ├── setup.ts └── exercise.js ├── package.json ├── README.md ├── notes └── FUTURE.md └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode -------------------------------------------------------------------------------- /og-image-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Phonbopit/advanced-patterns-workshop/main/og-image-new.png -------------------------------------------------------------------------------- /src/helpers/Brand.ts: -------------------------------------------------------------------------------- 1 | declare const brand: unique symbol; 2 | export type Brand = T & { [brand]: TBrand }; 3 | -------------------------------------------------------------------------------- /src/fake-external-lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./typePredicates"; 2 | export * from "./fetches"; 3 | export * from "./nonNullable"; 4 | -------------------------------------------------------------------------------- /src/fake-external-lib/nonNullable.ts: -------------------------------------------------------------------------------- 1 | export const isNonNullable = (value: T): value is NonNullable => 2 | value !== null && value !== undefined; 3 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ["src/**/*.ts"], 6 | setupFiles: ["scripts/setup.ts"], 7 | passWithNoTests: true, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /src/fake-external-lib/typePredicates.ts: -------------------------------------------------------------------------------- 1 | export const isDivElement = (element: unknown): element is HTMLDivElement => { 2 | return element instanceof HTMLDivElement; 3 | }; 4 | 5 | export const isBodyElement = (element: unknown): element is HTMLBodyElement => { 6 | return element instanceof HTMLBodyElement; 7 | }; 8 | -------------------------------------------------------------------------------- /scripts/script.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require("child_process"); 2 | 3 | const result = execSync("ls -R src").toString(); 4 | 5 | console.log( 6 | result 7 | .trim() 8 | .split("\n") 9 | .filter(Boolean) 10 | .filter((line) => line.endsWith(".ts") || line.endsWith(":")) 11 | .join("\n"), 12 | ); 13 | -------------------------------------------------------------------------------- /src/02-globals/10-challenge-custom-jsx-element.problem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | /** 4 | * How do we add a new base element to React's JSX? 5 | * 6 | * You'll need to do some detective work: check 7 | * out JSX.IntrinsicElements. 8 | */ 9 | 10 | const element = hello world; 11 | -------------------------------------------------------------------------------- /src/fake-external-lib/fetches.ts: -------------------------------------------------------------------------------- 1 | export const fetchUser = async (id: string) => { 2 | return { 3 | id, 4 | firstName: "John", 5 | lastName: "Doe", 6 | }; 7 | }; 8 | 9 | export const fetchPost = async (id: string) => { 10 | return { 11 | id, 12 | title: "Hello World", 13 | body: "This is a post that is great and is excessively long, much too long for an excerpt.", 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/01-branded-types/01-what-is-a-branded-type.explanation.ts: -------------------------------------------------------------------------------- 1 | import { Brand } from "../helpers/Brand"; 2 | 3 | type Password = Brand; 4 | type Email = Brand; 5 | 6 | const password = "1231423" as Password; 7 | 8 | const email = "mpocock@me.com" as Email; 9 | 10 | let passwordSlot: Password; 11 | 12 | // @ts-expect-error 13 | passwordSlot = email; 14 | 15 | passwordSlot = password; 16 | -------------------------------------------------------------------------------- /src/02-globals/10-challenge-custom-jsx-element.solution.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | /** 4 | * As a bonus, how do we make sure that it has 5 | * some required props? 6 | */ 7 | 8 | declare global { 9 | namespace JSX { 10 | interface IntrinsicElements { 11 | "custom-solution-element": {}; 12 | } 13 | } 14 | } 15 | 16 | const element = hello world; 17 | -------------------------------------------------------------------------------- /scripts/setup.ts: -------------------------------------------------------------------------------- 1 | import { fetch } from "cross-fetch"; 2 | 3 | global.fetch = fetch; 4 | 5 | let storage = {} as any; 6 | 7 | global.localStorage = { 8 | getItem: (key: string) => storage[key], 9 | setItem: (key: string, value: string) => { 10 | storage[key] = value; 11 | }, 12 | removeItem: (key: string) => { 13 | delete storage[key]; 14 | }, 15 | } as Storage; 16 | 17 | if (!process) { 18 | process = {} as any; 19 | } 20 | 21 | if (!process.env) { 22 | process.env = {} as any; 23 | } 24 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/11-type-predicates-with-filter.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | export const values = ["a", "b", undefined, "c", undefined]; 5 | 6 | const filteredValues = values.filter((value) => Boolean(value)); 7 | 8 | it("Should filter out the undefined values", () => { 9 | expect(filteredValues).toEqual(["a", "b", "c"]); 10 | }); 11 | 12 | it('Should be of type "string[]"', () => { 13 | type test1 = Expect>; 14 | }); 15 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/11-type-predicates-with-filter.solution.1.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | export const values = ["a", "b", undefined, "c", undefined]; 5 | 6 | const filteredValues = values.filter((value) => Boolean(value)) as string[]; 7 | 8 | it("Should filter out the undefined values", () => { 9 | expect(filteredValues).toEqual(["a", "b", "c"]); 10 | }); 11 | 12 | it('Should be of type "string[]"', () => { 13 | type test1 = Expect>; 14 | }); 15 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/11-type-predicates-with-filter.solution.2.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | export const values = ["a", "b", undefined, "c", undefined]; 5 | 6 | const filteredValues = values.filter((value): value is string => 7 | Boolean(value), 8 | ); 9 | 10 | it("Should filter out the undefined values", () => { 11 | expect(filteredValues).toEqual(["a", "b", "c"]); 12 | }); 13 | 14 | it('Should be of type "string[]"', () => { 15 | type test1 = Expect>; 16 | }); 17 | -------------------------------------------------------------------------------- /src/02-globals/09-custom-theme-object.problem.2.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | /** 4 | * How do we add a LOG_OUT and UPDATE_USERNAME events to the 5 | * DispatchableEvent interface from WITHIN 6 | * this file? 7 | */ 8 | 9 | const handler = (event: UnionOfDispatchableEvents) => { 10 | switch (event.type) { 11 | case "LOG_OUT": 12 | console.log("LOG_OUT"); 13 | break; 14 | case "UPDATE_USERNAME": 15 | console.log(event.username); 16 | break; 17 | } 18 | }; 19 | 20 | it("Should be able to handle LOG_OUT and UPDATE_USERNAME events", () => { 21 | handler({ type: "LOG_OUT" }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/06-identity-functions/30-no-generics-on-objects.solution.1.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This solution works, but means we need to specify the routes 3 | * TWICE - once in the routes array, and once in the generic 4 | * we pass to ConfigObj 5 | */ 6 | 7 | interface ConfigObj { 8 | routes: TRoute[]; 9 | fetchers: { 10 | [K in TRoute]?: () => any; 11 | }; 12 | } 13 | 14 | export const configObj: ConfigObj<"/" | "/about" | "/contact"> = { 15 | routes: ["/", "/about", "/contact"], 16 | fetchers: { 17 | // @ts-expect-error 18 | "/does-not-exist": () => { 19 | return {}; 20 | }, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/02-globals/09-custom-theme-object.solution.2.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | 3 | declare global { 4 | interface DispatchableEventSolution { 5 | LOG_OUT: {}; 6 | UPDATE_USERNAME: { username: string }; 7 | } 8 | } 9 | 10 | const handler = (event: UnionOfDispatchableEventsSolution) => { 11 | switch (event.type) { 12 | case "LOG_OUT": 13 | console.log("LOG_OUT"); 14 | break; 15 | case "UPDATE_USERNAME": 16 | console.log(event.username); 17 | break; 18 | } 19 | }; 20 | 21 | it("Should be able to handle LOG_OUT and UPDATE_USERNAME events", () => { 22 | handler({ type: "LOG_OUT" }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/02-globals/08-adding-to-process-env.solution.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | declare global { 5 | namespace NodeJS { 6 | interface ProcessEnv { 7 | MY_SOLUTION_ENV_VAR: string; 8 | } 9 | } 10 | } 11 | 12 | process.env.MY_SOLUTION_ENV_VAR = "Hello, world!"; 13 | 14 | it("Should be declared as a string", () => { 15 | expect(process.env.MY_SOLUTION_ENV_VAR).toEqual("Hello, world!"); 16 | }); 17 | 18 | it("Should NOT have undefined in the type", () => { 19 | const myVar = process.env.MY_SOLUTION_ENV_VAR; 20 | type tests = [Expect>]; 21 | }); 22 | -------------------------------------------------------------------------------- /src/06-identity-functions/30-no-generics-on-objects.problem.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * fetchers is an object where you can optionally 3 | * pass keys that match the route names. 4 | * 5 | * BUT - how do we prevent the user from passing 6 | * fetchers that don't exist in the routes array? 7 | * 8 | * We'll need to change this to a function which takes 9 | * in the config as an argument. 10 | * 11 | * Desired API: 12 | * 13 | * const config = makeConfigObj(config); 14 | */ 15 | 16 | export const configObj = { 17 | routes: ["/", "/about", "/contact"], 18 | fetchers: { 19 | // @ts-expect-error 20 | "/does-not-exist": () => { 21 | return {}; 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/06-identity-functions/30-no-generics-on-objects.solution.2.ts: -------------------------------------------------------------------------------- 1 | interface ConfigObj { 2 | routes: TRoute[]; 3 | fetchers: { 4 | [K in TRoute]?: () => any; 5 | }; 6 | } 7 | 8 | /** 9 | * The solution is to use an identity function containing 10 | * a generic, which will capture the names of the routes and 11 | * allow the user to specify the fetchers. 12 | */ 13 | const makeConfigObj = (config: ConfigObj) => 14 | config; 15 | 16 | export const configObj = makeConfigObj({ 17 | routes: ["/", "/about", "/contact"], 18 | fetchers: { 19 | // @ts-expect-error 20 | "/does-not-exist": () => { 21 | return {}; 22 | }, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/02-globals/08-adding-to-process-env.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | /** 5 | * Clues: 6 | * 7 | * 1. You'll need declare global again 8 | * 9 | * 2. You'll need to use the NodeJS namespace 10 | * 11 | * 3. Inside the NodeJS namespace, you'll need to add a 12 | * MY_SOLUTION_ENV_VAR property to the ProcessEnv interface 13 | */ 14 | 15 | process.env.MY_ENV_VAR = "Hello, world!"; 16 | 17 | it("Should be declared as a string", () => { 18 | expect(process.env.MY_ENV_VAR).toEqual("Hello, world!"); 19 | }); 20 | 21 | it("Should NOT have undefined in the type", () => { 22 | const myVar = process.env.MY_ENV_VAR; 23 | type tests = [Expect>]; 24 | }); 25 | -------------------------------------------------------------------------------- /src/02-globals/07-add-to-window.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | /** 5 | * Clues: 6 | * 7 | * 1. You'll need declare global again 8 | * 9 | * 2. Inside declare global, you'll need to modify the Window 10 | * interface to add a makeGreeting function 11 | */ 12 | 13 | window.makeGreeting = () => "Hello!"; 14 | 15 | it("Should let you call makeGreeting from the window object", () => { 16 | expect(window.makeGreeting()).toBe("Hello, world!"); 17 | 18 | type test1 = Expect string>>; 19 | }); 20 | 21 | it("Should not be available on globalThis", () => { 22 | expect( 23 | // @ts-expect-error 24 | globalThis.makeGreeting, 25 | ).toBe(undefined); 26 | }); 27 | -------------------------------------------------------------------------------- /src/04-builder-pattern/17-reusable-context.solution.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | 3 | const makeUseStyled = () => { 4 | const useStyled = (func: (theme: TTheme) => CSSProperties) => { 5 | // Imagine that this function hooks into a global theme 6 | // and returns the CSSProperties 7 | return {} as CSSProperties; 8 | }; 9 | 10 | return useStyled; 11 | }; 12 | 13 | const useStyled = makeUseStyled(); 14 | 15 | interface MyTheme { 16 | color: { 17 | primary: string; 18 | }; 19 | fontSize: { 20 | small: string; 21 | }; 22 | } 23 | 24 | const buttonStyle = useStyled((theme) => ({ 25 | color: theme.color.primary, 26 | fontSize: theme.fontSize.small, 27 | })); 28 | 29 | const divStyle = useStyled((theme) => ({ 30 | backgroundColor: theme.color.primary, 31 | })); 32 | -------------------------------------------------------------------------------- /src/02-globals/09-custom-theme-object.solution.1.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | declare global { 4 | interface DispatchableEventSolution { 5 | LOG_IN: { 6 | username: string; 7 | password: string; 8 | }; 9 | } 10 | type UnionOfDispatchableEventsSolution = { 11 | [K in keyof DispatchableEventSolution]: { 12 | type: K; 13 | } & DispatchableEventSolution[K]; 14 | }[keyof DispatchableEventSolution]; 15 | } 16 | 17 | const dispatchEvent = (event: UnionOfDispatchableEventsSolution) => { 18 | // Imagine that this function dispatches this event 19 | // to a global handler 20 | }; 21 | 22 | it("Should be able to dispatch a LOG_IN and LOG_OUT event", () => { 23 | dispatchEvent({ type: "LOG_IN", username: "username", password: "password" }); 24 | dispatchEvent({ type: "LOG_OUT" }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/06-identity-functions/27-as-const-alternative.solution.ts: -------------------------------------------------------------------------------- 1 | import { F } from "ts-toolbelt"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | export const asConst = (t: F.Narrow) => t; 5 | 6 | /** 7 | * Now, fruits is typed as: 8 | * [{ name: "apple"; price: 1 }, { name: "banana"; price: 2 }] 9 | * 10 | * Try changing the argument to asConst to see how it affects 11 | * the type. 12 | */ 13 | const fruits = asConst([ 14 | { 15 | name: "apple", 16 | price: 1, 17 | }, 18 | { 19 | name: "banana", 20 | price: 2, 21 | }, 22 | ]); 23 | 24 | type tests = [ 25 | Expect< 26 | Equal< 27 | typeof fruits, 28 | [ 29 | { 30 | name: "apple"; 31 | price: 1; 32 | }, 33 | { 34 | name: "banana"; 35 | price: 2; 36 | }, 37 | ] 38 | > 39 | >, 40 | ]; 41 | -------------------------------------------------------------------------------- /src/02-globals/06-add-function-to-global-scope.solution.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | /** 5 | * Because declare global works in multiple files, I've changed 6 | * the name of the variables/functions to avoid a conflict with 7 | * the other file 8 | */ 9 | declare global { 10 | function mySolutionFunc(): boolean; 11 | var mySolutionVar: number; 12 | } 13 | 14 | globalThis.mySolutionFunc = () => true; 15 | globalThis.mySolutionVar = 1; 16 | 17 | it("Should let you call myFunc without it being imported", () => { 18 | expect(mySolutionFunc()).toBe(true); 19 | type test1 = Expect boolean>>; 20 | }); 21 | 22 | it("Should let you access myVar without it being imported", () => { 23 | expect(mySolutionVar).toBe(1); 24 | type test1 = Expect>; 25 | }); 26 | -------------------------------------------------------------------------------- /src/02-globals/06-add-function-to-global-scope.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | /** 5 | * Clues: 6 | * 7 | * 1. declare global will be needed: 8 | * 9 | * declare global {} 10 | * 11 | * 2. myFunc will need to be added to the global scope using 'function': 12 | * 13 | * function myFunc(): boolean 14 | * 15 | * 3. myVar will need to be added to the global scope using 'var': 16 | * 17 | * var myVar: number 18 | */ 19 | 20 | globalThis.myFunc = () => true; 21 | globalThis.myVar = 1; 22 | 23 | it("Should let you call myFunc without it being imported", () => { 24 | expect(myFunc()).toBe(true); 25 | type test1 = Expect boolean>>; 26 | }); 27 | 28 | it("Should let you access myVar without it being imported", () => { 29 | expect(myVar).toBe(1); 30 | type test1 = Expect>; 31 | }); 32 | -------------------------------------------------------------------------------- /src/02-globals/07-add-to-window.solution.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | /** 5 | * Clues: 6 | * 7 | * 1. You'll need declare global again 8 | * 9 | * 2. Inside declare global, you'll need to modify the Window 10 | * interface to add a makeGreetingSolution function 11 | */ 12 | declare global { 13 | interface Window { 14 | makeGreetingSolution: () => string; 15 | } 16 | } 17 | 18 | window.makeGreetingSolution = () => "Hello!"; 19 | 20 | it("Should let you call makeGreetingSolution from the window object", () => { 21 | expect(window.makeGreetingSolution()).toBe("Hello, world!"); 22 | 23 | type test1 = Expect string>>; 24 | }); 25 | 26 | it("Should not be available on globalThis", () => { 27 | expect( 28 | // @ts-expect-error 29 | globalThis.makeGreetingSolution, 30 | ).toBe(undefined); 31 | }); 32 | -------------------------------------------------------------------------------- /src/05-external-libraries/22-extract-external-lib-types.solution.ts: -------------------------------------------------------------------------------- 1 | import { fetchUser } from "fake-external-lib"; 2 | import { Equal, Expect, ExpectExtends } from "../helpers/type-utils"; 3 | 4 | type ParametersOfFetchUser = Parameters; 5 | 6 | type ReturnTypeOfFetchUserWithFullName = Awaited< 7 | ReturnType 8 | > & { fullName: string }; 9 | 10 | export const fetchUserWithFullName = async ( 11 | ...args: ParametersOfFetchUser 12 | ): Promise => { 13 | const user = await fetchUser(...args); 14 | return { 15 | ...user, 16 | fullName: `${user.firstName} ${user.lastName}`, 17 | }; 18 | }; 19 | 20 | type tests = [ 21 | Expect>, 22 | Expect< 23 | ExpectExtends< 24 | ReturnTypeOfFetchUserWithFullName, 25 | { id: string; firstName: string; lastName: string; fullName: string } 26 | > 27 | >, 28 | ]; 29 | -------------------------------------------------------------------------------- /src/06-identity-functions/29-finite-state-machine.solution.ts: -------------------------------------------------------------------------------- 1 | import { F } from "ts-toolbelt"; 2 | 3 | interface FSMConfig { 4 | initial: F.NoInfer; 5 | states: Record< 6 | TState, 7 | { 8 | onEntry?: () => void; 9 | } 10 | >; 11 | } 12 | 13 | export const makeFiniteStateMachine = ( 14 | config: FSMConfig, 15 | ) => config; 16 | 17 | const config = makeFiniteStateMachine({ 18 | initial: "a", 19 | states: { 20 | a: { 21 | onEntry: () => { 22 | console.log("a"); 23 | }, 24 | }, 25 | // b should be allowed to be specified! 26 | b: {}, 27 | }, 28 | }); 29 | 30 | const config2 = makeFiniteStateMachine({ 31 | // c should not be allowed! It doesn't exist on the states below 32 | // @ts-expect-error 33 | initial: "c", 34 | states: { 35 | a: {}, 36 | // b should be allowed to be specified! 37 | b: {}, 38 | }, 39 | }); 40 | -------------------------------------------------------------------------------- /src/07-challenges/31-narrow-with-arrays.problem.ts: -------------------------------------------------------------------------------- 1 | // import { F } from 'ts-toolbelt'; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | interface Fruit { 5 | name: string; 6 | price: number; 7 | } 8 | 9 | export const wrapFruit = (fruits: unknown[]) => { 10 | const getFruit = (name: unknown): unknown => { 11 | return fruits.find((fruit) => fruit.name === name); 12 | }; 13 | 14 | return { 15 | getFruit, 16 | }; 17 | }; 18 | 19 | const fruits = wrapFruit([ 20 | { 21 | name: "apple", 22 | price: 1, 23 | }, 24 | { 25 | name: "banana", 26 | price: 2, 27 | }, 28 | ]); 29 | 30 | const banana = fruits.getFruit("banana"); 31 | const apple = fruits.getFruit("apple"); 32 | // @ts-expect-error 33 | const notAllowed = fruits.getFruit("not-allowed"); 34 | 35 | type tests = [ 36 | Expect>, 37 | Expect>, 38 | ]; 39 | -------------------------------------------------------------------------------- /src/05-external-libraries/23-wrap-external-lib.problem.ts: -------------------------------------------------------------------------------- 1 | import { fetchUser, fetchPost } from "fake-external-lib"; 2 | 3 | // UNFINISHED 4 | 5 | type AddAdditionalParamsToFunc< 6 | TFunc extends (...args: any[]) => Promise, 7 | TAdditionalParams, 8 | > = ( 9 | ...args: Parameters 10 | ) => Promise> & TAdditionalParams>; 11 | 12 | export const fetchUserWithFullName: AddAdditionalParamsToFunc< 13 | typeof fetchUser, 14 | { fullName: string } 15 | > = async (...args) => { 16 | const user = await fetchUser(...args); 17 | return { 18 | ...user, 19 | fullName: `${user.firstName} ${user.lastName}`, 20 | }; 21 | }; 22 | 23 | export const FetchPostWithExcerpt: AddAdditionalParamsToFunc< 24 | typeof fetchPost, 25 | { excerpt: string } 26 | > = async (...args) => { 27 | const post = await fetchPost(...args); 28 | return { 29 | ...post, 30 | excerpt: post.body.slice(0, 50) + "...", 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-generics-workshop", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Matt Pocock ", 6 | "license": "GPL-2.0", 7 | "devDependencies": { 8 | "@types/node": "^18.6.5", 9 | "chokidar": "^3.5.3", 10 | "cross-fetch": "^3.1.5", 11 | "typescript": "^4.8.3", 12 | "vitest": "^0.21.1" 13 | }, 14 | "scripts": { 15 | "exercise": "node scripts/exercise.js", 16 | "e": "npm run exercise", 17 | "solution": "cross-env SOLUTION=true node scripts/exercise.js", 18 | "s": "npm run solution" 19 | }, 20 | "dependencies": { 21 | "@types/express": "^4.17.14", 22 | "@types/lodash": "^4.14.186", 23 | "@types/react": "^18.0.21", 24 | "cross-env": "^7.0.3", 25 | "express": "^4.18.1", 26 | "fast-glob": "^3.2.12", 27 | "lodash": "^4.17.21", 28 | "react": "^18.2.0", 29 | "ts-toolbelt": "^9.6.0", 30 | "zod": "^3.19.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/06-identity-functions/28-constraints-with-f.narrow.problem.ts: -------------------------------------------------------------------------------- 1 | import { F } from "ts-toolbelt"; 2 | import { it } from "vitest"; 3 | import { Equal, Expect } from "../helpers/type-utils"; 4 | 5 | export const narrowFruits = ( 6 | t: F.Narrow, 7 | ) => t; 8 | 9 | const fruits = narrowFruits([ 10 | { 11 | name: "apple", 12 | price: 1, 13 | }, 14 | { 15 | name: "banana", 16 | price: 2, 17 | }, 18 | ]); 19 | 20 | type tests = [ 21 | Expect< 22 | Equal< 23 | typeof fruits, 24 | [ 25 | { 26 | name: "apple"; 27 | price: 1; 28 | }, 29 | { 30 | name: "banana"; 31 | price: 2; 32 | }, 33 | ] 34 | > 35 | >, 36 | ]; 37 | 38 | it("Should ONLY let you pass an array of fruits", () => { 39 | const notAllowed = narrowFruits([ 40 | // @ts-expect-error 41 | "not allowed", 42 | ]); 43 | }); 44 | -------------------------------------------------------------------------------- /src/06-identity-functions/28-constraints-with-f.narrow.solution.ts: -------------------------------------------------------------------------------- 1 | import { F } from "ts-toolbelt"; 2 | import { it } from "vitest"; 3 | import { Equal, Expect } from "../helpers/type-utils"; 4 | 5 | export const narrowFruits = ( 6 | t: F.Narrow, 7 | ) => t; 8 | 9 | const fruits = narrowFruits([ 10 | { 11 | name: "apple", 12 | price: 1, 13 | }, 14 | { 15 | name: "banana", 16 | price: 2, 17 | }, 18 | ]); 19 | 20 | type tests = [ 21 | Expect< 22 | Equal< 23 | typeof fruits, 24 | [ 25 | { 26 | name: "apple"; 27 | price: 1; 28 | }, 29 | { 30 | name: "banana"; 31 | price: 2; 32 | }, 33 | ] 34 | > 35 | >, 36 | ]; 37 | 38 | it("Should ONLY let you pass an array of fruits", () => { 39 | const notAllowed = narrowFruits([ 40 | // @ts-expect-error 41 | "not allowed", 42 | ]); 43 | }); 44 | -------------------------------------------------------------------------------- /src/05-external-libraries/22-extract-external-lib-types.problem.ts: -------------------------------------------------------------------------------- 1 | import { fetchUser } from "fake-external-lib"; 2 | import { Equal, Expect, ExpectExtends } from "../helpers/type-utils"; 3 | 4 | /** 5 | * We're using a function from fake-external lib, but we need 6 | * to extend the types. Extract the types below. 7 | */ 8 | 9 | type ParametersOfFetchUser = unknown; 10 | 11 | type ReturnTypeOfFetchUserWithFullName = unknown; 12 | 13 | export const fetchUserWithFullName = async ( 14 | ...args: ParametersOfFetchUser 15 | ): Promise => { 16 | const user = await fetchUser(...args); 17 | return { 18 | ...user, 19 | fullName: `${user.firstName} ${user.lastName}`, 20 | }; 21 | }; 22 | 23 | type tests = [ 24 | Expect>, 25 | Expect< 26 | ExpectExtends< 27 | { id: string; firstName: string; lastName: string; fullName: string }, 28 | ReturnTypeOfFetchUserWithFullName 29 | > 30 | >, 31 | ]; 32 | -------------------------------------------------------------------------------- /src/05-external-libraries/26-usage-with-zod.solution.3.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { z } from "zod"; 3 | 4 | const makeZodSafeFunction = ( 5 | schema: TSchema, 6 | func: (arg: z.infer) => TResult, 7 | ) => { 8 | return (arg: z.infer) => { 9 | const result = schema.parse(arg); 10 | return func(result); 11 | }; 12 | }; 13 | 14 | const addTwoNumbersArg = z.object({ 15 | a: z.number(), 16 | b: z.number(), 17 | }); 18 | 19 | const addTwoNumbers = makeZodSafeFunction( 20 | addTwoNumbersArg, 21 | (args) => args.a + args.b, 22 | ); 23 | 24 | it("Should error on the type level AND the runtime if you pass incorrect params", () => { 25 | expect(() => 26 | addTwoNumbers( 27 | // @ts-expect-error 28 | { a: 1, badParam: 3 }, 29 | ), 30 | ).toThrow(); 31 | }); 32 | 33 | it("Should succeed if you pass the correct type", () => { 34 | expect(addTwoNumbers({ a: 1, b: 2 })).toBe(3); 35 | }); 36 | -------------------------------------------------------------------------------- /src/05-external-libraries/26-usage-with-zod.solution.4.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { z } from "zod"; 3 | 4 | const makeZodSafeFunction = ( 5 | schema: TSchema, 6 | func: (arg: z.output) => TResult, 7 | ) => { 8 | return (arg: z.output) => { 9 | const result = schema.parse(arg); 10 | return func(result); 11 | }; 12 | }; 13 | 14 | const addTwoNumbersArg = z.object({ 15 | a: z.number(), 16 | b: z.number(), 17 | }); 18 | 19 | const addTwoNumbers = makeZodSafeFunction( 20 | addTwoNumbersArg, 21 | (args) => args.a + args.b, 22 | ); 23 | 24 | it("Should error on the type level AND the runtime if you pass incorrect params", () => { 25 | expect(() => 26 | addTwoNumbers( 27 | // @ts-expect-error 28 | { a: 1, badParam: 3 }, 29 | ), 30 | ).toThrow(); 31 | }); 32 | 33 | it("Should succeed if you pass the correct type", () => { 34 | expect(addTwoNumbers({ a: 1, b: 2 })).toBe(3); 35 | }); 36 | -------------------------------------------------------------------------------- /src/05-external-libraries/26-usage-with-zod.solution.2.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { z } from "zod"; 3 | 4 | const makeZodSafeFunction = ( 5 | schema: TSchema, 6 | func: (arg: TSchema["_output"]) => TResult, 7 | ) => { 8 | return (arg: TSchema["_output"]) => { 9 | const result = schema.parse(arg); 10 | return func(result); 11 | }; 12 | }; 13 | 14 | const addTwoNumbersArg = z.object({ 15 | a: z.number(), 16 | b: z.number(), 17 | }); 18 | 19 | const addTwoNumbers = makeZodSafeFunction( 20 | addTwoNumbersArg, 21 | (args) => args.a + args.b, 22 | ); 23 | 24 | it("Should error on the type level AND the runtime if you pass incorrect params", () => { 25 | expect(() => 26 | addTwoNumbers( 27 | // @ts-expect-error 28 | { a: 1, badParam: 3 }, 29 | ), 30 | ).toThrow(); 31 | }); 32 | 33 | it("Should succeed if you pass the correct type", () => { 34 | expect(addTwoNumbers({ a: 1, b: 2 })).toBe(3); 35 | }); 36 | -------------------------------------------------------------------------------- /src/06-identity-functions/29-finite-state-machine.problem.ts: -------------------------------------------------------------------------------- 1 | // import { F } from "ts-toolbelt"; 2 | 3 | /** 4 | * Clue: F.NoInfer is part of the solution! 5 | */ 6 | interface FSMConfig { 7 | initial: TState; 8 | states: Record< 9 | TState, 10 | { 11 | onEntry?: () => void; 12 | } 13 | >; 14 | } 15 | 16 | export const makeFiniteStateMachine = ( 17 | config: FSMConfig, 18 | ) => config; 19 | 20 | const config = makeFiniteStateMachine({ 21 | initial: "a", 22 | states: { 23 | a: { 24 | onEntry: () => { 25 | console.log("a"); 26 | }, 27 | }, 28 | // b should be allowed to be specified! 29 | b: {}, 30 | }, 31 | }); 32 | 33 | const config2 = makeFiniteStateMachine({ 34 | // c should not be allowed! It doesn't exist on the states below 35 | // @ts-expect-error 36 | initial: "c", 37 | states: { 38 | a: {}, 39 | // b should be allowed to be specified! 40 | b: {}, 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /src/04-builder-pattern/19-type-safe-map.solution.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | class TypeSafeStringMap = {}> { 4 | private map: TMap; 5 | constructor() { 6 | this.map = {} as TMap; 7 | } 8 | 9 | get(key: keyof TMap): string { 10 | return this.map[key]; 11 | } 12 | 13 | set( 14 | key: K, 15 | value: string, 16 | ): TypeSafeStringMap> { 17 | (this.map[key] as any) = value; 18 | 19 | return this; 20 | } 21 | } 22 | 23 | const map = new TypeSafeStringMap() 24 | .set("matt", "pocock") 25 | .set("jools", "holland") 26 | .set("brandi", "carlile"); 27 | 28 | it("Should not allow getting values which do not exist", () => { 29 | map.get( 30 | // @ts-expect-error 31 | "jim", 32 | ); 33 | }); 34 | 35 | it("Should return values from keys which do exist", () => { 36 | expect(map.get("matt")).toBe("pocock"); 37 | expect(map.get("jools")).toBe("holland"); 38 | expect(map.get("brandi")).toBe("carlile"); 39 | }); 40 | -------------------------------------------------------------------------------- /src/04-builder-pattern/18-working-around-partial-inference.solution.ts: -------------------------------------------------------------------------------- 1 | import { Equal, Expect } from "../helpers/type-utils"; 2 | 3 | export const makeSelectors = 4 | () => 5 | any>>( 6 | selectors: TSelectors, 7 | ) => { 8 | return selectors; 9 | }; 10 | 11 | interface Source { 12 | firstName: string; 13 | middleName: string; 14 | lastName: string; 15 | } 16 | 17 | const selectors = makeSelectors()({ 18 | getFullName: (source) => 19 | `${source.firstName} ${source.middleName} ${source.lastName}`, 20 | getFirstAndLastName: (source) => `${source.firstName} ${source.lastName}`, 21 | getFirstNameLength: (source) => source.firstName.length, 22 | }); 23 | 24 | type tests = [ 25 | Expect string>>, 26 | Expect< 27 | Equal string> 28 | >, 29 | Expect< 30 | Equal number> 31 | >, 32 | ]; 33 | -------------------------------------------------------------------------------- /src/04-builder-pattern/17-reusable-context.problem.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | 3 | /** 4 | * In this implementation, we need to specify the theme 5 | * inside useStyled wherever we use it. This is not ideal. 6 | * 7 | * See if you can refactor useStyled into a function called 8 | * makeUseStyled which returns a useStyled function, typed 9 | * with the theme. 10 | * 11 | * Desired API: 12 | * 13 | * const useStyled = makeUseStyled(); 14 | */ 15 | const useStyled = (func: (theme: TTheme) => CSSProperties) => { 16 | // Imagine that this function hooks into a global theme 17 | // and returns the CSSProperties 18 | return {} as CSSProperties; 19 | }; 20 | 21 | interface MyTheme { 22 | color: { 23 | primary: string; 24 | }; 25 | fontSize: { 26 | small: string; 27 | }; 28 | } 29 | 30 | const buttonStyle = useStyled((theme) => ({ 31 | color: theme.color.primary, 32 | fontSize: theme.fontSize.small, 33 | })); 34 | 35 | const divStyle = useStyled((theme) => ({ 36 | backgroundColor: theme.color.primary, 37 | })); 38 | -------------------------------------------------------------------------------- /src/07-challenges/31-narrow-with-arrays.solution.ts: -------------------------------------------------------------------------------- 1 | import { F } from "ts-toolbelt"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | interface Fruit { 5 | name: string; 6 | price: number; 7 | } 8 | 9 | export const wrapFruit = ( 10 | fruits: F.Narrow, 11 | ) => { 12 | const getFruit = ( 13 | name: TName, 14 | ): Extract => { 15 | return fruits.find((fruit) => fruit.name === name) as any; 16 | }; 17 | 18 | return { 19 | getFruit, 20 | }; 21 | }; 22 | 23 | const fruits = wrapFruit([ 24 | { 25 | name: "apple", 26 | price: 1, 27 | }, 28 | { 29 | name: "banana", 30 | price: 2, 31 | }, 32 | ]); 33 | 34 | const banana = fruits.getFruit("banana"); 35 | const apple = fruits.getFruit("apple"); 36 | // @ts-expect-error 37 | const notAllowed = fruits.getFruit("not-allowed"); 38 | 39 | type tests = [ 40 | Expect>, 41 | Expect>, 42 | ]; 43 | -------------------------------------------------------------------------------- /src/02-globals/10-custom-theme-object.problem.1.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | /** 4 | * Here, we've actually got _multiple_ problem files! 5 | * Make sure to to check problem.2.ts too. 6 | */ 7 | 8 | declare global { 9 | interface DispatchableEvent { 10 | LOG_IN: { 11 | username: string; 12 | password: string; 13 | }; 14 | } 15 | 16 | /** 17 | * This type converts the DispatchableEvent 18 | * interface into a union: 19 | * 20 | * { type: 'LOG_IN'; username: string; password: string; } 21 | */ 22 | type UnionOfDispatchableEvents = { 23 | [K in keyof DispatchableEvent]: { 24 | type: K; 25 | } & DispatchableEvent[K]; 26 | }[keyof DispatchableEvent]; 27 | } 28 | 29 | const dispatchEvent = (event: UnionOfDispatchableEvents) => { 30 | // Imagine that this function dispatches this event 31 | // to a global handler 32 | }; 33 | 34 | it("Should be able to dispatch a LOG_IN and LOG_OUT event", () => { 35 | dispatchEvent({ type: "LOG_IN", username: "username", password: "password" }); 36 | dispatchEvent({ type: "LOG_OUT" }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/15-brands-and-type-predicates.problem.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type Valid = Brand; 5 | 6 | interface PasswordValues { 7 | password: string; 8 | confirmPassword: string; 9 | } 10 | 11 | const isValidPassword = (values: PasswordValues) => { 12 | if (values.password !== values.confirmPassword) { 13 | return false; 14 | } 15 | return true; 16 | }; 17 | 18 | const createUserOnApi = (values: Valid) => { 19 | // Imagine this function creates the user on the API 20 | }; 21 | 22 | it("Should fail if you do not validate the values before calling createUserOnApi", () => { 23 | const onSubmitHandler = (values: PasswordValues) => { 24 | // @ts-expect-error 25 | createUserOnApi(values); 26 | }; 27 | }); 28 | 29 | it("Should succeed if you DO validate the values before calling createUserOnApi", () => { 30 | const onSubmitHandler = (values: PasswordValues) => { 31 | if (isValidPassword(values)) { 32 | createUserOnApi(values); 33 | } 34 | }; 35 | }); 36 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/16-brands-and-assertion-functions.problem.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type Valid = Brand; 5 | 6 | interface PasswordValues { 7 | password: string; 8 | confirmPassword: string; 9 | } 10 | 11 | function assertIsValidPassword(values: PasswordValues) { 12 | if (values.password !== values.confirmPassword) { 13 | throw new Error("Password is invalid"); 14 | } 15 | } 16 | 17 | const createUserOnApi = (values: Valid) => { 18 | // Imagine this function creates the user on the API 19 | }; 20 | 21 | it("Should fail if you do not validate the passwords before calling createUserOnApi", () => { 22 | const onSubmitHandler = (values: PasswordValues) => { 23 | // @ts-expect-error 24 | createUserOnApi(values); 25 | }; 26 | }); 27 | 28 | it("Should succeed if you DO validate the passwords before calling createUserOnApi", () => { 29 | const onSubmitHandler = (values: PasswordValues) => { 30 | assertIsValidPassword(values); 31 | createUserOnApi(values); 32 | }; 33 | }); 34 | -------------------------------------------------------------------------------- /src/01-branded-types/03-reusable-valid-brand.problem.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type Valid = unknown; 5 | 6 | interface PasswordValues { 7 | password: string; 8 | confirmPassword: string; 9 | } 10 | 11 | const validatePassword = (values: PasswordValues) => { 12 | if (values.password !== values.confirmPassword) { 13 | throw new Error("Passwords do not match"); 14 | } 15 | 16 | return values; 17 | }; 18 | 19 | const createUserOnApi = (values: Valid) => { 20 | // Imagine this function creates the user on the API 21 | }; 22 | 23 | it("Should fail if you do not validate the values before calling createUserOnApi", () => { 24 | const onSubmitHandler = (values: PasswordValues) => { 25 | // @ts-expect-error 26 | createUserOnApi(values); 27 | }; 28 | }); 29 | 30 | it("Should succeed if you DO validate the values before calling createUserOnApi", () => { 31 | const onSubmitHandler = (values: PasswordValues) => { 32 | const validatedValues = validatePassword(values); 33 | createUserOnApi(validatedValues); 34 | }; 35 | }); 36 | -------------------------------------------------------------------------------- /src/06-identity-functions/27-as-const-alternative.problem.ts: -------------------------------------------------------------------------------- 1 | import { Equal, Expect } from "../helpers/type-utils"; 2 | 3 | /** 4 | * This is an identity function. It takes a value and returns the same value. 5 | * Except that it doesn't work great on arrays, or object values. 6 | * 7 | * Below, you can see that fruits is typed as 8 | * 9 | * { name: string; price: number }[] 10 | * 11 | * instead of [{ name: "apple"; price: 1 }, { name: "banana"; price: 2 }] 12 | * 13 | * We could handle this using 'as const', but sometimes that isn't possible. 14 | * 15 | * So, we can use F.Narrow from ts-toolbelt instead. 16 | */ 17 | export const asConst = (t: T) => t; 18 | 19 | const fruits = asConst([ 20 | { 21 | name: "apple", 22 | price: 1, 23 | }, 24 | { 25 | name: "banana", 26 | price: 2, 27 | }, 28 | ]); 29 | 30 | type tests = [ 31 | Expect< 32 | Equal< 33 | typeof fruits, 34 | [ 35 | { 36 | name: "apple"; 37 | price: 1; 38 | }, 39 | { 40 | name: "banana"; 41 | price: 2; 42 | }, 43 | ] 44 | > 45 | >, 46 | ]; 47 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/12-assertion-functions.solution.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | interface User { 5 | id: string; 6 | name: string; 7 | } 8 | 9 | interface AdminUser extends User { 10 | role: "admin"; 11 | organisations: string[]; 12 | } 13 | 14 | interface NormalUser extends User { 15 | role: "normal"; 16 | } 17 | 18 | function assertUserIsAdmin( 19 | user: NormalUser | AdminUser, 20 | ): asserts user is AdminUser { 21 | if (user.role !== "admin") { 22 | throw new Error("Not an admin user"); 23 | } 24 | } 25 | 26 | it("Should throw an error when it encounters a normal user", () => { 27 | const user: NormalUser = { 28 | id: "user_1", 29 | name: "Miles", 30 | role: "normal", 31 | }; 32 | 33 | expect(() => assertUserIsAdmin(user)).toThrow(); 34 | }); 35 | 36 | it("Should assert that the type is an admin user after it has been validated", () => { 37 | const example = (user: NormalUser | AdminUser) => { 38 | assertUserIsAdmin(user); 39 | 40 | type tests = [Expect>]; 41 | }; 42 | }); 43 | -------------------------------------------------------------------------------- /src/05-external-libraries/26-usage-with-zod.solution.1.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { z } from "zod"; 3 | 4 | const makeZodSafeFunction = ( 5 | schema: TSchema, 6 | func: (arg: TSchema["_type"]) => TResult, 7 | ) => { 8 | return (arg: TSchema["_type"]) => { 9 | const result = schema.parse(arg); 10 | return func(result); 11 | }; 12 | }; 13 | 14 | /** 15 | * You should be able to edit the object below, 16 | * and see the args inside the makeZodSafeFunction 17 | * call change. 18 | */ 19 | const addTwoNumbersArg = z.object({ 20 | a: z.number(), 21 | b: z.number(), 22 | }); 23 | 24 | const addTwoNumbers = makeZodSafeFunction( 25 | addTwoNumbersArg, 26 | (args) => args.a + args.b, 27 | // ^ 🕵️‍♂️ 28 | ); 29 | 30 | it("Should error on the type level AND the runtime if you pass incorrect params", () => { 31 | expect(() => 32 | addTwoNumbers( 33 | // @ts-expect-error 34 | { a: 1, badParam: 3 }, 35 | ), 36 | ).toThrow(); 37 | }); 38 | 39 | it("Should succeed if you pass the correct type", () => { 40 | expect(addTwoNumbers({ a: 1, b: 2 })).toBe(3); 41 | }); 42 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/15-brands-and-type-predicates.solution.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type Valid = Brand; 5 | 6 | interface PasswordValues { 7 | password: string; 8 | confirmPassword: string; 9 | } 10 | 11 | const isValidPassword = ( 12 | values: PasswordValues, 13 | ): values is Valid => { 14 | if (values.password !== values.confirmPassword) { 15 | return false; 16 | } 17 | return true; 18 | }; 19 | 20 | const createUserOnApi = (values: Valid) => { 21 | // Imagine this function creates the user on the API 22 | }; 23 | 24 | it("Should fail if you do not validate the passwords before calling createUserOnApi", () => { 25 | const onSubmitHandler = (values: PasswordValues) => { 26 | // @ts-expect-error 27 | createUserOnApi(values); 28 | }; 29 | }); 30 | 31 | it("Should succeed if you DO validate the passwords before calling createUserOnApi", () => { 32 | const onSubmitHandler = (values: PasswordValues) => { 33 | if (isValidPassword(values)) { 34 | createUserOnApi(values); 35 | } 36 | }; 37 | }); 38 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/16-brands-and-assertion-functions.solution.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type Valid = Brand; 5 | 6 | interface PasswordValues { 7 | password: string; 8 | confirmPassword: string; 9 | } 10 | 11 | function assertIsValidPassword( 12 | values: PasswordValues, 13 | ): asserts values is Valid { 14 | if (values.password !== values.confirmPassword) { 15 | throw new Error("Password is invalid"); 16 | } 17 | } 18 | 19 | const createUserOnApi = (values: Valid) => { 20 | // Imagine this function creates the user on the API 21 | }; 22 | 23 | it("Should fail if you do not validate the passwords before calling createUserOnApi", () => { 24 | const onSubmitHandler = (values: PasswordValues) => { 25 | // @ts-expect-error 26 | createUserOnApi(values); 27 | }; 28 | }); 29 | 30 | it("Should succeed if you DO validate the passwords before calling createUserOnApi", () => { 31 | const onSubmitHandler = (values: PasswordValues) => { 32 | assertIsValidPassword(values); 33 | createUserOnApi(values); 34 | }; 35 | }); 36 | -------------------------------------------------------------------------------- /src/01-branded-types/03-reusable-valid-brand.solution.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type Valid = Brand; 5 | 6 | interface PasswordValues { 7 | password: string; 8 | confirmPassword: string; 9 | } 10 | 11 | const validatePassword = (values: PasswordValues): Valid => { 12 | if (values.password !== values.confirmPassword) { 13 | throw new Error("Passwords do not match"); 14 | } 15 | 16 | return values as Valid; 17 | }; 18 | 19 | const createUserOnApi = (values: Valid) => { 20 | // Imagine this function creates the user on the API 21 | }; 22 | 23 | it("Should fail if you do not validate the values before calling createUserOnApi", () => { 24 | const onSubmitHandler = (values: PasswordValues) => { 25 | // @ts-expect-error 26 | createUserOnApi(values); 27 | }; 28 | }); 29 | 30 | it("Should succeed if you DO validate the values before calling createUserOnApi", () => { 31 | const onSubmitHandler = (values: PasswordValues) => { 32 | const validatedValues = validatePassword(values); 33 | createUserOnApi(validatedValues); 34 | }; 35 | }); 36 | -------------------------------------------------------------------------------- /src/01-branded-types/02-entity-fetching.problem.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type UserId = Brand; 5 | type PostId = Brand; 6 | 7 | interface User { 8 | id: UserId; 9 | name: string; 10 | } 11 | 12 | interface Post { 13 | id: PostId; 14 | title: string; 15 | content: string; 16 | } 17 | 18 | const db: { users: User[]; posts: Post[] } = { 19 | users: [ 20 | { 21 | id: "1" as UserId, 22 | name: "Miles", 23 | }, 24 | ], 25 | posts: [ 26 | { 27 | id: "1" as PostId, 28 | title: "Hello world", 29 | content: "This is my first post", 30 | }, 31 | ], 32 | }; 33 | 34 | const getUserById = (id: string) => { 35 | return db.users.find((user) => user.id === id); 36 | }; 37 | 38 | const getPostById = (id: string) => { 39 | return db.posts.find((post) => post.id === id); 40 | }; 41 | 42 | it("Should only let you get a user by id with a user id", () => { 43 | const postId = "1" as PostId; 44 | 45 | // @ts-expect-error 46 | getUserById(postId); 47 | }); 48 | 49 | it("Should only let you get a post by id with a PostId", () => { 50 | const userId = "1" as UserId; 51 | 52 | // @ts-expect-error 53 | getPostById(userId); 54 | }); 55 | -------------------------------------------------------------------------------- /src/01-branded-types/02-entity-fetching.solution.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type UserId = Brand; 5 | type PostId = Brand; 6 | 7 | interface User { 8 | id: UserId; 9 | name: string; 10 | } 11 | 12 | interface Post { 13 | id: PostId; 14 | title: string; 15 | content: string; 16 | } 17 | 18 | const db: { users: User[]; posts: Post[] } = { 19 | users: [ 20 | { 21 | id: "1" as UserId, 22 | name: "Miles", 23 | }, 24 | ], 25 | posts: [ 26 | { 27 | id: "1" as PostId, 28 | title: "Hello world", 29 | content: "This is my first post", 30 | }, 31 | ], 32 | }; 33 | 34 | const getUserById = (id: UserId) => { 35 | return db.users.find((user) => user.id === id); 36 | }; 37 | 38 | const getPostById = (id: PostId) => { 39 | return db.posts.find((post) => post.id === id); 40 | }; 41 | 42 | it("Should only let you get a user by id with a user id", () => { 43 | const postId = "1" as PostId; 44 | 45 | // @ts-expect-error 46 | getUserById(postId); 47 | }); 48 | 49 | it("Should only let you get a post by id with a PostId", () => { 50 | const userId = "1" as UserId; 51 | 52 | // @ts-expect-error 53 | getPostById(userId); 54 | }); 55 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/12-assertion-functions.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | interface User { 5 | id: string; 6 | name: string; 7 | } 8 | 9 | interface AdminUser extends User { 10 | role: "admin"; 11 | organisations: string[]; 12 | } 13 | 14 | interface NormalUser extends User { 15 | role: "normal"; 16 | } 17 | 18 | /** 19 | * Clue - check the docs on assertion functions: 20 | * https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions 21 | */ 22 | function assertUserIsAdmin(user: NormalUser | AdminUser) { 23 | if (user.role !== "admin") { 24 | throw new Error("Not an admin user"); 25 | } 26 | } 27 | 28 | it("Should throw an error when it encounters a normal user", () => { 29 | const user: NormalUser = { 30 | id: "user_1", 31 | name: "Miles", 32 | role: "normal", 33 | }; 34 | 35 | expect(() => assertUserIsAdmin(user)).toThrow(); 36 | }); 37 | 38 | it("Should assert that the type is an admin user after it has been validated", () => { 39 | const example = (user: NormalUser | AdminUser) => { 40 | assertUserIsAdmin(user); 41 | 42 | type tests = [Expect>]; 43 | }; 44 | }); 45 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/13-typescripts-worst-error.solution.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | interface User { 5 | id: string; 6 | name: string; 7 | } 8 | 9 | interface AdminUser extends User { 10 | role: "admin"; 11 | organisations: string[]; 12 | } 13 | 14 | interface NormalUser extends User { 15 | role: "normal"; 16 | } 17 | 18 | function assertUserIsAdmin( 19 | user: NormalUser | AdminUser, 20 | ): asserts user is AdminUser { 21 | if (user.role !== "admin") { 22 | throw new Error("Not an admin user"); 23 | } 24 | } 25 | 26 | it("Should throw an error when it encounters a normal user", () => { 27 | const user: NormalUser = { 28 | id: "user_1", 29 | name: "Miles", 30 | role: "normal", 31 | }; 32 | 33 | expect(() => assertUserIsAdmin(user)).toThrow(); 34 | }); 35 | 36 | it("Should assert that the type is an admin user after it has been validated", () => { 37 | const example = (user: NormalUser | AdminUser) => { 38 | /** 39 | * The fix is to make assertUserIsAdmin a function, 40 | * not an arrow function. Lord above. 41 | */ 42 | assertUserIsAdmin(user); 43 | 44 | type tests = [Expect>]; 45 | }; 46 | }); 47 | -------------------------------------------------------------------------------- /src/04-builder-pattern/20-importance-of-default-generic.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | /** 4 | * I've made a small change to the solution of the previous problem 5 | * which breaks it. Can you spot what it is? 6 | * 7 | * Clue: it's somewhere inside class TypeSafeStringMap, and it's 8 | * on the type level - not the runtime level. 9 | */ 10 | class TypeSafeStringMap> { 11 | private map: TMap; 12 | constructor() { 13 | this.map = {} as TMap; 14 | } 15 | 16 | get(key: keyof TMap): string { 17 | return this.map[key]; 18 | } 19 | 20 | set( 21 | key: K, 22 | value: string, 23 | ): TypeSafeStringMap> { 24 | (this.map[key] as any) = value; 25 | 26 | return this; 27 | } 28 | } 29 | 30 | const map = new TypeSafeStringMap() 31 | .set("matt", "pocock") 32 | .set("jools", "holland") 33 | .set("brandi", "carlile"); 34 | 35 | it("Should not allow getting values which do not exist", () => { 36 | map.get( 37 | // @ts-expect-error 38 | "jim", 39 | ); 40 | }); 41 | 42 | it("Should return values from keys which do exist", () => { 43 | expect(map.get("matt")).toBe("pocock"); 44 | expect(map.get("jools")).toBe("holland"); 45 | expect(map.get("brandi")).toBe("carlile"); 46 | }); 47 | -------------------------------------------------------------------------------- /src/01-branded-types/05-index-signatures.problem.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | import { Equal, Expect } from "../helpers/type-utils"; 4 | 5 | type PostId = Brand; 6 | type UserId = Brand; 7 | 8 | interface User { 9 | id: UserId; 10 | name: string; 11 | } 12 | 13 | interface Post { 14 | id: PostId; 15 | title: string; 16 | } 17 | 18 | // Change this type definition! 19 | const db: Record = {}; 20 | 21 | it("Should let you add users and posts to the db by their id", () => { 22 | const postId = "post_1" as PostId; 23 | const userId = "user_1" as UserId; 24 | 25 | db[postId] = { 26 | id: postId, 27 | title: "Hello world", 28 | }; 29 | 30 | db[userId] = { 31 | id: userId, 32 | name: "Miles", 33 | }; 34 | 35 | const post = db[postId]; 36 | const user = db[userId]; 37 | 38 | type tests = [ 39 | Expect>, 40 | Expect>, 41 | ]; 42 | }); 43 | 44 | it("Should fail if you try to add a user under a post id", () => { 45 | const postId = "post_1" as PostId; 46 | const userId = "user_1" as UserId; 47 | 48 | const user: User = { 49 | id: userId, 50 | name: "Miles", 51 | }; 52 | 53 | // @ts-expect-error 54 | db[postId] = user; 55 | }); 56 | -------------------------------------------------------------------------------- /src/01-branded-types/05-index-signatures.solution.ts: -------------------------------------------------------------------------------- 1 | import { it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | import { Equal, Expect } from "../helpers/type-utils"; 4 | 5 | type PostId = Brand; 6 | type UserId = Brand; 7 | 8 | interface User { 9 | id: UserId; 10 | name: string; 11 | } 12 | 13 | interface Post { 14 | id: PostId; 15 | title: string; 16 | } 17 | 18 | const db: { 19 | [id: PostId]: Post; 20 | [id: UserId]: User; 21 | } = {}; 22 | 23 | it("Should let you add users and posts to the db by their id", () => { 24 | const postId = "post_1" as PostId; 25 | const userId = "user_1" as UserId; 26 | 27 | db[postId] = { 28 | id: postId, 29 | title: "Hello world", 30 | }; 31 | 32 | db[userId] = { 33 | id: userId, 34 | name: "Miles", 35 | }; 36 | 37 | const post = db[postId]; 38 | const user = db[userId]; 39 | 40 | type tests = [ 41 | Expect>, 42 | Expect>, 43 | ]; 44 | }); 45 | 46 | it("Should fail if you try to add a user under a post id", () => { 47 | const postId = "post_1" as PostId; 48 | const userId = "user_1" as UserId; 49 | 50 | const user: User = { 51 | id: userId, 52 | name: "Miles", 53 | }; 54 | 55 | // @ts-expect-error 56 | db[postId] = user; 57 | }); 58 | -------------------------------------------------------------------------------- /src/05-external-libraries/24-lodash-groupby.problem.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { expect, it } from "vitest"; 3 | import { doNotExecute, Equal, Expect } from "../helpers/type-utils"; 4 | 5 | const groupByAge = (array: unknown[]) => { 6 | const grouped = _.groupBy(array, "age"); 7 | 8 | return grouped; 9 | }; 10 | 11 | const result = groupByAge([ 12 | { 13 | name: "John", 14 | age: 20, 15 | }, 16 | { 17 | name: "Jane", 18 | age: 20, 19 | }, 20 | { 21 | name: "Mary", 22 | age: 30, 23 | }, 24 | ]); 25 | 26 | it("Should group the items by age", () => { 27 | expect(result).toEqual({ 28 | 20: [ 29 | { 30 | name: "John", 31 | age: 20, 32 | }, 33 | { 34 | name: "Jane", 35 | age: 20, 36 | }, 37 | ], 38 | 30: [ 39 | { 40 | name: "Mary", 41 | age: 30, 42 | }, 43 | ], 44 | }); 45 | 46 | type tests = [ 47 | Expect>>, 48 | ]; 49 | }); 50 | 51 | it("Should not let you pass in an array of objects NOT containing age", () => { 52 | doNotExecute(() => { 53 | groupByAge([ 54 | { 55 | // @ts-expect-error 56 | name: "John", 57 | }, 58 | { 59 | // @ts-expect-error 60 | name: "Bill", 61 | }, 62 | ]); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/13-typescripts-worst-error.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | interface User { 5 | id: string; 6 | name: string; 7 | } 8 | 9 | interface AdminUser extends User { 10 | role: "admin"; 11 | organisations: string[]; 12 | } 13 | 14 | interface NormalUser extends User { 15 | role: "normal"; 16 | } 17 | 18 | const assertUserIsAdmin = ( 19 | user: NormalUser | AdminUser, 20 | ): asserts user is AdminUser => { 21 | if (user.role !== "admin") { 22 | throw new Error("Not an admin user"); 23 | } 24 | }; 25 | 26 | it("Should throw an error when it encounters a normal user", () => { 27 | const user: NormalUser = { 28 | id: "user_1", 29 | name: "Miles", 30 | role: "normal", 31 | }; 32 | 33 | expect(() => assertUserIsAdmin(user)).toThrow(); 34 | }); 35 | 36 | it("Should assert that the type is an admin user after it has been validated", () => { 37 | const example = (user: NormalUser | AdminUser) => { 38 | /** 39 | * Why is this error happening? 40 | * 41 | * Note: PLEASE DON'T SPEND TOO LONG HERE - feel 42 | * free to use the solution. I have personally wasted 43 | * hours on this error. 44 | */ 45 | assertUserIsAdmin(user); 46 | 47 | type tests = [Expect>]; 48 | }; 49 | }); 50 | -------------------------------------------------------------------------------- /src/04-builder-pattern/19-type-safe-map.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | /** 4 | * In this problem, we need to type the return type of the set() 5 | * method to make it add keys to the TMap generic. 6 | * 7 | * Clues: 8 | * 9 | * 1. Classes can be in positions where types usually belong: 10 | * 11 | * set(): TypeSafeStringMap; 12 | * 13 | * 2. In the return type of set(), we'll need to modify the TMap 14 | * generic to add the new key/value pair. 15 | */ 16 | 17 | class TypeSafeStringMap = {}> { 18 | private map: TMap; 19 | constructor() { 20 | this.map = {} as TMap; 21 | } 22 | 23 | get(key: keyof TMap): string { 24 | return this.map[key]; 25 | } 26 | 27 | set(key: K, value: string): unknown { 28 | (this.map[key] as any) = value; 29 | 30 | return this; 31 | } 32 | } 33 | 34 | const map = new TypeSafeStringMap() 35 | .set("matt", "pocock") 36 | .set("jools", "holland") 37 | .set("brandi", "carlile"); 38 | 39 | it("Should not allow getting values which do not exist", () => { 40 | map.get( 41 | // @ts-expect-error 42 | "jim", 43 | ); 44 | }); 45 | 46 | it("Should return values from keys which do exist", () => { 47 | expect(map.get("matt")).toBe("pocock"); 48 | expect(map.get("jools")).toBe("holland"); 49 | expect(map.get("brandi")).toBe("carlile"); 50 | }); 51 | -------------------------------------------------------------------------------- /src/05-external-libraries/24-lodash-groupby.solution.ts: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import { expect, it } from "vitest"; 3 | import { doNotExecute, Equal, Expect } from "../helpers/type-utils"; 4 | 5 | const groupByAge = (array: T[]) => { 6 | const grouped = _.groupBy(array, "age"); 7 | 8 | return grouped; 9 | }; 10 | 11 | const result = groupByAge([ 12 | { 13 | name: "John", 14 | age: 20, 15 | }, 16 | { 17 | name: "Jane", 18 | age: 20, 19 | }, 20 | { 21 | name: "Mary", 22 | age: 30, 23 | }, 24 | ]); 25 | 26 | it("Should group the items by age", () => { 27 | expect(result).toEqual({ 28 | 20: [ 29 | { 30 | name: "John", 31 | age: 20, 32 | }, 33 | { 34 | name: "Jane", 35 | age: 20, 36 | }, 37 | ], 38 | 30: [ 39 | { 40 | name: "Mary", 41 | age: 30, 42 | }, 43 | ], 44 | }); 45 | 46 | type tests = [ 47 | Expect>>, 48 | ]; 49 | }); 50 | 51 | it("Should not let you pass in an array of objects NOT containing age", () => { 52 | doNotExecute(() => { 53 | groupByAge([ 54 | { 55 | // @ts-expect-error 56 | name: "John", 57 | }, 58 | { 59 | // @ts-expect-error 60 | name: "Bill", 61 | }, 62 | ]); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/helpers/type-utils.ts: -------------------------------------------------------------------------------- 1 | export type Expect = T; 2 | export type ExpectTrue = T; 3 | export type ExpectFalse = T; 4 | export type IsTrue = T; 5 | export type IsFalse = T; 6 | 7 | export type Equal = (() => T extends X ? 1 : 2) extends < 8 | T, 9 | >() => T extends Y ? 1 : 2 10 | ? true 11 | : false; 12 | export type NotEqual = true extends Equal ? false : true; 13 | 14 | // https://stackoverflow.com/questions/49927523/disallow-call-with-any/49928360#49928360 15 | export type IsAny = 0 extends 1 & T ? true : false; 16 | export type NotAny = true extends IsAny ? false : true; 17 | 18 | export type Debug = { [K in keyof T]: T[K] }; 19 | export type MergeInsertions = T extends object 20 | ? { [K in keyof T]: MergeInsertions } 21 | : T; 22 | 23 | export type Alike = Equal, MergeInsertions>; 24 | 25 | export type ExpectExtends = EXPECTED extends VALUE 26 | ? true 27 | : false; 28 | export type ExpectValidArgs< 29 | FUNC extends (...args: any[]) => any, 30 | ARGS extends any[], 31 | > = ARGS extends Parameters ? true : false; 32 | 33 | export type UnionToIntersection = ( 34 | U extends any ? (k: U) => void : never 35 | ) extends (k: infer I) => void 36 | ? I 37 | : never; 38 | 39 | export const doNotExecute = (func: () => void) => {}; 40 | -------------------------------------------------------------------------------- /src/05-external-libraries/25-usage-with-express.solution.ts: -------------------------------------------------------------------------------- 1 | import express, { 2 | NextFunction, 3 | Request, 4 | RequestHandler, 5 | Response, 6 | } from "express"; 7 | import { Equal, Expect } from "../helpers/type-utils"; 8 | 9 | const app = express(); 10 | 11 | type Params = Record; 12 | 13 | const makeTypeSafeGet = 14 | ( 15 | parser: (params: Params) => TParams, 16 | handler: RequestHandler, 17 | ) => 18 | (req: Request, res: Response, next: NextFunction) => { 19 | try { 20 | /** 21 | * Try removing the 'as' cast below and see what happens. 22 | */ 23 | parser(req.params); 24 | } catch (e) { 25 | res.status(400).send("Invalid params: " + (e as Error).message); 26 | return; 27 | } 28 | 29 | return handler(req, res, next); 30 | }; 31 | 32 | const getUser = makeTypeSafeGet( 33 | (params) => { 34 | if (typeof params.id !== "string") { 35 | throw new Error("You must pass an id"); 36 | } 37 | 38 | return { 39 | id: params.id, 40 | }; 41 | }, 42 | (req, res) => { 43 | // req.params should be EXACTLY the type returned from 44 | // the parser above 45 | type tests = [Expect>]; 46 | 47 | res.json({ 48 | id: req.params.id, 49 | name: "Matt", 50 | }); 51 | }, 52 | ); 53 | 54 | app.get("/user", getUser); 55 | -------------------------------------------------------------------------------- /src/04-builder-pattern/18-working-around-partial-inference.problem.ts: -------------------------------------------------------------------------------- 1 | import { Equal, Expect } from "../helpers/type-utils"; 2 | 3 | export const makeSelectors = < 4 | TSource, 5 | TSelectors extends Record any> = {}, 6 | >( 7 | selectors: TSelectors, 8 | ) => { 9 | return selectors; 10 | }; 11 | 12 | interface Source { 13 | firstName: string; 14 | middleName: string; 15 | lastName: string; 16 | } 17 | 18 | /** 19 | * We've got a problem here. We want to be able to infer the type 20 | * of the selectors object from what we passed in to makeSelectors. 21 | * 22 | * But we can't. As soon as we pass ONE type argument, inference 23 | * doesn't work on the other type arguments. We want to refactor this 24 | * so that we can infer the type of the selectors object. 25 | * 26 | * Desired API: 27 | * 28 | * makeSelectors()({ ...selectorsGoHere }) 29 | */ 30 | const selectors = makeSelectors({ 31 | getFullName: (source) => 32 | `${source.firstName} ${source.middleName} ${source.lastName}`, 33 | getFirstAndLastName: (source) => `${source.firstName} ${source.lastName}`, 34 | getFirstNameLength: (source) => source.firstName.length, 35 | }); 36 | 37 | type tests = [ 38 | Expect string>>, 39 | Expect< 40 | Equal string> 41 | >, 42 | Expect< 43 | Equal number> 44 | >, 45 | ]; 46 | -------------------------------------------------------------------------------- /src/05-external-libraries/26-usage-with-zod.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { z } from "zod"; 3 | 4 | /** 5 | * In this exercise, we need to dive deep into the types of a 6 | * library called zod - a schema definition library. We're trying 7 | * to make a function that takes a schema and a function, and 8 | * returns a new function that will parse the input using the 9 | * schema before calling the original function. 10 | * 11 | * Your focus should be the 'arg:' below - what type should it be? 12 | * 13 | * Clues: 14 | * 15 | * 1. You'll need to investigate the generic signature of z.ZodType 16 | * to see if you can extract the output. 17 | */ 18 | const makeZodSafeFunction = ( 19 | schema: TSchema, 20 | func: (arg: unknown) => TResult, 21 | ) => { 22 | return (arg: unknown) => { 23 | const result = schema.parse(arg); 24 | return func(result); 25 | }; 26 | }; 27 | 28 | const addTwoNumbersArg = z.object({ 29 | a: z.number(), 30 | b: z.number(), 31 | }); 32 | 33 | const addTwoNumbers = makeZodSafeFunction( 34 | addTwoNumbersArg, 35 | (args) => args.a + args.b, 36 | ); 37 | 38 | it("Should error on the type level AND the runtime if you pass incorrect params", () => { 39 | expect(() => 40 | addTwoNumbers( 41 | // @ts-expect-error 42 | { a: 1, badParam: 3 }, 43 | ), 44 | ).toThrow(); 45 | }); 46 | 47 | it("Should succeed if you pass the correct type", () => { 48 | expect(addTwoNumbers({ a: 1, b: 2 })).toBe(3); 49 | }); 50 | -------------------------------------------------------------------------------- /src/01-branded-types/01-form-validation.solution.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type Password = Brand; 5 | type Email = Brand; 6 | 7 | export const validateValues = (values: { email: string; password: string }) => { 8 | if (!values.email.includes("@")) { 9 | throw new Error("Email invalid"); 10 | } 11 | if (values.password.length < 8) { 12 | throw new Error("Password not long enough"); 13 | } 14 | 15 | return { 16 | email: values.email as Email, 17 | password: values.password as Password, 18 | }; 19 | }; 20 | 21 | const createUserOnApi = (values: { email: Email; password: Password }) => { 22 | // Imagine this function creates the user on the API 23 | }; 24 | 25 | const onSubmitHandler = (values: { email: string; password: string }) => { 26 | const validatedValues = validateValues(values); 27 | createUserOnApi(validatedValues); 28 | }; 29 | 30 | describe("onSubmitHandler", () => { 31 | it("Should error if the email is invalid", () => { 32 | expect(() => { 33 | onSubmitHandler({ 34 | email: "invalid", 35 | password: "12345678", 36 | }); 37 | }).toThrowError("Email invalid"); 38 | }); 39 | 40 | it("Should error if the password is too short", () => { 41 | expect(() => { 42 | onSubmitHandler({ 43 | email: "whatever@example.com", 44 | password: "1234567", 45 | }); 46 | }).toThrowError("Password not long enough"); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/01-branded-types/01-form-validation.problem.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | type Password = Brand; 5 | type Email = Brand; 6 | 7 | export const validateValues = (values: { email: string; password: string }) => { 8 | if (!values.email.includes("@")) { 9 | throw new Error("Email invalid"); 10 | } 11 | if (values.password.length < 8) { 12 | throw new Error("Password not long enough"); 13 | } 14 | 15 | return { 16 | email: values.email, 17 | password: values.password, 18 | }; 19 | }; 20 | 21 | const createUserOnApi = (values: { email: Email; password: Password }) => { 22 | // Imagine this function creates the user on the API 23 | }; 24 | 25 | const onSubmitHandler = (values: { email: string; password: string }) => { 26 | const validatedValues = validateValues(values); 27 | // How do we stop this erroring? 28 | createUserOnApi(validatedValues); 29 | }; 30 | 31 | describe("onSubmitHandler", () => { 32 | it("Should error if the email is invalid", () => { 33 | expect(() => { 34 | onSubmitHandler({ 35 | email: "invalid", 36 | password: "12345678", 37 | }); 38 | }).toThrowError("Email invalid"); 39 | }); 40 | 41 | it("Should error if the password is too short", () => { 42 | expect(() => { 43 | onSubmitHandler({ 44 | email: "whatever@example.com", 45 | password: "1234567", 46 | }); 47 | }).toThrowError("Password not long enough"); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/14-type-predicates-with-generics.problem.ts: -------------------------------------------------------------------------------- 1 | import { isBodyElement, isDivElement } from "fake-external-lib"; 2 | import { it } from "vitest"; 3 | import { Equal, Expect } from "../helpers/type-utils"; 4 | 5 | interface DOMNodeExtractorConfig { 6 | isNode: (node: unknown) => boolean; 7 | transform: (node: T) => Result; 8 | } 9 | 10 | const createDOMNodeExtractor = ( 11 | config: DOMNodeExtractorConfig, 12 | ) => { 13 | return (nodes: unknown[]): TResult[] => { 14 | return nodes.filter(config.isNode).map(config.transform); 15 | }; 16 | }; 17 | 18 | it('Should pick up that "extractDivs" is of type "HTMLDivElement[]"', () => { 19 | const extractDivs = createDOMNodeExtractor({ 20 | isNode: isDivElement, 21 | transform: (div) => { 22 | type test1 = Expect>; 23 | return div.innerText; 24 | }, 25 | }); 26 | 27 | const divs = extractDivs([document.createElement("div")]); 28 | 29 | type test2 = Expect>; 30 | }); 31 | 32 | it('Should pick up that "extractBodies" is of type "HTMLBodyElement[]"', () => { 33 | const extractBodies = createDOMNodeExtractor({ 34 | isNode: isBodyElement, 35 | transform: (body) => { 36 | type test1 = Expect>; 37 | 38 | return body.bgColor; 39 | }, 40 | }); 41 | 42 | const bodies = extractBodies([document.createElement("body")]); 43 | 44 | type test2 = Expect>; 45 | }); 46 | -------------------------------------------------------------------------------- /src/03-type-predicates-assertion-functions/14-type-predicates-with-generics.solution.ts: -------------------------------------------------------------------------------- 1 | import { isBodyElement, isDivElement } from "fake-external-lib"; 2 | import { it } from "vitest"; 3 | import { Equal, Expect } from "../helpers/type-utils"; 4 | 5 | interface DOMNodeExtractorConfig { 6 | isNode: (node: unknown) => node is T; 7 | transform: (node: T) => Result; 8 | } 9 | 10 | const createDOMNodeExtractor = ( 11 | config: DOMNodeExtractorConfig, 12 | ) => { 13 | return (nodes: unknown[]): TResult[] => { 14 | return nodes.filter(config.isNode).map(config.transform); 15 | }; 16 | }; 17 | 18 | it('Should pick up that "extractDivs" is of type "HTMLDivElement[]"', () => { 19 | const extractDivs = createDOMNodeExtractor({ 20 | isNode: isDivElement, 21 | transform: (div) => { 22 | type test1 = Expect>; 23 | return div.innerText; 24 | }, 25 | }); 26 | 27 | const divs = extractDivs([document.createElement("div")]); 28 | 29 | type test2 = Expect>; 30 | }); 31 | 32 | it('Should pick up that "extractBodies" is of type "HTMLBodyElement[]"', () => { 33 | const extractBodies = createDOMNodeExtractor({ 34 | isNode: isBodyElement, 35 | transform: (body) => { 36 | type test1 = Expect>; 37 | 38 | return body.bgColor; 39 | }, 40 | }); 41 | 42 | const bodies = extractBodies([document.createElement("body")]); 43 | 44 | type test2 = Expect>; 45 | }); 46 | -------------------------------------------------------------------------------- /src/04-builder-pattern/20-importance-of-default-generic.solution.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | 3 | /** 4 | * I removed the default generic of TypeSafeStringMap, and it broke! 5 | * Why? 6 | * 7 | * The reason is that if you DON'T specify a default generic, then 8 | * TypeScript will default it to Record. When you 9 | * later try to assign keys to TypeSafeStringMap, TypeScript will 10 | * still keep Record inside TMap. 11 | * 12 | * This means that keyof TMap (inside get) will be string, which 13 | * means you lose the type safety of the keys. 14 | */ 15 | class TypeSafeStringMap = {}> { 16 | private map: TMap; 17 | constructor() { 18 | this.map = {} as TMap; 19 | } 20 | 21 | get(key: keyof TMap): string { 22 | return this.map[key]; 23 | } 24 | 25 | set( 26 | key: K, 27 | value: string, 28 | ): TypeSafeStringMap> { 29 | (this.map[key] as any) = value; 30 | 31 | return this; 32 | } 33 | } 34 | 35 | const map = new TypeSafeStringMap() 36 | .set("matt", "pocock") 37 | .set("jools", "holland") 38 | .set("brandi", "carlile"); 39 | 40 | it("Should not allow getting values which do not exist", () => { 41 | map.get( 42 | // @ts-expect-error 43 | "jim", 44 | ); 45 | }); 46 | 47 | it("Should return values from keys which do exist", () => { 48 | expect(map.get("matt")).toBe("pocock"); 49 | expect(map.get("jools")).toBe("holland"); 50 | expect(map.get("brandi")).toBe("carlile"); 51 | }); 52 | -------------------------------------------------------------------------------- /scripts/exercise.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require("child_process"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const chokidar = require("chokidar"); 5 | const fg = require("fast-glob"); 6 | 7 | const srcPath = path.resolve(__dirname, "../src"); 8 | 9 | const [, , exercise] = process.argv; 10 | 11 | if (!exercise) { 12 | console.log("Please specify an exercise"); 13 | process.exit(1); 14 | } 15 | 16 | const allExercises = fg.sync( 17 | path.join(srcPath, "**", "**.ts").replace(/\\/g, "/"), 18 | ); 19 | 20 | let pathIndicator = ".problem."; 21 | 22 | if (process.env.SOLUTION) { 23 | pathIndicator = ".solution."; 24 | } 25 | 26 | const exerciseFile = allExercises.find((e) => { 27 | const base = path.parse(e).base; 28 | return base.startsWith(exercise) && base.includes(pathIndicator); 29 | }); 30 | 31 | if (!exerciseFile) { 32 | console.log(`Exercise ${exercise} not found`); 33 | process.exit(1); 34 | } 35 | 36 | // One-liner for current directory 37 | chokidar.watch(exerciseFile).on("all", (event, path) => { 38 | const fileContents = fs.readFileSync(exerciseFile, "utf8"); 39 | 40 | const containsVitest = 41 | fileContents.includes(`from "vitest"`) || 42 | fileContents.includes(`from 'vitest'`); 43 | try { 44 | console.clear(); 45 | if (containsVitest) { 46 | console.log("Running tests..."); 47 | execSync(`vitest run "${exerciseFile}" --passWithNoTests`, { 48 | stdio: "inherit", 49 | }); 50 | } 51 | console.log("Checking types..."); 52 | execSync(`tsc "${exerciseFile}" --noEmit --strict --esModuleInterop`, { 53 | stdio: "inherit", 54 | }); 55 | console.log("Typecheck complete. You finished the exercise!"); 56 | } catch (e) { 57 | console.log("Failed. Try again!"); 58 | } 59 | }); 60 | -------------------------------------------------------------------------------- /src/04-builder-pattern/21-dynamic-middleware.solution.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { fetchUser } from "fake-external-lib"; 3 | 4 | type Middleware = (input: TInput) => TOutput; 5 | 6 | /** 7 | * In this problem, we need to type the return type of the use() 8 | * method to make it update the TOutput generic with a new one. 9 | */ 10 | class DynamicMiddleware { 11 | private middleware: Middleware[] = []; 12 | 13 | constructor(firstMiddleware: Middleware) { 14 | this.middleware.push(firstMiddleware); 15 | } 16 | 17 | use( 18 | middleware: Middleware, 19 | ): DynamicMiddleware { 20 | this.middleware.push(middleware); 21 | 22 | return this as any; 23 | } 24 | 25 | async run(input: TInput): Promise { 26 | let result: TOutput = input as any; 27 | 28 | for (const middleware of this.middleware) { 29 | result = await middleware(result); 30 | } 31 | 32 | return result; 33 | } 34 | } 35 | 36 | const middleware = new DynamicMiddleware((req: Request) => { 37 | return { 38 | ...req, 39 | // Transforms /user/123 to 123 40 | userId: req.url.split("/")[2], 41 | }; 42 | }) 43 | .use((req) => { 44 | if (req.userId === "123") { 45 | throw new Error(); 46 | } 47 | return req; 48 | }) 49 | .use(async (req) => { 50 | return { 51 | ...req, 52 | user: await fetchUser(req.userId), 53 | }; 54 | }); 55 | 56 | it("Should fail if the user id is 123", () => { 57 | expect(middleware.run({ url: "/user/123" } as Request)).rejects.toThrow(); 58 | }); 59 | 60 | it("Should return a request with a user", async () => { 61 | const result = await middleware.run({ url: "/user/matt" } as Request); 62 | 63 | expect(result.user.id).toBe("matt"); 64 | expect(result.user.firstName).toBe("John"); 65 | expect(result.user.lastName).toBe("Doe"); 66 | }); 67 | -------------------------------------------------------------------------------- /src/05-external-libraries/25-usage-with-express.problem.ts: -------------------------------------------------------------------------------- 1 | import express, { 2 | NextFunction, 3 | Request, 4 | RequestHandler, 5 | Response, 6 | } from "express"; 7 | import { Equal, Expect } from "../helpers/type-utils"; 8 | 9 | const app = express(); 10 | 11 | type Params = Record; 12 | 13 | /** 14 | * The intention of this function is to parse the params 15 | * before the handler is called. If the params are invalid, 16 | * we want to return a 400 response. 17 | * 18 | * The issue is that the handler is not type safe. We need to 19 | * find some way to pass the type of the parsed params to the 20 | * RequestHandler AND the Request type to make the tests happy 21 | * below. 22 | * 23 | * Clues: 24 | * 25 | * 1. You'll need to investigate the generic signature of RequestHandler 26 | * and Request. 27 | * 28 | * 2. Remember that any params passed will always conform to 29 | * Record. 30 | */ 31 | const makeTypeSafeGet = 32 | (parser: (params: Params) => unknown, handler: RequestHandler) => 33 | (req: Request, res: Response, next: NextFunction) => { 34 | try { 35 | /** 36 | * Try removing the 'as' cast below and see what happens. 37 | */ 38 | parser(req.params); 39 | } catch (e) { 40 | res.status(400).send("Invalid params: " + (e as Error).message); 41 | return; 42 | } 43 | 44 | return handler(req, res, next); 45 | }; 46 | 47 | const getUser = makeTypeSafeGet( 48 | (params) => { 49 | if (typeof params.id !== "string") { 50 | throw new Error("You must pass an id"); 51 | } 52 | 53 | return { 54 | id: params.id, 55 | }; 56 | }, 57 | (req, res) => { 58 | // req.params should be EXACTLY the type returned from 59 | // the parser above 60 | type tests = [Expect>]; 61 | 62 | res.json({ 63 | id: req.params.id, 64 | name: "Matt", 65 | }); 66 | }, 67 | ); 68 | 69 | app.get("/user", getUser); 70 | -------------------------------------------------------------------------------- /src/04-builder-pattern/21-dynamic-middleware.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { fetchUser } from "fake-external-lib"; 3 | 4 | type Middleware = (input: TInput) => TOutput; 5 | 6 | /** 7 | * In this problem, we need to type the return type of the use() 8 | * method to make it update the TOutput generic with a new one. 9 | * 10 | * Currently, the use method just uses the same TOutput as the 11 | * first middleware you pass in. But it should infer the _new_ 12 | * output from the middleware you pass in. 13 | */ 14 | class DynamicMiddleware { 15 | private middleware: Middleware[] = []; 16 | 17 | constructor(firstMiddleware: Middleware) { 18 | this.middleware.push(firstMiddleware); 19 | } 20 | 21 | // Clue: you'll need to make changes here! 22 | use(middleware: Middleware) { 23 | this.middleware.push(middleware); 24 | 25 | return this as any; 26 | // ^ You'll need the 'as any'! 27 | } 28 | 29 | async run(input: TInput): Promise { 30 | let result: TOutput = input as any; 31 | 32 | for (const middleware of this.middleware) { 33 | result = await middleware(result); 34 | } 35 | 36 | return result; 37 | } 38 | } 39 | 40 | const middleware = new DynamicMiddleware((req: Request) => { 41 | return { 42 | ...req, 43 | // Transforms /user/123 to 123 44 | userId: req.url.split("/")[2], 45 | }; 46 | }) 47 | .use((req) => { 48 | if (req.userId === "123") { 49 | throw new Error(); 50 | } 51 | return req; 52 | }) 53 | .use(async (req) => { 54 | return { 55 | ...req, 56 | user: await fetchUser(req.userId), 57 | }; 58 | }); 59 | 60 | it("Should fail if the user id is 123", () => { 61 | expect(middleware.run({ url: "/user/123" } as Request)).rejects.toThrow(); 62 | }); 63 | 64 | it("Should return a request with a user", async () => { 65 | const result = await middleware.run({ url: "/user/matt" } as Request); 66 | 67 | expect(result.user.id).toBe("matt"); 68 | expect(result.user.firstName).toBe("John"); 69 | expect(result.user.lastName).toBe("Doe"); 70 | }); 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Quickstart 4 | 5 | Clone this repo or [open in Gitpod](https://gitpod.io/#https://github.com/total-typescript/advanced-patterns-workshop). 6 | 7 | ```sh 8 | # Installs all dependencies 9 | npm install 10 | 11 | # Starts the first exercise 12 | npm run exercise 01 13 | 14 | # Runs linting and tests on the solution 15 | npm run solution 01 16 | ``` 17 | 18 | ## How to take the course 19 | 20 | You'll notice that the course is split into modules. Each module is a group of related exercises. 21 | 22 | Each exercise is split into a `*.problem.ts` and a `*.solution.ts`. 23 | 24 | To take an exercise: 25 | 26 | 1. Go into `*.problem.ts` 27 | 2. Run `npm run exercise 01`, where `01` is the number of the exercise (not module) you're on. 28 | 29 | The `exercise` script will run TypeScript typechecks and a test suite on the exercise. 30 | 31 | This course encourages **active, exploratory learning**. I'll present a problem, and **you'll be asked to try to find a solution**. To attempt a solution, you'll need to: 32 | 33 | 1. Check out [TypeScript's docs](https://www.typescriptlang.org/docs/handbook/intro.html) 34 | 2. Try to find something that looks relevant. 35 | 3. Give it a go to see if it solves the problem. 36 | 37 | You'll know if you've succeeded because the tests will pass. 38 | 39 | **If you succeed**, or **if you get stuck**, unpause the video and check out the `*.solution.ts`. You can see if your solution is better or worse than mine! 40 | 41 | You can run `npm run solution 01` to run the tests and typechecking on the solution. 42 | 43 | ## Acknowledgements 44 | 45 | Say thanks to Matt on [Twitter](https://twitter.com/mattpocockuk) or by joining his [Discord](https://discord.gg/8S5ujhfTB3). Consider signing up to his [Total TypeScript course](https://totaltypescript.com). 46 | 47 | ## Reference 48 | 49 | ### `npm run exercise 01` 50 | 51 | Alias: `npm run e 01` 52 | 53 | Run the corresponding `*.problem.ts` file. 54 | 55 | ### `npm run solution 01` 56 | 57 | Alias: `npm run s 01` 58 | 59 | Run the corresponding `*.solution.ts` file. If there are multiple, it runs only the first one. 60 | -------------------------------------------------------------------------------- /notes/FUTURE.md: -------------------------------------------------------------------------------- 1 | # TypeScript Advanced Patterns Workshop 2 | 3 | ## Things to add 4 | 5 | 1. Type predicates and assertion functions 6 | 1. Assertion functions in classes 7 | 1. Creating factories with top-level context (trpc context, for example) 8 | 1. Branded types 9 | 1. Builder pattern (transformers) 10 | 1. Using external libraries 11 | 1. Generics with Express 12 | 1. Generics with Zod 13 | 1. Conditional errors 14 | 1. Global types 15 | 1. Object coercion (maps) 16 | 1. Routers 17 | 1. Reducer 18 | 1. Usage with React? 19 | 1. Discriminated unions for props? 20 | 21 | ## Sections 22 | 23 | ### 1. Branded types 24 | 25 | ✅ Form validation - email & password 26 | ✅ Branded ids for data fetching (userId vs postId) 27 | ✅ ValidatedCurrency - using brands to figure out complicated payments logic 28 | Cryptographically secure 29 | ✅ Branded objects (for confirmPassword/password validation) 30 | Ints or Floats 31 | ✅ Records with different branded index signatures? 32 | 33 | ### 2. Global types 34 | 35 | ✅ Adding a function to global scope 36 | ✅ Adding to Window 37 | ✅ Adding to ProcessEnv (with namespaces) 38 | ✅ Custom global interfaces 39 | ✅ Custom JSX elements (as a challenge) 40 | 41 | ### 3. Type predicates and assertion functions 42 | 43 | ✅ Type predicates (with .filter) 44 | ✅ Assertion functions 45 | Assertion functions _inside classes_ 46 | ✅ Fixing the AWFUL asserts error 47 | ✅ Type predicates with generic inference 48 | ✅ Type predicates WITH branded types 49 | ✅ Assertion functions with branded types 50 | 51 | ### 4. Builder pattern 52 | 53 | ✅ Global context creator 54 | ✅ Solving partial inference with currying 55 | ✅ Classes which build generics on themselves 56 | ✅ Importance of default generics 57 | ✅ Middleware? 58 | 59 | ### 5. Usage with external libraries 60 | 61 | With a simple external library 62 | With a non-generic external library 63 | ✅ Extracting out library types with ReturnType/Parameters 64 | ✅ Usage with Express 65 | ✅ Usage with Zod 66 | 67 | ### 6. Identity Functions 68 | 69 | ✅ No generics on objects! 70 | ✅ F.Narrow vs as const 71 | ✅ F.NoInfer 72 | ✅ Lodash (MAYBE REVISIT AND ADD COMPLEXITY) 73 | 74 | 75 | 76 | 77 | 78 | ### 7. Challenges 79 | 80 | Build a reducer 81 | Rebuild jQuery! 82 | 83 | ### Unknown bucket 84 | 85 | FSM's in TypeScript? 86 | -------------------------------------------------------------------------------- /src/01-branded-types/04-currency-conversion.problem.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | interface User { 5 | id: string; 6 | name: string; 7 | maxConversionAmount: number; 8 | } 9 | 10 | // Mocks a function that uses an API to convert 11 | // One currency to another 12 | const getConversionRateFromApi = async ( 13 | amount: number, 14 | from: string, 15 | to: string, 16 | ) => { 17 | return Promise.resolve(amount * 0.82); 18 | }; 19 | 20 | // Mocks a function which actually performs the conversion 21 | const performConversion = async (user: User, to: string, amount: number) => {}; 22 | 23 | const ensureUserCanConvert = (user: User, amount: number): User => { 24 | if (user.maxConversionAmount < amount) { 25 | throw new Error("User cannot convert currency"); 26 | } 27 | 28 | return user; 29 | }; 30 | 31 | describe("Possible implementations", () => { 32 | it("Should error if you do not authorize the user first", () => { 33 | const handleConversionRequest = async ( 34 | user: User, 35 | from: string, 36 | to: string, 37 | amount: number, 38 | ) => { 39 | const convertedAmount = await getConversionRateFromApi(amount, from, to); 40 | 41 | // @ts-expect-error 42 | await performConversion(user, to, convertedAmount); 43 | }; 44 | }); 45 | 46 | it("Should error if you do not convert the amount first", () => { 47 | const handleConversionRequest = async ( 48 | user: User, 49 | from: string, 50 | to: string, 51 | amount: number, 52 | ) => { 53 | // @ts-expect-error 54 | const authorizedUser = ensureUserCanConvert(user, amount); 55 | 56 | // @ts-expect-error 57 | await performConversion(authorizedUser, to, amount); 58 | }; 59 | }); 60 | 61 | it("Should pass type checking if you authorize the user AND convert the amount", () => { 62 | const handleConversionRequest = async ( 63 | user: User, 64 | from: string, 65 | to: string, 66 | amount: number, 67 | ) => { 68 | const convertedAmount = await getConversionRateFromApi(amount, from, to); 69 | const authorizedUser = ensureUserCanConvert(user, convertedAmount); 70 | 71 | await performConversion(authorizedUser, to, convertedAmount); 72 | }; 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/01-branded-types/04-currency-conversion.solution.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "vitest"; 2 | import { Brand } from "../helpers/Brand"; 3 | 4 | interface User { 5 | id: string; 6 | name: string; 7 | maxConversionAmount: number; 8 | } 9 | 10 | type ConvertedCurrency = Brand; 11 | type AuthorizedUser = Brand; 12 | 13 | // Mocks a function that uses an API to convert 14 | // One currency to another 15 | const getConversionRateFromApi = async ( 16 | amount: number, 17 | from: string, 18 | to: string, 19 | ) => { 20 | return Promise.resolve((amount * 0.82) as ConvertedCurrency); 21 | }; 22 | 23 | // Mocks a function which actually performs the conversion 24 | const performConversion = async ( 25 | user: AuthorizedUser, 26 | to: string, 27 | amount: ConvertedCurrency, 28 | ) => {}; 29 | 30 | const ensureUserCanConvert = ( 31 | user: User, 32 | amount: ConvertedCurrency, 33 | ): AuthorizedUser => { 34 | if (user.maxConversionAmount < amount) { 35 | throw new Error("User cannot convert currency"); 36 | } 37 | 38 | return user as AuthorizedUser; 39 | }; 40 | 41 | describe("Possible implementations", () => { 42 | it("Should error if you do not authorize the user first", () => { 43 | const handleConversionRequest = async ( 44 | user: User, 45 | from: string, 46 | to: string, 47 | amount: number, 48 | ) => { 49 | const convertedAmount = await getConversionRateFromApi(amount, from, to); 50 | 51 | // @ts-expect-error 52 | await performConversion(user, to, convertedAmount); 53 | }; 54 | }); 55 | 56 | it("Should error if you do not convert the amount first", () => { 57 | const handleConversionRequest = async ( 58 | user: User, 59 | from: string, 60 | to: string, 61 | amount: number, 62 | ) => { 63 | // @ts-expect-error 64 | const authorizedUser = ensureUserCanConvert(user, amount); 65 | 66 | // @ts-expect-error 67 | await performConversion(authorizedUser, to, amount); 68 | }; 69 | }); 70 | 71 | it("Should pass type checking if you authorize the user AND convert the amount", () => { 72 | const handleConversionRequest = async ( 73 | user: User, 74 | from: string, 75 | to: string, 76 | amount: number, 77 | ) => { 78 | const convertedAmount = await getConversionRateFromApi(amount, from, to); 79 | const authorizedUser = ensureUserCanConvert(user, convertedAmount); 80 | 81 | await performConversion(authorizedUser, to, convertedAmount); 82 | }; 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/07-challenges/32-dynamic-reducer.solution.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | type HandlersToDiscriminatedUnion> = { 5 | [K in keyof T]: { type: K } & T[K]; 6 | }[keyof T]; 7 | 8 | export class DynamicReducer< 9 | TState, 10 | THandlers extends Record = {}, 11 | > { 12 | private handlers = {} as Record< 13 | string, 14 | (state: TState, action: HandlersToDiscriminatedUnion) => TState 15 | >; 16 | 17 | addHandler( 18 | type: TType, 19 | handler: (state: TState, payload: TPayload) => TState, 20 | ): DynamicReducer> { 21 | this.handlers[type] = handler; 22 | 23 | return this; 24 | } 25 | 26 | reduce( 27 | state: TState, 28 | action: HandlersToDiscriminatedUnion, 29 | ): TState { 30 | const handler = this.handlers[action.type]; 31 | if (!handler) { 32 | return state; 33 | } 34 | 35 | return handler(state, action); 36 | } 37 | } 38 | 39 | interface State { 40 | username: string; 41 | password: string; 42 | } 43 | 44 | const reducer = new DynamicReducer() 45 | .addHandler( 46 | "LOG_IN", 47 | (state, action: { username: string; password: string }) => { 48 | return { 49 | username: action.username, 50 | password: action.password, 51 | }; 52 | }, 53 | ) 54 | .addHandler("LOG_OUT", () => { 55 | return { 56 | username: "", 57 | password: "", 58 | }; 59 | }); 60 | 61 | it("Should return the new state after LOG_IN", () => { 62 | const state = reducer.reduce( 63 | { username: "", password: "" }, 64 | { type: "LOG_IN", username: "foo", password: "bar" }, 65 | ); 66 | 67 | type test = [Expect>]; 68 | 69 | expect(state).toEqual({ username: "foo", password: "bar" }); 70 | }); 71 | 72 | it("Should return the new state after LOG_OUT", () => { 73 | const state = reducer.reduce( 74 | { username: "foo", password: "bar" }, 75 | { type: "LOG_OUT" }, 76 | ); 77 | 78 | type test = [Expect>]; 79 | 80 | expect(state).toEqual({ username: "", password: "" }); 81 | }); 82 | 83 | it("Should error if you pass it an incorrect action", () => { 84 | const state = reducer.reduce( 85 | { username: "foo", password: "bar" }, 86 | { 87 | // @ts-expect-error 88 | type: "NOT_ALLOWED", 89 | }, 90 | ); 91 | }); 92 | 93 | it("Should error if you pass an incorrect payload", () => { 94 | const state = reducer.reduce( 95 | { username: "foo", password: "bar" }, 96 | // @ts-expect-error 97 | { 98 | type: "LOG_IN", 99 | }, 100 | ); 101 | }); 102 | -------------------------------------------------------------------------------- /src/07-challenges/32-dynamic-reducer.problem.ts: -------------------------------------------------------------------------------- 1 | import { expect, it } from "vitest"; 2 | import { Equal, Expect } from "../helpers/type-utils"; 3 | 4 | // Clue - this will be needed! 5 | type HandlersToDiscriminatedUnion> = { 6 | [K in keyof T]: { type: K } & T[K]; 7 | }[keyof T]; 8 | 9 | /** 10 | * It turns a record of handler information into a discriminated union: 11 | * 12 | * | { type: "LOG_IN", username: string, password: string } 13 | * | { type: "LOG_OUT" } 14 | */ 15 | type TestingHandlersToDiscriminatedUnion = HandlersToDiscriminatedUnion<{ 16 | LOG_IN: { username: string; password: string }; 17 | LOG_OUT: {}; 18 | }>; 19 | 20 | /** 21 | * Clue: 22 | * 23 | * You'll need to add two generics here! 24 | */ 25 | export class DynamicReducer { 26 | private handlers = {} as unknown; 27 | 28 | addHandler( 29 | type: unknown, 30 | handler: (state: unknown, payload: unknown) => unknown, 31 | ): unknown { 32 | this.handlers[type] = handler; 33 | 34 | return this; 35 | } 36 | 37 | reduce(state: unknown, action: unknown): unknown { 38 | const handler = this.handlers[action.type]; 39 | if (!handler) { 40 | return state; 41 | } 42 | 43 | return handler(state, action); 44 | } 45 | } 46 | 47 | interface State { 48 | username: string; 49 | password: string; 50 | } 51 | 52 | const reducer = new DynamicReducer() 53 | .addHandler( 54 | "LOG_IN", 55 | (state, action: { username: string; password: string }) => { 56 | return { 57 | username: action.username, 58 | password: action.password, 59 | }; 60 | }, 61 | ) 62 | .addHandler("LOG_OUT", () => { 63 | return { 64 | username: "", 65 | password: "", 66 | }; 67 | }); 68 | 69 | it("Should return the new state after LOG_IN", () => { 70 | const state = reducer.reduce( 71 | { username: "", password: "" }, 72 | { type: "LOG_IN", username: "foo", password: "bar" }, 73 | ); 74 | 75 | type test = [Expect>]; 76 | 77 | expect(state).toEqual({ username: "foo", password: "bar" }); 78 | }); 79 | 80 | it("Should return the new state after LOG_OUT", () => { 81 | const state = reducer.reduce( 82 | { username: "foo", password: "bar" }, 83 | { type: "LOG_OUT" }, 84 | ); 85 | 86 | type test = [Expect>]; 87 | 88 | expect(state).toEqual({ username: "", password: "" }); 89 | }); 90 | 91 | it("Should error if you pass it an incorrect action", () => { 92 | const state = reducer.reduce( 93 | { username: "foo", password: "bar" }, 94 | { 95 | // @ts-expect-error 96 | type: "NOT_ALLOWED", 97 | }, 98 | ); 99 | }); 100 | 101 | it("Should error if you pass an incorrect payload", () => { 102 | const state = reducer.reduce( 103 | { username: "foo", password: "bar" }, 104 | // @ts-expect-error 105 | { 106 | type: "LOG_IN", 107 | }, 108 | ); 109 | }); 110 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [ 4 | "vitest/importMeta" 5 | ], 6 | /* Visit https://aka.ms/tsconfig to read more about this file */ 7 | /* Projects */ 8 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 9 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 10 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 11 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 12 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 13 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 14 | /* Language and Environment */ 15 | "target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 16 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 17 | "jsx": "preserve", /* Specify what JSX code is generated. */ 18 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 19 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 20 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 21 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 22 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 23 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 24 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 25 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 26 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 27 | /* Modules */ 28 | "module": "ES2022", /* Specify what module code is generated. */ 29 | "moduleResolution": "node", 30 | // "rootDir": "./", /* Specify the root folder within your source files. */ 31 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 32 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 33 | "paths": { 34 | "fake-external-lib": [ 35 | "./src/fake-external-lib/index" 36 | ] 37 | }, /* Specify a set of entries that re-map imports to additional lookup locations. */ 38 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 39 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 40 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 41 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 42 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 43 | // "resolveJsonModule": true, /* Enable importing .json files. */ 44 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 45 | /* JavaScript Support */ 46 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 47 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 48 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 49 | /* Emit */ 50 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 51 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 52 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 53 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 54 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 55 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 56 | // "removeComments": true, /* Disable emitting comments. */ 57 | "noEmit": true, /* Disable emitting files from a compilation. */ 58 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 59 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 60 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 61 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 62 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 63 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 64 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 65 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 66 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 67 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 68 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 69 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 70 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 71 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 72 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 73 | /* Interop Constraints */ 74 | "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 75 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 76 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 77 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 78 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 79 | /* Type Checking */ 80 | "strict": true, /* Enable all strict type-checking options. */ 81 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 82 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 83 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 84 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 85 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 86 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 87 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 88 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 89 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 90 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 91 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 92 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 93 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 94 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 95 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 96 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 97 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 98 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": [ 104 | "src" 105 | ] 106 | } --------------------------------------------------------------------------------