├── .gitignore ├── .vscode └── settings.json ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.test.tsx ├── App.tsx ├── AppEnv.tsx ├── components │ ├── Breeds.tsx │ └── Main.tsx ├── hooks │ ├── useAppEnv.ts │ ├── useDomain.tsx │ ├── useIO.ts │ └── useStable.ts ├── index.tsx ├── model │ ├── Breed.ts │ └── Image.ts ├── react-app-env.d.ts ├── reportWebVitals.ts ├── service │ ├── cache │ │ └── CacheService.ts │ ├── domain │ │ └── DogService.ts │ ├── http │ │ ├── FetchHttpClient.ts │ │ ├── HttpClient.ts │ │ └── HttpError.ts │ └── localStorage │ │ ├── DomLocalStorage.ts │ │ └── LocalStorage.ts ├── setupTests.ts └── util │ ├── decode.ts │ ├── fpts.ts │ └── typeGuards.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .eslintcache 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@devexperts/remote-data-ts": "^2.0.4", 7 | "@testing-library/jest-dom": "^5.11.4", 8 | "@testing-library/react": "^11.1.0", 9 | "@testing-library/user-event": "^12.1.10", 10 | "@types/jest": "^26.0.15", 11 | "@types/node": "^12.0.0", 12 | "@types/react": "^16.9.53", 13 | "@types/react-dom": "^16.9.8", 14 | "fp-ts": "^2.9.1", 15 | "io-ts": "^2.2.13", 16 | "react": "^17.0.1", 17 | "react-dom": "^17.0.1", 18 | "react-scripts": "4.0.1", 19 | "typescript": "^4.0.3", 20 | "web-vitals": "^0.2.4" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": [ 30 | "react-app", 31 | "react-app/jest" 32 | ] 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | }, 46 | "devDependencies": { 47 | "prettier": "^2.2.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andywhite37/react-rte-experiment/84918e89a0846b6f65f3d23448aec86b6313f6e6/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andywhite37/react-rte-experiment/84918e89a0846b6f65f3d23448aec86b6313f6e6/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andywhite37/react-rte-experiment/84918e89a0846b6f65f3d23448aec86b6313f6e6/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import React from "react"; 3 | import App from "./App"; 4 | 5 | test("renders learn react link", () => { 6 | render(); 7 | const linkElement = screen.getByText(/The App/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Main } from "./components/Main"; 3 | 4 | export const App = () => { 5 | return
; 6 | }; 7 | -------------------------------------------------------------------------------- /src/AppEnv.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { fetchHttpClient } from "./service/http/FetchHttpClient"; 3 | import { HttpClient, HttpClientEnv } from "./service/http/HttpClient"; 4 | import { HttpJsonError } from "./service/http/HttpError"; 5 | import { domLocalStorage } from "./service/localStorage/DomLocalStorage"; 6 | import { 7 | LocalStorage, 8 | LocalStorageEnv, 9 | } from "./service/localStorage/LocalStorage"; 10 | import { 11 | CacheService, 12 | CacheServiceEnv, 13 | makeLocalStorageCacheService, 14 | } from "./service/cache/CacheService"; 15 | import { 16 | BreedImageService, 17 | BreedImageServiceEnv, 18 | makeBreedImageService, 19 | BreedService, 20 | BreedServiceEnv, 21 | makeBreedService, 22 | } from "./service/domain/DogService"; 23 | 24 | //////////////////////////////////////////////////////////////////////////////// 25 | // Instantiate the implementations of our services services 26 | //////////////////////////////////////////////////////////////////////////////// 27 | 28 | const httpClient: HttpClient = fetchHttpClient; 29 | 30 | export const httpClientEnv: HttpClientEnv = { 31 | httpClient, 32 | }; 33 | 34 | const localStorage: LocalStorage = domLocalStorage; 35 | 36 | export const localStorageEnv: LocalStorageEnv = { 37 | localStorage, 38 | }; 39 | 40 | export const cacheService: CacheService = makeLocalStorageCacheService( 41 | localStorageEnv 42 | ); 43 | 44 | export const cacheServiceEnv: CacheServiceEnv = { 45 | cacheService, 46 | }; 47 | 48 | export const breedService: BreedService = makeBreedService({ 49 | ...httpClientEnv, 50 | ...cacheServiceEnv, 51 | }); 52 | 53 | export const breedServiceEnv: BreedServiceEnv = { 54 | breedService, 55 | }; 56 | 57 | export const breedImageService: BreedImageService = makeBreedImageService( 58 | { ...httpClientEnv, ...localStorageEnv } 59 | ); 60 | 61 | export const breedImageServiceEnv: BreedImageServiceEnv = { 62 | breedImageService, 63 | }; 64 | 65 | //////////////////////////////////////////////////////////////////////////////// 66 | // Implementation of context where we bundle it all together for consumption. 67 | // 68 | // Pros: 69 | // - Components can use RTEs that depend on any part of the overall AppEnv 70 | // - Easy to create a general-purpose custom hook that can run any RTE that depends on AppEnv 71 | // 72 | // Cons: 73 | // - Use of AppEnv-based hook makes your component dependent on the whole AppEnv. 74 | // This is a tradeoff between component isolation and convenience. 75 | //////////////////////////////////////////////////////////////////////////////// 76 | 77 | export type AppEnv = HttpClientEnv & 78 | LocalStorageEnv & 79 | CacheServiceEnv & 80 | BreedServiceEnv & 81 | BreedImageServiceEnv; 82 | 83 | export const appEnv: AppEnv = { 84 | ...httpClientEnv, 85 | ...localStorageEnv, 86 | ...cacheServiceEnv, 87 | ...breedServiceEnv, 88 | ...breedImageServiceEnv, 89 | }; 90 | -------------------------------------------------------------------------------- /src/components/Breeds.tsx: -------------------------------------------------------------------------------- 1 | import { Breed } from "../model/Breed"; 2 | import { HttpJsonError } from "../service/http/HttpError"; 3 | import { pipe, RD } from "../util/fpts"; 4 | 5 | const getErrorMessage = (e: HttpJsonError): string => { 6 | switch (e.tag) { 7 | case "httpRequestError": 8 | return "Failed to connect to server"; 9 | case "httpContentTypeError": 10 | return "Unexpected response from server"; 11 | case "httpResponseStatusError": 12 | return `Request failed with status: ${e.status}`; 13 | case "decodeError": 14 | return `Failed to decode response JSON`; 15 | } 16 | }; 17 | 18 | export const Breeds = ({ 19 | breedsRD, 20 | }: { 21 | breedsRD: RD.RemoteData>; 22 | }) => { 23 | return ( 24 | <> 25 |

Breeds

26 | {pipe( 27 | breedsRD, 28 | RD.fold( 29 | () =>

Welcome

, 30 | () =>

Loading...

, 31 | (error) =>

{getErrorMessage(error)}

, 32 | (breeds) => ( 33 |
    34 | {breeds.map((breed) => ( 35 |
  • 36 | {breed.name} 37 |
      38 | {breed.subBreeds.map((subBreed) => ( 39 |
    • {subBreed}
    • 40 | ))} 41 |
    42 |
  • 43 | ))} 44 |
45 | ) 46 | ) 47 | )} 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/components/Main.tsx: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useState } from "react"; 2 | import { 3 | appEnv, 4 | breedServiceEnv, 5 | cacheServiceEnv, 6 | httpClientEnv, 7 | } from "../AppEnv"; 8 | import { 9 | AppEnvContext, 10 | useAppEnvReducer, 11 | useAppEnvRemoteData, 12 | useAppEnvRT, 13 | useAppEnvRTE, 14 | } from "../hooks/useAppEnv"; 15 | import { BreedServiceContext, useBreedsRD } from "../hooks/useDomain"; 16 | import { useIO } from "../hooks/useIO"; 17 | import { Breed } from "../model/Breed"; 18 | import { 19 | BreedService, 20 | getBreeds, 21 | getBreedsWithCache, 22 | } from "../service/domain/DogService"; 23 | import { HttpJsonError } from "../service/http/HttpError"; 24 | import { E, Eq, pipe, RD, RT, RTE, TE } from "../util/fpts"; 25 | import { Breeds } from "./Breeds"; 26 | 27 | //////////////////////////////////////////////////////////////////////////////// 28 | // Vanillaish implementation 29 | //////////////////////////////////////////////////////////////////////////////// 30 | 31 | export const MainRTEWithGlobalDeps = () => { 32 | const [remoteData, setRemoteData] = useState< 33 | RD.RemoteData> 34 | >(RD.initial); 35 | 36 | useIO( 37 | () => { 38 | setRemoteData(RD.pending); 39 | RTE.run(getBreedsWithCache, { 40 | // Not great b/c we are importing global static deps - hard to test/mock/reuse 41 | ...httpClientEnv, 42 | ...cacheServiceEnv, 43 | }).then( 44 | E.fold( 45 | (e) => setRemoteData(RD.failure(e)), 46 | (b) => setRemoteData(RD.success(b)) 47 | ) 48 | ); 49 | }, 50 | [], 51 | Eq.getTupleEq() 52 | ); 53 | 54 | return ; 55 | }; 56 | 57 | //////////////////////////////////////////////////////////////////////////////// 58 | // Using lowest-level ReaderTask implementation 59 | // 60 | // This requires the caller to use an RTE, then explicitly map it into a 61 | // ReaderTask to indicate that errors are handled (`never`) and output 62 | // is consumed (`void`). 63 | //////////////////////////////////////////////////////////////////////////////// 64 | 65 | export const MainAppEnvRT = () => { 66 | const [breedsRD, setBreedsRD] = useState< 67 | RD.RemoteData> 68 | >(RD.initial); 69 | 70 | // Not great b/c we are dependent on the entire AppEnv, and the logic here is a little "low-level" 71 | useAppEnvRT({ 72 | rt: pipe( 73 | getBreeds, 74 | RTE.fold( 75 | (e: HttpJsonError) => 76 | RT.fromIO(() => { 77 | setBreedsRD(RD.failure(e)); 78 | }), 79 | (breeds: Array) => 80 | RT.fromIO(() => { 81 | setBreedsRD(RD.success(breeds)); 82 | }) 83 | ) 84 | ), 85 | deps: [], 86 | eqDeps: Eq.getTupleEq(), 87 | }); 88 | 89 | return ; 90 | }; 91 | 92 | //////////////////////////////////////////////////////////////////////////////// 93 | // Using an RTE with before/success/error callbacks 94 | // 95 | // This is a helpers to ease the explicit handling of before/error/success 96 | //////////////////////////////////////////////////////////////////////////////// 97 | 98 | export const MainAppEnvRTE = () => { 99 | const [breedsRD, setBreedsRD] = useState< 100 | RD.RemoteData> 101 | >(RD.initial); 102 | 103 | // A littler simpler logic, but still depend on entire AppEnv 104 | useAppEnvRTE({ 105 | rte: getBreeds, 106 | onBefore: () => setBreedsRD(RD.pending), 107 | onError: (error) => setBreedsRD(RD.failure(error)), 108 | onSuccess: (breeds) => setBreedsRD(RD.success(breeds)), 109 | deps: [], 110 | eqDeps: Eq.getTupleEq(), 111 | }); 112 | 113 | return ; 114 | }; 115 | 116 | //////////////////////////////////////////////////////////////////////////////// 117 | // Using an RTE that manifests itself as a RemoteData state 118 | //////////////////////////////////////////////////////////////////////////////// 119 | 120 | export const MainAppEnvRemoteData = () => { 121 | // Simpler, still depends on AppEnv 122 | const breedsRD = useAppEnvRemoteData({ 123 | rte: getBreeds, 124 | deps: [], 125 | eqDeps: Eq.getTupleEq(), 126 | }); 127 | 128 | return ; 129 | }; 130 | 131 | //////////////////////////////////////////////////////////////////////////////// 132 | // Using an RTE that manifests itself as reducer/redux actions 133 | //////////////////////////////////////////////////////////////////////////////// 134 | 135 | // Redux/reducer code 136 | type LoadingBreeds = { type: "loadingBreeds" }; 137 | type FailedBreeds = { type: "failedBreeds"; error: HttpJsonError }; 138 | type LoadedBreeds = { type: "loadedBreeds"; breeds: Array }; 139 | 140 | type Action = LoadingBreeds | FailedBreeds | LoadedBreeds; 141 | 142 | type State = { breedsRD: RD.RemoteData> }; 143 | 144 | const initialState = { breedsRD: RD.initial }; 145 | 146 | type Reducer = (state: State, action: Action) => State; 147 | 148 | const reducer: Reducer = (_state, action) => { 149 | switch (action.type) { 150 | case "loadingBreeds": 151 | return { breedsRD: RD.pending }; 152 | case "failedBreeds": 153 | return { breedsRD: RD.failure(action.error) }; 154 | case "loadedBreeds": 155 | return { breedsRD: RD.success(action.breeds) }; 156 | } 157 | }; 158 | 159 | export const MainAppEnvReducer = () => { 160 | const [state, dispatch] = useReducer(reducer, initialState); 161 | 162 | // Demo for dispatching actions (still depends on AppEnv) 163 | useAppEnvReducer({ 164 | rte: getBreedsWithCache, 165 | dispatch, 166 | getBeforeAction: (): Action => ({ type: "loadingBreeds" }), 167 | getErrorAction: (error): Action => ({ type: "failedBreeds", error }), 168 | getSuccessAction: (breeds): Action => ({ 169 | type: "loadedBreeds", 170 | breeds, 171 | }), 172 | deps: [], 173 | eqDeps: Eq.getTupleEq(), 174 | }); 175 | 176 | return ; 177 | }; 178 | 179 | //////////////////////////////////////////////////////////////////////////////// 180 | // BreedService implementation 181 | //////////////////////////////////////////////////////////////////////////////// 182 | 183 | const mockBreedService: BreedService = { 184 | getBreeds: TE.right([ 185 | { name: "breed1", subBreeds: ["sub1", "sub2"] }, 186 | { name: "breed2", subBreeds: ["sub3", "sub4"] }, 187 | ]), 188 | }; 189 | 190 | export const MainBreedService = () => { 191 | const breedsRD = useBreedsRD(); 192 | 193 | return ; 194 | }; 195 | 196 | //////////////////////////////////////////////////////////////////////////////// 197 | // Show a particular implementation 198 | //////////////////////////////////////////////////////////////////////////////// 199 | 200 | export const Main = () => { 201 | /////////////////////////////////////////////////////////////////////////////// 202 | // AppEnv context with ReaderTask-based hook 203 | /////////////////////////////////////////////////////////////////////////////// 204 | 205 | //return ( 206 | // 207 | // 208 | // 209 | //); 210 | 211 | /////////////////////////////////////////////////////////////////////////////// 212 | // AppEnv context with ReaderTask-based hook 213 | /////////////////////////////////////////////////////////////////////////////// 214 | 215 | //return ( 216 | // 217 | // 218 | // 219 | //); 220 | 221 | /////////////////////////////////////////////////////////////////////////////// 222 | // AppEnv context with RemoteData-based hook 223 | /////////////////////////////////////////////////////////////////////////////// 224 | 225 | //return ( 226 | // 227 | // 228 | // 229 | //); 230 | 231 | /////////////////////////////////////////////////////////////////////////////// 232 | // AppEnv context with reducer-based hook 233 | /////////////////////////////////////////////////////////////////////////////// 234 | 235 | //return ( 236 | // 237 | // 238 | // 239 | //); 240 | 241 | /////////////////////////////////////////////////////////////////////////////// 242 | // BreedService context with real API 243 | /////////////////////////////////////////////////////////////////////////////// 244 | 245 | return ( 246 | 247 | 248 | 249 | ); 250 | 251 | /////////////////////////////////////////////////////////////////////////////// 252 | // BreedService context with mock data 253 | /////////////////////////////////////////////////////////////////////////////// 254 | 255 | //return ( 256 | // 257 | // 258 | // 259 | //); 260 | }; 261 | -------------------------------------------------------------------------------- /src/hooks/useAppEnv.ts: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { useState } from "react"; 3 | import { appEnv, AppEnv } from "../AppEnv"; 4 | import { E, Eq, pipe, RD, RT, RTE } from "../util/fpts"; 5 | import { useIO } from "./useIO"; 6 | 7 | export const AppEnvContext = React.createContext(appEnv); 8 | 9 | export const useAppEnv = () => { 10 | return useContext(AppEnvContext); 11 | }; 12 | 13 | /** 14 | * Runs an ReaderTask using the global AppEnv. A ReaderTask is the same as a ReaderTaskEither 15 | * with the error type set to `never` - this is done to force the caller to explicitly handle the 16 | * all possible errors. 17 | */ 18 | export const useAppEnvRT = >({ 19 | rt, 20 | deps, 21 | eqDeps, 22 | }: { 23 | rt: RT.ReaderTask; 24 | deps: Deps; 25 | eqDeps: Eq.Eq; 26 | }) => { 27 | // This is the key to the RTE flexibility, but it comes at the cost of making any component 28 | // that uses this dependent on the whole AppEnv 29 | const env = useAppEnv(); 30 | 31 | useIO( 32 | () => { 33 | RT.run(rt, env); 34 | }, 35 | deps, 36 | eqDeps 37 | ); 38 | }; 39 | 40 | /** 41 | * Runs an RTE and handles before, success, and failure using the given 42 | * callbacks. The callbacks cannot fail, and are run as IO operations in the RTE chain. 43 | */ 44 | export const useAppEnvRTE = >({ 45 | rte, 46 | onBefore, 47 | onError, 48 | onSuccess, 49 | deps, 50 | eqDeps, 51 | }: { 52 | rte: RTE.ReaderTaskEither; 53 | // TODO: these could be made effectful (IO/RT/RTE/etc.) if desired 54 | onBefore: () => void; 55 | onError: (e: E) => void; 56 | onSuccess: (a: A) => void; 57 | deps: Deps; 58 | eqDeps: Eq.Eq; 59 | }): void => { 60 | const rt: RT.ReaderTask = pipe( 61 | RTE.fromIO(onBefore), 62 | RTE.chain((_) => rte), 63 | RTE.fold( 64 | (e) => 65 | RT.fromIO(() => { 66 | onError(e); 67 | }), 68 | (a) => 69 | RT.fromIO(() => { 70 | onSuccess(a); 71 | }) 72 | ) 73 | ); 74 | 75 | useAppEnvRT({ 76 | rt, 77 | deps, 78 | eqDeps, 79 | }); 80 | }; 81 | 82 | /** 83 | * A hook to integrate an RTE with the dispatch of redux/reducer actions 84 | */ 85 | export const useAppEnvReducer = >({ 86 | rte, 87 | getBeforeAction, 88 | getErrorAction, 89 | getSuccessAction, 90 | dispatch, 91 | deps, 92 | eqDeps, 93 | }: { 94 | rte: RTE.ReaderTaskEither; 95 | getBeforeAction: () => Action; 96 | getErrorAction: (e: E) => Action; 97 | getSuccessAction: (a: A) => Action; 98 | dispatch: (a: Action) => void; 99 | deps: Deps; 100 | eqDeps: Eq.Eq; 101 | }): void => 102 | useAppEnvRTE({ 103 | rte, 104 | onBefore: () => dispatch(getBeforeAction()), 105 | onSuccess: (a) => dispatch(getSuccessAction(a)), 106 | onError: (e) => dispatch(getErrorAction(e)), 107 | deps, 108 | eqDeps, 109 | }); 110 | 111 | /** 112 | * A hook to handle as RTE effect as a RemoteData state 113 | */ 114 | export const useAppEnvRemoteData = >({ 115 | rte, 116 | deps, 117 | eqDeps, 118 | }: { 119 | rte: RTE.ReaderTaskEither; 120 | deps: Deps; 121 | eqDeps: Eq.Eq; 122 | }): RD.RemoteData => { 123 | const [remoteData, setRemoteData] = useState>(RD.initial); 124 | 125 | useAppEnvRTE({ 126 | rte, 127 | onBefore: () => setRemoteData(RD.pending), 128 | onError: (e) => setRemoteData(RD.failure(e)), 129 | onSuccess: (a) => setRemoteData(RD.success(a)), 130 | deps, 131 | eqDeps, 132 | }); 133 | 134 | return remoteData; 135 | }; 136 | -------------------------------------------------------------------------------- /src/hooks/useDomain.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react"; 2 | import { breedServiceEnv } from "../AppEnv"; 3 | import { Breed } from "../model/Breed"; 4 | import { HttpJsonError } from "../service/http/HttpError"; 5 | import { E, Eq, RD } from "../util/fpts"; 6 | import { useIO } from "./useIO"; 7 | 8 | export const BreedServiceContext = React.createContext(breedServiceEnv); 9 | 10 | export const useBreedService = () => { 11 | return useContext(BreedServiceContext); 12 | }; 13 | 14 | export const useBreedsRD = () => { 15 | const breedServiceEnv = useBreedService(); 16 | 17 | const [remoteData, setRemoteData] = useState< 18 | RD.RemoteData> 19 | >(RD.initial); 20 | 21 | useIO( 22 | () => { 23 | setRemoteData(RD.pending); 24 | breedServiceEnv.breedService.getBreeds().then( 25 | E.fold( 26 | (error) => setRemoteData(RD.failure(error)), 27 | (breeds) => setRemoteData(RD.success(breeds)) 28 | ) 29 | ); 30 | }, 31 | [], 32 | Eq.getTupleEq() 33 | ); 34 | 35 | return remoteData; 36 | }; 37 | -------------------------------------------------------------------------------- /src/hooks/useIO.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Eq, IO } from "../util/fpts"; 3 | import { useStable } from "./useStable"; 4 | 5 | export const useIO = >( 6 | io: IO.IO, 7 | dependencies: T, 8 | eq: Eq.Eq 9 | ) => { 10 | const deps = useStable(dependencies, eq); 11 | useEffect(() => { 12 | io(); 13 | // eslint-disable-next-line react-hooks/exhaustive-deps 14 | }, deps); 15 | }; 16 | -------------------------------------------------------------------------------- /src/hooks/useStable.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { Eq } from "../util/fpts"; 3 | 4 | export const useStable = (a: A, eqA: Eq.Eq) => { 5 | const refA = useRef(a); 6 | if (!eqA.equals(a, refA.current)) { 7 | refA.current = a; 8 | } 9 | return refA.current; 10 | }; 11 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { App } from "./App"; 4 | import reportWebVitals from "./reportWebVitals"; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById("root") 11 | ); 12 | 13 | // If you want to start measuring performance in your app, pass a function 14 | // to log results (for example: reportWebVitals(console.log)) 15 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 16 | reportWebVitals(); 17 | -------------------------------------------------------------------------------- /src/model/Breed.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | 3 | export interface Breed { 4 | name: string; 5 | subBreeds: Array; 6 | } 7 | 8 | export const breedCodec: t.Type = t.type({ 9 | name: t.string, 10 | subBreeds: t.array(t.string), 11 | }); 12 | -------------------------------------------------------------------------------- /src/model/Image.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | 3 | export type Image = { 4 | url: string; 5 | }; 6 | 7 | export const imageCodec: t.Type = t.type({ 8 | url: t.string, 9 | }); 10 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/service/cache/CacheService.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { DecodeError } from "../../util/decode"; 3 | import { pipe, R, RTE, TE } from "../../util/fpts"; 4 | import { 5 | getItemWithCache, 6 | LocalStorageEnv, 7 | } from "../localStorage/LocalStorage"; 8 | 9 | //////////////////////////////////////////////////////////////////////////////// 10 | // Interfaces 11 | //////////////////////////////////////////////////////////////////////////////// 12 | 13 | export interface CacheService { 14 | getWithCache( 15 | key: string, 16 | codec: t.Type, 17 | get: RTE.ReaderTaskEither 18 | ): RTE.ReaderTaskEither; 19 | 20 | clear: TE.TaskEither; 21 | } 22 | 23 | export interface CacheServiceEnv { 24 | cacheService: CacheService; 25 | } 26 | 27 | //////////////////////////////////////////////////////////////////////////////// 28 | // RTE and helper functions 29 | //////////////////////////////////////////////////////////////////////////////// 30 | 31 | export const getWithCache = ( 32 | key: string, 33 | codec: t.Type, 34 | get: RTE.ReaderTaskEither 35 | ): RTE.ReaderTaskEither => 36 | pipe( 37 | RTE.ask(), 38 | RTE.chainW((env) => env.cacheService.getWithCache(key, codec, get)) 39 | ); 40 | 41 | export const clear: RTE.ReaderTaskEither = pipe( 42 | RTE.ask(), 43 | RTE.chainTaskEitherKW((env) => env.cacheService.clear) 44 | ); 45 | 46 | //////////////////////////////////////////////////////////////////////////////// 47 | // Implementations 48 | //////////////////////////////////////////////////////////////////////////////// 49 | 50 | export const makeLocalStorageCacheService: R.Reader< 51 | LocalStorageEnv, 52 | CacheService 53 | > = (localStorageEnv): CacheService => ({ 54 | getWithCache: ( 55 | key: string, 56 | codec: t.Type, 57 | get: RTE.ReaderTaskEither 58 | ) => 59 | pipe( 60 | RTE.ask(), 61 | RTE.chainTaskEitherKW((r) => 62 | getItemWithCache(key, codec, get)({ ...localStorageEnv, ...r }) 63 | ) 64 | ), 65 | 66 | clear: TE.fromIO(localStorageEnv.localStorage.clear), 67 | }); 68 | -------------------------------------------------------------------------------- /src/service/domain/DogService.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/lib/function"; 2 | import * as t from "io-ts"; 3 | import { Breed, breedCodec } from "../../model/Breed"; 4 | import { Image, imageCodec } from "../../model/Image"; 5 | import { decodeWithCodec } from "../../util/decode"; 6 | import { A, R, Rec, RTE, TE } from "../../util/fpts"; 7 | import { CacheServiceEnv, getWithCache } from "../cache/CacheService"; 8 | import { getJson, HttpClientEnv } from "../http/HttpClient"; 9 | import { HttpJsonError } from "../http/HttpError"; 10 | 11 | //////////////////////////////////////////////////////////////////////////////// 12 | // Implementations of the services 13 | //////////////////////////////////////////////////////////////////////////////// 14 | 15 | type GetBreedsResponse = { 16 | message: Record>; 17 | }; 18 | 19 | const getBreedsResponseCodec: t.Type = t.type({ 20 | message: t.record(t.string, t.array(t.string)), 21 | }); 22 | 23 | export const getBreeds: RTE.ReaderTaskEither< 24 | HttpClientEnv, 25 | HttpJsonError, 26 | Array 27 | > = pipe( 28 | getJson( 29 | "https://dog.ceo/api/breeds/list/all", 30 | decodeWithCodec(getBreedsResponseCodec) 31 | ), 32 | RTE.map((response) => 33 | pipe( 34 | Rec.toArray(response.message), 35 | A.map(([name, subBreeds]) => ({ name, subBreeds })) 36 | ) 37 | ) 38 | ); 39 | 40 | type GetBreedImagesResponse = { 41 | message: Array; 42 | }; 43 | 44 | const getBreedImagesResponseCodec: t.Type = t.type({ 45 | message: t.array(t.string), 46 | }); 47 | 48 | export const getBreedImages = ( 49 | breed: Breed 50 | ): RTE.ReaderTaskEither> => 51 | pipe( 52 | getJson( 53 | `https://dog.ceo/api/breed/${breed.name}/images`, 54 | decodeWithCodec(getBreedImagesResponseCodec) 55 | ), 56 | RTE.map((response) => 57 | pipe( 58 | response.message, 59 | A.map((url) => ({ url })) 60 | ) 61 | ) 62 | ); 63 | 64 | export const getBreedsWithCache: RTE.ReaderTaskEither< 65 | HttpClientEnv & CacheServiceEnv, 66 | HttpJsonError, 67 | Array 68 | > = getWithCache("breeds", t.array(breedCodec), getBreeds); 69 | 70 | export const getBreedImagesWithCache = ( 71 | breed: Breed 72 | ): RTE.ReaderTaskEither< 73 | HttpClientEnv & CacheServiceEnv, 74 | HttpJsonError, 75 | Array 76 | > => 77 | getWithCache( 78 | `breedImages-${breed.name}`, 79 | t.array(imageCodec), 80 | getBreedImages(breed) 81 | ); 82 | 83 | //////////////////////////////////////////////////////////////////////////////// 84 | // Domain services to get breeds, etc. 85 | // 86 | // These should not expose lower-level dependencies, and should keep the error type 87 | // abstract. 88 | //////////////////////////////////////////////////////////////////////////////// 89 | 90 | export interface BreedService { 91 | getBreeds: TE.TaskEither>; 92 | } 93 | 94 | export type BreedServiceEnv = { 95 | breedService: BreedService; 96 | }; 97 | 98 | export interface BreedImageService { 99 | getBreedImages: (breed: Breed) => TE.TaskEither>; 100 | } 101 | 102 | export type BreedImageServiceEnv = { 103 | breedImageService: BreedImageService; 104 | }; 105 | 106 | //////////////////////////////////////////////////////////////////////////////// 107 | // Service implementations 108 | //////////////////////////////////////////////////////////////////////////////// 109 | 110 | export const makeBreedService: R.Reader< 111 | HttpClientEnv & CacheServiceEnv, 112 | BreedService 113 | > = (env) => ({ 114 | getBreeds: getBreedsWithCache(env), 115 | }); 116 | 117 | /** 118 | * Reader that takes the required env, and produces an implementation of the GetBreedImagesService 119 | */ 120 | export const makeBreedImageService: R.Reader< 121 | HttpClientEnv, 122 | BreedImageService 123 | > = (env) => ({ 124 | getBreedImages: (breed) => getBreedImages(breed)(env), 125 | }); 126 | -------------------------------------------------------------------------------- /src/service/http/FetchHttpClient.ts: -------------------------------------------------------------------------------- 1 | import { pipe, RTE, TE } from "../../util/fpts"; 2 | import { HttpClient, HttpRequest, HttpResponse } from "./HttpClient"; 3 | import { httpContentTypeError, httpRequestError } from "./HttpError"; 4 | 5 | export const httpRequestToFetchRequest = (request: HttpRequest): Request => 6 | new Request(request.url, { ...request }); 7 | 8 | export const fetchResponseToHttpResponse = ( 9 | response: Response 10 | ): HttpResponse => { 11 | return { 12 | status: response.status, 13 | headers: {}, // TODO: convert Headers 14 | // TODO: not sure what/if we need to deal with the issue where you can only read the response body once. I'm using clone for now as a workaround. 15 | getBodyAsJson: TE.tryCatch( 16 | () => response.clone().json(), 17 | (error) => httpContentTypeError<"json">("json", error) 18 | ), 19 | getBodyAsText: TE.tryCatch( 20 | () => response.clone().json(), 21 | (error) => httpContentTypeError<"text">("text", error) 22 | ), 23 | }; 24 | }; 25 | 26 | export const fetchHttpClient: HttpClient = { 27 | sendRequest: pipe( 28 | RTE.ask(), 29 | RTE.chainTaskEitherK((request) => 30 | TE.tryCatch(() => { 31 | return fetch(httpRequestToFetchRequest(request)); 32 | }, httpRequestError) 33 | ), 34 | RTE.map(fetchResponseToHttpResponse) 35 | ), 36 | }; 37 | -------------------------------------------------------------------------------- /src/service/http/HttpClient.ts: -------------------------------------------------------------------------------- 1 | import { E, pipe, RTE, TE } from "../../util/fpts"; 2 | import { DecodeError } from "../../util/decode"; 3 | import { 4 | HttpContentTypeError, 5 | HttpJsonError, 6 | HttpRequestError, 7 | HttpResponseStatusError, 8 | httpResponseStatusError, 9 | } from "./HttpError"; 10 | 11 | //////////////////////////////////////////////////////////////////////////////// 12 | // Intefaces 13 | //////////////////////////////////////////////////////////////////////////////// 14 | 15 | export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; 16 | 17 | export interface HttpRequest { 18 | method: HttpMethod; 19 | url: string; 20 | headers?: Record; 21 | } 22 | 23 | export interface HttpResponse { 24 | status: number; 25 | headers?: Record; 26 | getBodyAsJson: TE.TaskEither, unknown>; 27 | getBodyAsText: TE.TaskEither, string>; 28 | } 29 | 30 | export interface HttpClient { 31 | sendRequest( 32 | request: HttpRequest 33 | ): TE.TaskEither; 34 | } 35 | 36 | export interface HttpClientEnv { 37 | httpClient: HttpClient; 38 | } 39 | 40 | //////////////////////////////////////////////////////////////////////////////// 41 | // RTE and helper functions 42 | //////////////////////////////////////////////////////////////////////////////// 43 | 44 | export const sendRequest = ( 45 | httpRequest: HttpRequest 46 | ): RTE.ReaderTaskEither => 47 | pipe( 48 | RTE.asks((m: HttpClientEnv) => m.httpClient), 49 | RTE.chainTaskEitherKW((httpClient) => httpClient.sendRequest(httpRequest)) 50 | ); 51 | 52 | export const ensureStatusRange = ( 53 | minInclusive: number, 54 | maxExclusive: number 55 | ) => ( 56 | httpResponse: HttpResponse 57 | ): E.Either => 58 | httpResponse.status >= minInclusive && httpResponse.status < maxExclusive 59 | ? E.right(httpResponse) 60 | : E.left( 61 | httpResponseStatusError( 62 | httpResponse, 63 | httpResponse.status, 64 | minInclusive, 65 | maxExclusive 66 | ) 67 | ); 68 | 69 | export const ensure2xx = ensureStatusRange(200, 300); 70 | 71 | // This is just a simple helper to demonstrate how to create helpers using lower-level utility 72 | // methods. This is not intended to be the end-all solution for GET JSON requests 73 | export const getJson = ( 74 | url: string, 75 | decode: (raw: unknown) => E.Either 76 | ): RTE.ReaderTaskEither => 77 | pipe( 78 | sendRequest({ method: "GET", url }), 79 | RTE.chainEitherKW(ensure2xx), 80 | RTE.chainTaskEitherKW((response) => response.getBodyAsJson), 81 | RTE.chainEitherKW(decode) 82 | ); 83 | -------------------------------------------------------------------------------- /src/service/http/HttpError.ts: -------------------------------------------------------------------------------- 1 | import { DecodeError } from "../../util/decode"; 2 | import { HttpResponse } from "./HttpClient"; 3 | 4 | export type HttpRequestError = { 5 | tag: "httpRequestError"; 6 | error: unknown; 7 | }; 8 | 9 | export const httpRequestError = (error: unknown): HttpRequestError => ({ 10 | tag: "httpRequestError", 11 | error, 12 | }); 13 | 14 | export type HttpContentTypeError = { 15 | tag: "httpContentTypeError"; 16 | attemptedContentType: CT; 17 | error: unknown; 18 | }; 19 | 20 | export const httpContentTypeError = ( 21 | attemptedContentType: CT, 22 | error: unknown 23 | ): HttpContentTypeError => ({ 24 | tag: "httpContentTypeError", 25 | attemptedContentType, 26 | error, 27 | }); 28 | 29 | export type HttpResponseStatusError = { 30 | tag: "httpResponseStatusError"; 31 | httpResponse: HttpResponse; 32 | status: number; 33 | minStatusInclusive: number; 34 | maxStatusExclusive: number; 35 | }; 36 | 37 | export const httpResponseStatusError = ( 38 | httpResponse: HttpResponse, 39 | status: number, 40 | minStatusInclusive: number, 41 | maxStatusExclusive: number 42 | ): HttpResponseStatusError => ({ 43 | tag: "httpResponseStatusError", 44 | httpResponse, 45 | status, 46 | minStatusInclusive, 47 | maxStatusExclusive, 48 | }); 49 | 50 | /** 51 | * Combination of common errors for JSON requests, for convenience 52 | */ 53 | export type HttpJsonError = 54 | | HttpRequestError 55 | | HttpContentTypeError<"json"> 56 | | HttpResponseStatusError 57 | | DecodeError; 58 | -------------------------------------------------------------------------------- /src/service/localStorage/DomLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { O } from "../../util/fpts"; 2 | import { LocalStorage } from "./LocalStorage"; 3 | 4 | export const domLocalStorage: LocalStorage = { 5 | getItem: (key: string) => () => O.fromNullable(localStorage.getItem(key)), 6 | 7 | setItem: (key: string, value: string) => () => 8 | localStorage.setItem(key, value), 9 | 10 | removeItem: (key: string) => () => localStorage.removeItem(key), 11 | 12 | clear: () => localStorage.clear(), 13 | 14 | size: () => localStorage.length, 15 | }; 16 | -------------------------------------------------------------------------------- /src/service/localStorage/LocalStorage.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { DecodeError, decodeWithCodec } from "../../util/decode"; 3 | import { E, IO, O, pipe, RTE } from "../../util/fpts"; 4 | 5 | //////////////////////////////////////////////////////////////////////////////// 6 | // Interfaces 7 | //////////////////////////////////////////////////////////////////////////////// 8 | 9 | export interface LocalStorage { 10 | getItem(key: string): IO.IO>; 11 | setItem(key: string, value: string): IO.IO; 12 | removeItem(key: string): IO.IO; 13 | clear: IO.IO; 14 | size: IO.IO; 15 | } 16 | 17 | export interface LocalStorageEnv { 18 | localStorage: LocalStorage; 19 | } 20 | 21 | //////////////////////////////////////////////////////////////////////////////// 22 | // RTE and helper functions 23 | //////////////////////////////////////////////////////////////////////////////// 24 | 25 | export const getItem = ( 26 | key: string 27 | ): RTE.ReaderTaskEither> => 28 | pipe( 29 | RTE.ask(), 30 | RTE.chain((env) => RTE.fromIO(env.localStorage.getItem(key))) 31 | ); 32 | 33 | export const setItem = ( 34 | key: string, 35 | value: string 36 | ): RTE.ReaderTaskEither => 37 | pipe( 38 | RTE.ask(), 39 | RTE.chain((env) => RTE.fromIO(env.localStorage.setItem(key, value))) 40 | ); 41 | 42 | export const removeItem = ( 43 | key: string 44 | ): RTE.ReaderTaskEither => 45 | pipe( 46 | RTE.ask(), 47 | RTE.chain((env) => RTE.fromIO(env.localStorage.removeItem(key))) 48 | ); 49 | 50 | export const clear: RTE.ReaderTaskEither = pipe( 51 | RTE.ask(), 52 | RTE.chain((env) => RTE.fromIO(env.localStorage.clear)) 53 | ); 54 | 55 | export const size: RTE.ReaderTaskEither = pipe( 56 | RTE.ask(), 57 | RTE.chain((env) => RTE.fromIO(env.localStorage.size)) 58 | ); 59 | 60 | export const getItemWithDecode = ( 61 | key: string, 62 | decode: (raw: unknown) => E.Either 63 | ): RTE.ReaderTaskEither> => 64 | pipe( 65 | getItem(key), 66 | RTE.chainEitherKW((itemStringOpt) => 67 | pipe( 68 | itemStringOpt, 69 | O.fold( 70 | (): E.Either> => E.right(O.none), 71 | (itemString) => pipe(itemString, JSON.parse, decode, E.map(O.some)) 72 | ) 73 | ) 74 | ) 75 | ); 76 | 77 | export const setItemWithEncode = ( 78 | key: string, 79 | item: A, 80 | encode: (a: A) => unknown 81 | ): RTE.ReaderTaskEither => 82 | pipe(item, encode, JSON.stringify, (value) => setItem(key, value)); 83 | 84 | export const getItemWithCache = ( 85 | key: string, 86 | codec: t.Type, 87 | get: RTE.ReaderTaskEither 88 | ): RTE.ReaderTaskEither => 89 | pipe( 90 | // Try to get from the localStorage cache 91 | getItemWithDecode(key, decodeWithCodec(codec)), 92 | RTE.chainW((dataOpt) => 93 | pipe( 94 | dataOpt, 95 | O.fold( 96 | // Cache miss - do the API call, and store the results in the cache 97 | () => 98 | pipe( 99 | // Do get call 100 | get, 101 | RTE.chainW((data) => 102 | pipe( 103 | // Store the results as a side-effect 104 | setItemWithEncode(key, data, codec.encode), 105 | // Return the results 106 | RTE.map((_) => data) 107 | ) 108 | ) 109 | ), 110 | // Cache hit - just return it 111 | RTE.right 112 | ) 113 | ) 114 | ) 115 | ); 116 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /src/util/decode.ts: -------------------------------------------------------------------------------- 1 | import * as t from "io-ts"; 2 | import { E, pipe } from "./fpts"; 3 | 4 | export type DecodeError = { tag: "decodeError"; errors: t.Errors }; 5 | 6 | export const decodeError = (errors: t.Errors): DecodeError => ({ 7 | tag: "decodeError", 8 | errors, 9 | }); 10 | 11 | export const decodeWithCodec = (codec: t.Type) => ( 12 | value: unknown 13 | ): E.Either => 14 | pipe(codec.decode(value), E.mapLeft(decodeError)); 15 | -------------------------------------------------------------------------------- /src/util/fpts.ts: -------------------------------------------------------------------------------- 1 | export * as A from "fp-ts/lib/Array"; 2 | export * as E from "fp-ts/lib/Either"; 3 | export * as Eq from "fp-ts/lib/Eq"; 4 | export * as IO from "fp-ts/lib/IO"; 5 | export * as IOE from "fp-ts/lib/IOEither"; 6 | export * as NEA from "fp-ts/lib/NonEmptyArray"; 7 | export * as O from "fp-ts/lib/Option"; 8 | export { pipe } from "fp-ts/lib/pipeable"; 9 | export * as R from "fp-ts/lib/Reader"; 10 | export * as Rec from "fp-ts/lib/Record"; 11 | export * as RA from "fp-ts/lib/ReadonlyArray"; 12 | export * as RD from "@devexperts/remote-data-ts"; 13 | export * as RT from "fp-ts/lib/ReaderTask"; 14 | export * as RTE from "fp-ts/lib/ReaderTaskEither"; 15 | export * as T from "fp-ts/lib/Task"; 16 | export * as TE from "fp-ts/lib/TaskEither"; 17 | -------------------------------------------------------------------------------- /src/util/typeGuards.ts: -------------------------------------------------------------------------------- 1 | export type Nullable = A | null; 2 | 3 | export type Undefinedable = A | undefined; 4 | 5 | export type Nil = null | undefined; 6 | 7 | export type Nilable = A | Nil; 8 | 9 | export const isNull = (x: unknown): x is null => x === null; 10 | 11 | export const isUndefined = (x: unknown): x is undefined => x === undefined; 12 | 13 | export const isNil = (x: unknown): x is Nil => isNull(x) || isUndefined(x); 14 | 15 | export const isNotUndefined = ( 16 | x: Undefinedable 17 | ): x is Exclude => !isUndefined(x); 18 | 19 | export const isNotNull = (x: Nullable): x is Exclude => 20 | !isNull(x); 21 | 22 | export const isNotNil = (x: Nilable): x is Exclude => !isNil(x); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------