diff --git a/.editorconfig b/.editorconfig index 6ce19cb..a4eba99 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,2 @@ -root = true - -[*.{fs, fsx}] +[*.{fs,fsx}] fsharp_multiline_bracket_style = stroustrup \ No newline at end of file diff --git a/README.md b/README.md index 54eb47e..3e4d86e 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,5 @@ npm start ``` ## Inspirations -- [Fable REPL](https://github.com/fable-compiler/repl) -- [Gleam Language Tour](https://tour.gleam.run) \ No newline at end of file +- [Gleam Language Tour](https://tour.gleam.run) +- [Learn You a Haskell](https://learnyouahaskell.github.io) \ No newline at end of file diff --git a/public/css/pico.min.css b/public/css/pico.min.css deleted file mode 100644 index 738a156..0000000 --- a/public/css/pico.min.css +++ /dev/null @@ -1,4 +0,0 @@ -@charset "UTF-8";/*! - * Pico CSS ✨ v2.0.3 (https://picocss.com) - * Copyright 2019-2024 - Licensed under MIT - */:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:root{--pico-font-size:106.25%}}@media (min-width:768px){:root{--pico-font-size:112.5%}}@media (min-width:1024px){:root{--pico-font-size:118.75%}}@media (min-width:1280px){:root{--pico-font-size:125%}}@media (min-width:1536px){:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:root:not([data-theme=dark]),[data-theme=light]{--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:#e7eaf0;--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:#fde7c0;--pico-mark-color:#0f1114;--pico-ins-color:#1d6a54;--pico-del-color:#883935;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#f3f5f7;--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#fbfcfc;--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#b86a6b;--pico-form-element-invalid-active-border-color:#c84f48;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#4c9b8a;--pico-form-element-valid-active-border-color:#279977;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#fbfcfc;--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 155, 138)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200, 79, 72)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:light}:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme]){--pico-background-color:#13171f;--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 9, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 9, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 9, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 9, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 9, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 9, 12, 0.06),0 0 0 0.0625rem rgba(7, 9, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:#ce7e7b;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#1a1f28;--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#1c212c;--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:#1a1f28;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#964a50;--pico-form-element-invalid-active-border-color:#b7403b;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:#16896a;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#1a1f28;--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(8, 9, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:dark}:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{--pico-background-color:#13171f;--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 9, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 9, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 9, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 9, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 9, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 9, 12, 0.06),0 0 0 0.0625rem rgba(7, 9, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:#ce7e7b;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#1a1f28;--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#1c212c;--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:#1a1f28;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#964a50;--pico-form-element-invalid-active-border-color:#b7403b;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:#16896a;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#1a1f28;--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(8, 9, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:dark}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;width:calc(1.25em - var(--pico-border-width) * 2);height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(1.125em - var(--pico-border-width))}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown summary::after,details.dropdown>a::after,details.dropdown>button::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown summary:not([role]):active,details.dropdown summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown summary:not([role]):focus-visible{outline:0}details.dropdown summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown summary::after{transform:rotate(0) translateX(0)}nav details.dropdown summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown summary+ul[dir=rtl]{right:0;left:auto}details.dropdown summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown summary+ul li a:active,details.dropdown summary+ul li a:focus,details.dropdown summary+ul li a:focus-visible,details.dropdown summary+ul li a:hover,details.dropdown summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown summary+ul li label{width:100%}details.dropdown summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open] summary{margin-bottom:0}details.dropdown[open] summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open] summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1rem auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog article{max-width:510px}}@media (min-width:768px){dialog article{max-width:700px}}dialog article>header>*{margin-bottom:0}dialog article>header .close,dialog article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog article>footer{text-align:right}dialog article>footer [role=button],dialog article>footer button{margin-bottom:0}dialog article>footer [role=button]:not(:first-of-type),dialog article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog article .close,dialog article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}} \ No newline at end of file diff --git a/public/documentation/basics/conditional-expressions.fsx b/public/documentation/basics/conditional-expressions.fsx deleted file mode 100644 index 87a5921..0000000 --- a/public/documentation/basics/conditional-expressions.fsx +++ /dev/null @@ -1,9 +0,0 @@ -let number = 20 - -let fizzBuzz = - if number % 15 = 0 then "FizzBuzz" - elif number % 5 = 0 then "Buzz" - elif number % 3 = 0 then "Fizz" - else string number - -printfn "%s" fizzBuzz diff --git a/public/documentation/basics/conditional-expressions.md b/public/documentation/basics/conditional-expressions.md deleted file mode 100644 index a32d03d..0000000 --- a/public/documentation/basics/conditional-expressions.md +++ /dev/null @@ -1,21 +0,0 @@ -# Conditional Expressions - -Conditional expressions are a control flow mechanism to evaluate one of many expressions based on boolean values. If a boolean expressions evaluates to `true`, the associated expression will be evaluated. - -```fsharp -let age = 20 -let ageDescription = if age >= 18 then "Adult" else "Juvenile" -printfn "%s" ageDescription -``` - -The above conditional expression will evaluate to `"Adult"` if `age >= 18`, otherwise it will evaluate to `"Juvenile"`. Conditional expressions can include multiple conditions by including `elif` branches, which is an `else` branch with `if` condition. - -```fsharp -let number = 20 - -let fizzBuzz = - if number % 15 = 0 then "FizzBuzz" - elif number % 5 = 0 then "Buzz" - elif number % 3 = 0 then "Fizz" - else string number -``` diff --git a/public/documentation/basics/expressions.fsx b/public/documentation/basics/expressions.fsx deleted file mode 100644 index 51b8801..0000000 --- a/public/documentation/basics/expressions.fsx +++ /dev/null @@ -1,5 +0,0 @@ -let ten = - let five = 5 - five + five - -let twenty = ten + ten diff --git a/public/documentation/basics/expressions.md b/public/documentation/basics/expressions.md deleted file mode 100644 index 7c7e6b0..0000000 --- a/public/documentation/basics/expressions.md +++ /dev/null @@ -1,57 +0,0 @@ -# Expressions - -The primary piece of F# syntax is an expression. An expression is simply, a block of code that when evaluated, produces a value. - -_Let bindings_ allow you to bind the result of an expression to a name. - -```fsharp -let number = 5 -``` - -Everything on the right-hand side of the equal sign is an expression. The last expression within an expression block is what produces a result for the expression as a whole. - -```fsharp -let ten = - let five = 5 - five + five -``` - -As you may notice, the `ten` binding doesn't include a type annotation. This is because the F# compiler can infer the types of values from their usage. In this case, `ten` is inferred as an `int` although it can easily be several other numerical types. If the compiler doesn't have enough information to infer your desired type, you can add a type annotation to override it. - -```fsharp -let ten: float = 10 -``` - -By default, let bindings are immutable. Immutability is highly preferred as it ensures that values won't be changed unexpectedly and results in code that's easier to reason about. - -In F#, code is evaluated from top to bottom which has some interesting side effects. You can only reference bindings, functions, or types that are defined above the current definition. - -```fsharp -ten + ten -let ten = 10 -// `ten` is defined below its usage which results in a compiler error. -``` - -The advantage of top-down evaluation is that the flow of your application's code is easier to reason about. Code is read and evaluated from top to bottom sequentially, from a higher to a lower level, from core components to specific implementation details. We can clearly understand and reason about our dependent modules, types, functions and their dependencies. - -This top-down evaluation also applies to the ordering of files within a project. You can only use functions, types, and modules defined in other files if the file is ordered above the current definition. - -``` -1. Logic.fs -2. Program.fs -``` - -Here, any code in `Program.fs` can access modules, types, functions, and bindings in `Logic.fs`, but not the other way around. This is because `Logic.fs` is ordered above `Program.fs`. - -F# relies on the level of indentation to determine the beginning and end of an expression. You may be familiar with this if you've programmed in languages with syntatic indentation before. When writing an expression block, the level of indentation must be consistent for each expression within that block. - -```fsharp -let ten = - let five = 5 // <--- - five + five // <--- - // this block has consistent indentation levels. - -let twenty = ten + ten -``` - -Here, the compiler can distinguish between the beginning and the end of the expression. Both of these lines are indented consistently with 4 spaces and are terminated by the subsequent non-indented expression. \ No newline at end of file diff --git a/public/documentation/basics/hello-world.fsx b/public/documentation/basics/hello-world.fsx deleted file mode 100644 index 70fa638..0000000 --- a/public/documentation/basics/hello-world.fsx +++ /dev/null @@ -1 +0,0 @@ -printfn "Hello, World!" diff --git a/public/documentation/basics/hello-world.md b/public/documentation/basics/hello-world.md deleted file mode 100644 index f27c004..0000000 --- a/public/documentation/basics/hello-world.md +++ /dev/null @@ -1,7 +0,0 @@ -# Hello, World! - -Here is a program that prints out the text `"Hello, World!"`. - -In a normal F# program, this would be executed by using the command `dotnet run` on the command line. - -Try changing the text being printed to `"Hello!"` and click the `Run` button at the top right to see what happens. \ No newline at end of file diff --git a/public/documentation/basics/operators.fsx b/public/documentation/basics/operators.fsx deleted file mode 100644 index 4431b57..0000000 --- a/public/documentation/basics/operators.fsx +++ /dev/null @@ -1,17 +0,0 @@ -let ten = 5 + 5 -printfn "5 + 5 = %d" ten - -let four = 8 - 4 -printfn "8 - 4 = %d" four - -let five = 10 / 2 -printfn "10 / 2 = %d" five - -let twenty = 10 * 2 -printfn "10 * 2 = %d" twenty - -let remainder = 10 % 2 -printfn "10 %% 2 = %d" remainder - -let squared = 10.0 ** 2.0 -printfn "10.0 ** 2.0 = %f" squared diff --git a/public/documentation/basics/operators.md b/public/documentation/basics/operators.md deleted file mode 100644 index eec88cc..0000000 --- a/public/documentation/basics/operators.md +++ /dev/null @@ -1,33 +0,0 @@ -# Operators - -Operators are special functions that can be applied to several types. Unlike other functions, operators can be infixed (placed between two parameters). - -The arithmetic operators include: `+`, `-`, `/`, `*`, `%`, `**`. - -```fsharp -let ten = 5 + 5 // add two numbers. -let four = 8 - 4 // subtract two numbers. -let five = 10 / 2 // divide two numbers. -let twenty = 10 * 2 // multiply two numbers. -let remainder = 10 % 2 // remainder after division. -let squared = 10.0 ** 2.0 // 10 to the power of 2 -``` - -The comparison operators include: `=`, `<>`, `>`, `<`, `>=`, `<=`. These operators compare two values and return a boolean result: `true` or `false`. - -```fsharp -let equals = 5 = 5 // is 5 equal to 5? -let notEquals = 5 <> 5 // is 5 not equal to 5? -let greaterThan = 5 > 5 // is 5 greater than 5? -let lessThan = 5 < 5 // is 5 less than 5? -let greaterThanOrEqualTo = 5 >= 5 // is 5 greater than or equal to 5 -let lessThanOrEqualTo = 5 <= 5 // is 5 less than or equal to 5 -``` - -Although these operators are used with numbers here, some of them apply to multiple types, like `+`, `=`, and `<>`. - -```fsharp -let helloWorld = "Hello, " + "World!" -let areStringsEqual = "Hello" = "Hello" -let areStringsNotEqual = "Hello" <> "Hello, World!" -``` \ No newline at end of file diff --git a/public/documentation/basics/patterns-and-match-expressions.fsx b/public/documentation/basics/patterns-and-match-expressions.fsx deleted file mode 100644 index 010b11d..0000000 --- a/public/documentation/basics/patterns-and-match-expressions.fsx +++ /dev/null @@ -1,10 +0,0 @@ -let number = 15 - -let fizzBuzz = - match number with - | number when number % 15 = 0 -> "FizzBuzz" - | number when number % 5 = 0 -> "Buzz" - | number when number % 3 = 0 -> "Fizz" - | number -> string number - -printfn "%s" fizzBuzz diff --git a/public/documentation/basics/patterns-and-match-expressions.md b/public/documentation/basics/patterns-and-match-expressions.md deleted file mode 100644 index 8b82da4..0000000 --- a/public/documentation/basics/patterns-and-match-expressions.md +++ /dev/null @@ -1,62 +0,0 @@ -# Patterns and Match Expressions - -Pattern matching allows you to match a value against patterns, which act as rules for their transformation. These patterns can bind values to names and deconstruct or decompose values into their constituent parts. - -To demonstrate pattern matching, let's get started with a pattern you're already familiar with: the _variable_ pattern. This pattern allows you to bind a value to a name like so: `let five = 5`. That's right, you've been using the variable pattern the whole time! Just like how everything on the right-hand side of the equals sign in a binding is an expression, the left-hand side is always a pattern. - -```fsharp -let = -``` - -This is limited as the pattern needs to be exhaustive. Exhaustivity means that every possible value is accounted for by the given pattern. Because the variable pattern will bind any value to a name, it will always match against a value and is therefore exhaustive. However, not all patterns are exhaustive and you may want to attempt to match a value against a set of patterns. You can use match expressions to do this. You can declare a match expression like so: - -```fsharp -let number = 10 -let result = - match number with - | 10 -> "The number is Ten" - | number -> $"The number is not ten, but instead: {number}" -``` - -Here you can see two patterns in action: the _constant_ and _variable_ patterns. The _constant_ pattern will match a value against a constant value like `10` or `"Hello, World!"`. This match expression is exhaustive as the last branch utilizes the _variable_ pattern which will always match against the value. - -When you don't care about the value but still require exhaustivity, you can use the _wildcard_ pattern to discard it. - -```fsharp -let number = 10 -let result = - match number with - | 10 -> "The number is Ten" - | _ -> $"The value was discarded" -``` - -A branch in a match expression can also include a conditional expression. This is often used in conjunction with the _variable_ pattern to check if the bound value passes a conditional check. - -```fsharp -let number = 10 -let result = - match number with - | number when number % 2 = 0 -> $"{number} is even" - | number -> $"{number} is odd" -``` - -Two patterns can lead to the same expression being evaluated using the _OR_ pattern. - -```fsharp -let number = 10 -let result = - match number with - | pattern1 - | pattern2 -> "" - | _ -> "" -``` - -You can require that a value matches against two or more patterns using the _AND_ pattern. - -```fsharp -let number = 10 -let result = - match number with - | pattern1 & pattern2 -> "" - | _ -> "" -``` \ No newline at end of file diff --git a/public/documentation/basics/primitive-types.fsx b/public/documentation/basics/primitive-types.fsx deleted file mode 100644 index e9f5567..0000000 --- a/public/documentation/basics/primitive-types.fsx +++ /dev/null @@ -1,6 +0,0 @@ -let int = 10 -let float = 10.0 -let char = 'a' -let string = "Hello, World!" -let boolean = true -let unit = () diff --git a/public/documentation/basics/primitive-types.md b/public/documentation/basics/primitive-types.md deleted file mode 100644 index 15c3407..0000000 --- a/public/documentation/basics/primitive-types.md +++ /dev/null @@ -1,22 +0,0 @@ -# Primitive Types - -The fundamental types used in most F# programs are `int`, `float`, `bool`, `char`, `string`, and `unit`. -These types are called primitives, as they are basic data types that contain simple values and are the foundation for other, more complex types. - -What do these primitive types represent? - -- `int` represents a 32-bit numerical value, that is, a number without a decimal point. -- `float` represents a 64-bit double-precision floating-point numerical value, that is, a number _with_ a decimal point. -- `char` represents a Unicode character value, like an individual letter or emoji. -- `string` represents a sequence of `char` values, that is, a piece of text. -- `bool` represents a `true` or `false` value, often used in conditional logic. -- `unit`, when passed to a function, means that function has no arguments. When returned from a function, it indicates that the function has no useful return value. Often used when a function e.g. prints to the screen and does nothing else. - -```fsharp -10 // int -10.0 // float -'a' // char -"Hello, World!" // string -true // bool -() // unit -``` diff --git a/public/documentation/basics/string-formatting.fsx b/public/documentation/basics/string-formatting.fsx deleted file mode 100644 index 6fdba51..0000000 --- a/public/documentation/basics/string-formatting.fsx +++ /dev/null @@ -1,3 +0,0 @@ -let name = "John Doe" -let greeting = $"Hello, {name}!" -printfn "%s" greeting diff --git a/public/documentation/basics/string-formatting.md b/public/documentation/basics/string-formatting.md deleted file mode 100644 index 2ed04e6..0000000 --- a/public/documentation/basics/string-formatting.md +++ /dev/null @@ -1,37 +0,0 @@ -# String Formatting - -String formatting is the process of integrating additional values into string literals. - -```fsharp -let name = "John Doe" -sprintf "Your name is %s" name // "Your name is John Doe" -``` - -Here, we specify that the string format contains a single string value, indicated by the `%s` format specifier. - -Other common format specifiers include: -`%b` for boolean values. -`%d` for integer values. -`%f` for floating point values. -`%O` which uses the values string representation (calls `value.ToString()`). -`%A` which uses a structured plain-text representation. - -The `sprintf`, `printf`, and `printfn` all take format specifiers like the ones above. The `sprintf` function will create a string value from a template while the `printf` and `printfn` functions will print values to the standard output, the latter adding a newline at the end. - -```fsharp -printfn "Hello, %s!" name -``` - -Another method is to use interpolated strings which allow you to bake the values directly into a string literal. - -```fsharp -let name = "John Doe" -$"Your name is {name}" // "Your name is John Doe" -``` - -These interpolated strings can also be type checked by providing a format before the template. This will result in a compiler error if the type of the value doesn't match the format specifier. - -```fsharp -$"Your name is %s{name}" // this works. -$"Your name is %b{name}" // compiler error. %b = boolean -``` \ No newline at end of file diff --git a/public/documentation/bookshop-domain/41-domain-ids.fsx b/public/documentation/bookshop-domain/41-domain-ids.fsx new file mode 100644 index 0000000..1469b81 --- /dev/null +++ b/public/documentation/bookshop-domain/41-domain-ids.fsx @@ -0,0 +1,16 @@ +type BookId = BookId of int +type CustomerId = CustomerId of int +type OrderId = OrderId of int + +type IdError = NonPositiveId + +let createBookId value = + if value > 0 then Ok(BookId value) else Error NonPositiveId + +let displayBookId (BookId value) = $"B-%d{value}" + +match createBookId 42 with +| Ok id -> printfn "Created %s" (displayBookId id) +| Error NonPositiveId -> printfn "An ID must be positive" + +// Try passing CustomerId 42 to displayBookId and read the type error. diff --git a/public/documentation/bookshop-domain/41-domain-ids.md b/public/documentation/bookshop-domain/41-domain-ids.md new file mode 100644 index 0000000..41501b2 --- /dev/null +++ b/public/documentation/bookshop-domain/41-domain-ids.md @@ -0,0 +1,76 @@ +# Domain-specific identifiers + +## What you will learn + +Use single-case unions to stop identifiers with the same primitive representation from being mixed accidentally. + +Our catalog, customers, and orders will all need identifiers. Using `int` for every one makes this mistake legal: + +```fsharp +let findBook (bookId: int) catalog = + catalog |> Map.tryFind bookId + +let customerId = 12 +findBook customerId catalog +``` + +The compiler sees only two integers. The domain sees two different categories. + +## Give each identifier its own type + +```fsharp +type BookId = BookId of int +type CustomerId = CustomerId of int +type OrderId = OrderId of int +``` + +`BookId 12` and `CustomerId 12` carry the same primitive value but have different F# types. A function requiring `BookId` rejects `CustomerId` before the program runs. + +```fsharp +let findBook (bookId: BookId) catalog = + catalog |> Map.tryFind bookId +``` + +The union case constructs a wrapped value. A pattern unwraps it when primitive behavior is needed: + +```fsharp +let displayBookId (BookId value) = + $"B-%d{value}" +``` + +Most domain functions should pass and compare `BookId` values without unwrapping them. + +## A type abbreviation is different + +```fsharp +type BookNumber = int +``` + +This gives `int` another name without creating a distinct type. The vocabulary improves, but the compiler still permits mixing. Use an abbreviation when two names describe the same values and a wrapper when exchanging them would be a bug. + +## Validate construction + +```fsharp +type IdError = NonPositiveId + +let createBookId value = + if value > 0 then + Ok (BookId value) + else + Error NonPositiveId +``` + +Because the union case is still public, a caller can bypass the function and write `BookId -1`. Modules will soon let us make the case private and turn the constructor into the only public route. + +Be precise about the current guarantee. The wrapper proves “this is a book identifier.” A value returned by `createBookId` has also passed the positive-number rule. + +## Experiment + +- Pass a `CustomerId` to `findBook` and read the two domain type names in the error. +- Add a `CartId` wrapper. +- Write display functions for each identifier. +- Change `createBookId` to reject values above a chosen limit. + +## Summary + +A wrapper union adds domain identity to primitive data. It is worthwhile when accidentally exchanging two values would be plausible and harmful. diff --git a/public/documentation/bookshop-domain/42-catalog.fsx b/public/documentation/bookshop-domain/42-catalog.fsx new file mode 100644 index 0000000..e13b1d1 --- /dev/null +++ b/public/documentation/bookshop-domain/42-catalog.fsx @@ -0,0 +1,58 @@ +type BookId = BookId of int +type Isbn = Isbn of string + +type Author = { Name: string } + +type Genre = + | Fiction + | History + | Science + +type Book = { + Id: BookId + Isbn: Isbn + Title: string + Authors: Author list + Genres: Set + PriceInCents: int +} + +type Catalog = Map + +let all catalog = + catalog |> Map.toList |> List.map (fun (_, book) -> book) + +let normalize (text: string) = text.Trim().ToLowerInvariant() + +let titleContains query book = + let wanted = normalize query + let title = normalize book.Title + title.Contains(wanted) + +let search query catalog = + catalog |> all |> List.filter (titleContains query) + +let books = [ + { + Id = BookId 1 + Isbn = Isbn "9780807083697" + Title = "Kindred" + Authors = [ { Name = "Octavia E. Butler" } ] + Genres = Set.ofList [ Fiction ] + PriceInCents = 1299 + } + { + Id = BookId 2 + Isbn = Isbn "9780441478125" + Title = "A Wizard of Earthsea" + Authors = [ { Name = "Ursula K. Le Guin" } ] + Genres = Set.ofList [ Fiction ] + PriceInCents = 1099 + } +] + +let catalog = books |> List.map (fun book -> book.Id, book) |> Map.ofList + +printfn "Search results: %A" (search " earth " catalog) + +// Try adding a History book and searching for part of its title. diff --git a/public/documentation/bookshop-domain/42-catalog.md b/public/documentation/bookshop-domain/42-catalog.md new file mode 100644 index 0000000..f6c16ff --- /dev/null +++ b/public/documentation/bookshop-domain/42-catalog.md @@ -0,0 +1,89 @@ +# Modeling the bookshop catalog + +## What you will learn + +Combine records, unions, maps, sets, wrappers, and pure queries into one coherent catalog. + +The earlier `Book` records were deliberately small. A shop now needs stable identity, an ISBN, authors, genres, and a price: + +```fsharp +type BookId = BookId of int +type Isbn = Isbn of string + +type Author = + { Name: string } + +type Genre = + | Fiction + | History + | Science + +type Book = + { + Id: BookId + Isbn: Isbn + Title: string + Authors: Author list + Genres: Set + PriceInCents: int + } +``` + +Money is represented as integer cents in this teaching domain. That avoids introducing binary floating-point rounding into order totals. A production .NET system might instead choose `decimal` or a dedicated money type and would document its rounding and currency policy explicitly. + +## Index by stable identity + +```fsharp +type Catalog = Map + +let add book catalog = + catalog |> Map.add book.Id book + +let find bookId catalog = + catalog |> Map.tryFind bookId +``` + +`Catalog` is a type abbreviation because it names an existing map shape. `BookId` remains a wrapper because confusing it with another identifier would be a bug. + +`add` returns a successor catalog; the original map remains available. `find` returns `Book option` because an ID may be absent. + +## Search is a transformation, not a mutation + +```fsharp +let all catalog = + catalog + |> Map.toList + |> List.map (fun (_, book) -> book) + +let normalize (text: string) = + text.Trim().ToLowerInvariant() + +let titleContains query book = + let wanted = normalize query + let title = normalize book.Title + title.Contains(wanted) + +let search query catalog = + catalog + |> all + |> List.filter (titleContains query) +``` + +The query produces a list view and leaves the indexed catalog unchanged. Search can later grow to inspect authors or genres without changing how books are stored. + +## Preserve useful distinctions + +Authors stay as records, ISBN stays distinct from plain text, and genres stay in a set because duplicates have no meaning. The model grows by preserving guarantees we have already earned. + +Stock belongs outside `Book` because catalog metadata and inventory change for different reasons. The next lesson gives inventory its own model. + +## Experiment + +- Add another author and another genre. +- Search with different capitalization and surrounding spaces. +- Look up a missing ID and handle `None`. +- Add a second book without changing the query functions. + +## Summary + +A catalog is an immutable index of explicit book values. Queries derive views; updates return a new index; separate concepts such as inventory remain separate. diff --git a/public/documentation/bookshop-domain/43-customers.fsx b/public/documentation/bookshop-domain/43-customers.fsx new file mode 100644 index 0000000..e6b5751 --- /dev/null +++ b/public/documentation/bookshop-domain/43-customers.fsx @@ -0,0 +1,39 @@ +type CustomerId = CustomerId of int +type EmailAddress = EmailAddress of string + +type Membership = + | Standard + | Member of discountPercent: int + +type Customer = { + Id: CustomerId + Name: string + Email: EmailAddress option + Membership: Membership +} + +type EmailError = InvalidEmail + +let createEmail (text: string) = + let cleaned = text.Trim() + + if cleaned.Contains("@") then + Ok(EmailAddress cleaned) + else + Error InvalidEmail + +let discountPercent customer = + match customer.Membership with + | Standard -> 0 + | Member percent -> percent + +let customer = { + Id = CustomerId 1 + Name = "Ada" + Email = Some(EmailAddress "ada@example.org") + Membership = Member 10 +} + +printfn "%s receives a %d%% discount" customer.Name (discountPercent customer) + +// Try Standard membership, then create an invalid email through createEmail. diff --git a/public/documentation/bookshop-domain/43-customers.md b/public/documentation/bookshop-domain/43-customers.md new file mode 100644 index 0000000..71d2d1e --- /dev/null +++ b/public/documentation/bookshop-domain/43-customers.md @@ -0,0 +1,90 @@ +# Customers and validated contact information + +## What you will learn + +Model stable customer identity, optional validated contact information, and membership without nullable fields or contradictory flags. + +```fsharp +type CustomerId = CustomerId of int +type EmailAddress = EmailAddress of string + +type Membership = + | Standard + | Member of discountPercent: int + +type Customer = + { + Id: CustomerId + Name: string + Email: EmailAddress option + Membership: Membership + } +``` + +An email may be absent, but any present email has the `EmailAddress` type. At this stage the union case is public, so the wrapper distinguishes an email string but does not yet prove that construction used `createEmail`. Lesson 49 will use module privacy to enforce that route. Membership is exactly one case; a member case carries the discount that belongs to that status. + +## Convert loose input into a domain value + +```fsharp +type EmailError = InvalidEmail + +let createEmail (text: string) = + let cleaned = text.Trim() + + if cleaned.Contains("@") then + Ok (EmailAddress cleaned) + else + Error InvalidEmail +``` + +This small check is not a complete implementation of the email standard. Its purpose is to show how successful construction can return a more meaningful type. + +```fsharp +createEmail "ada@example.org" +// Ok (EmailAddress "ada@example.org") + +createEmail "not-an-address" +// Error InvalidEmail +``` + +Code that receives the successful result from `createEmail` does not need to repeat this boundary check. Until construction is made private, other `EmailAddress` values may still bypass it. + +## Derive policy rather than storing another flag + +```fsharp +let discountPercent customer = + match customer.Membership with + | Standard -> 0 + | Member percent -> percent +``` + +Adding an `IsMember` boolean next to `Membership` would create two facts that could disagree. Keep the union authoritative and derive the numeric policy with a function. The current `Member of int` case still permits negative or excessive percentages; a validated constructor can enforce a range once construction is private. + +Customer identity remains stable when membership changes: + +```fsharp +let joinMembership percent customer = + { customer with Membership = Member percent } +``` + +The original customer remains unchanged. + +## Optional and validated are separate ideas + +When values are created through `createEmail`, `EmailAddress option` expresses two facts: + +- contact information may legitimately be absent; +- present contact information passed the construction rule. + +A plain `string option` expresses only the first fact. The capstone keeps the wrapper so that validated contact information remains distinct from unchecked text. + +## Experiment + +- Create a customer without an email. +- Give a standard customer a 10-percent membership. +- Pattern match to produce a membership label. +- Pass an invalid string through `createEmail` and handle the error. + +## Summary + +Validated wrappers strengthen primitive values; options model legitimate absence; unions keep mutually exclusive customer states explicit. diff --git a/public/documentation/bookshop-domain/44-inventory.fsx b/public/documentation/bookshop-domain/44-inventory.fsx new file mode 100644 index 0000000..1c223a6 --- /dev/null +++ b/public/documentation/bookshop-domain/44-inventory.fsx @@ -0,0 +1,48 @@ +type BookId = BookId of int + +type Stock = { + BookId: BookId + OnHand: int + Reserved: int +} + +type InventoryError = + | BookNotStocked + | InvalidQuantity + | InsufficientStock of available: int + +let available stock = stock.OnHand - stock.Reserved + +let reserve quantity stock = + if quantity <= 0 then + Error InvalidQuantity + elif quantity > available stock then + Error(InsufficientStock(available stock)) + else + Ok { + stock with + Reserved = stock.Reserved + quantity + } + +let reserveBook bookId quantity inventory = + match inventory |> Map.tryFind bookId with + | None -> Error BookNotStocked + | Some stock -> + reserve quantity stock + |> Result.map (fun updated -> inventory |> Map.add bookId updated) + +let bookId = BookId 1 + +let inventory = + Map.ofList [ + (bookId, + { + BookId = bookId + OnHand = 5 + Reserved = 1 + }) + ] + +printfn "Reservation: %A" (reserveBook bookId 2 inventory) + +// Try reserving five copies and inspect the explicit error. diff --git a/public/documentation/bookshop-domain/44-inventory.md b/public/documentation/bookshop-domain/44-inventory.md new file mode 100644 index 0000000..c86cadb --- /dev/null +++ b/public/documentation/bookshop-domain/44-inventory.md @@ -0,0 +1,85 @@ +# Inventory and stock levels + +## What you will learn + +Represent inventory separately from catalog metadata and express stock changes as checked transformations. + +A book's title and ISBN rarely change. Its stock changes whenever copies arrive or orders reserve them. One record should not pretend those facts have the same lifecycle. + +```fsharp +type BookId = BookId of int + +type Stock = + { + BookId: BookId + OnHand: int + Reserved: int + } + +type Inventory = Map +``` + +Available quantity is derived: + +```fsharp +let available stock = + stock.OnHand - stock.Reserved +``` + +Storing `Available` as another field would allow it to disagree with the two authoritative counts. + +This public record can still be constructed with negative counts or with `Reserved` greater than `OnHand`. The transition functions below preserve sensible values when they start from sensible stock, but the type itself does not yet enforce that starting invariant. Private validated construction would be needed for that stronger guarantee. + +## Checked stock changes + +```fsharp +type StockError = + | InvalidQuantity + | InsufficientStock of available: int + +let reserve quantity stock = + if quantity <= 0 then + Error InvalidQuantity + elif quantity > available stock then + Error (InsufficientStock (available stock)) + else + Ok { stock with Reserved = stock.Reserved + quantity } +``` + +The success case contains a complete successor value. On failure, no successor is returned; the original stock value is unchanged in either case. + +Releasing stock is another transition: + +```fsharp +let release quantity stock = + if quantity <= 0 || quantity > stock.Reserved then + Error InvalidQuantity + else + Ok { stock with Reserved = stock.Reserved - quantity } +``` + +The same `InvalidQuantity` case covers several invalid inputs in this small model. A richer domain could distinguish negative quantities from releasing more than was reserved. + +## Update the inventory map + +```fsharp +let reserveBook bookId quantity inventory = + match inventory |> Map.tryFind bookId with + | None -> Error BookNotStocked + | Some stock -> + reserve quantity stock + |> Result.map (fun updated -> inventory |> Map.add bookId updated) +``` + +The map handles lookup and replacement. The `reserve` function owns the arithmetic rule. Keeping those concerns separate lets the stock rule be understood without an inventory map. + +## Experiment + +- Reserve one available copy and inspect old and new stock. +- Request more than is available. +- Add a `restock` function for a positive delivery quantity. +- Explain why `Available` should remain a function rather than a stored field. + +## Summary + +Inventory is an index of stock values. Each operation checks the requested change and returns an updated stock or inventory value, leaving no hidden mutation behind. diff --git a/public/documentation/bookshop-domain/45-shopping-carts.fsx b/public/documentation/bookshop-domain/45-shopping-carts.fsx new file mode 100644 index 0000000..a52c087 --- /dev/null +++ b/public/documentation/bookshop-domain/45-shopping-carts.fsx @@ -0,0 +1,48 @@ +type BookId = BookId of int + +type CartLine = { BookId: BookId; Quantity: int } + +type Cart = { Lines: CartLine list } + +type CartError = InvalidQuantity + +let addLine bookId quantity cart = + if quantity <= 0 then + Error InvalidQuantity + else + match cart.Lines |> List.tryFind (fun line -> line.BookId = bookId) with + | None -> + Ok { + cart with + Lines = { BookId = bookId; Quantity = quantity } :: cart.Lines + } + | Some existing -> + let updatedLines = + cart.Lines + |> List.map (fun line -> + if line.BookId = bookId then + { + line with + Quantity = existing.Quantity + quantity + } + else + line) + + Ok { cart with Lines = updatedLines } + +let removeLine bookId cart = { + cart with + Lines = cart.Lines |> List.filter (fun line -> line.BookId <> bookId) +} + +let emptyCart = { Lines = [] } + +let result = + emptyCart + |> addLine (BookId 1) 1 + |> Result.bind (addLine (BookId 2) 2) + |> Result.bind (addLine (BookId 1) 3) + +printfn "Cart: %A" result + +// Try adding a zero quantity, then remove BookId 2 from a successful cart. diff --git a/public/documentation/bookshop-domain/45-shopping-carts.md b/public/documentation/bookshop-domain/45-shopping-carts.md new file mode 100644 index 0000000..05c64f5 --- /dev/null +++ b/public/documentation/bookshop-domain/45-shopping-carts.md @@ -0,0 +1,89 @@ +# Shopping carts as immutable transformations + +## What you will learn + +Build a cart from records and lists, with functions that add, change, and remove lines without hidden mutation. + +```fsharp +type BookId = BookId of int + +type CartLine = + { + BookId: BookId + Quantity: int + } + +type Cart = + { Lines: CartLine list } +``` + +The cart contains intent: which books the customer wants and in what quantity. Current price and stock remain outside because they can change independently and must be checked at checkout. + +## Add one book + +```fsharp +type CartError = InvalidQuantity + +let addLine bookId quantity cart = + if quantity <= 0 then + Error InvalidQuantity + else + match cart.Lines |> List.tryFind (fun line -> line.BookId = bookId) with + | None -> + Ok { cart with Lines = { BookId = bookId; Quantity = quantity } :: cart.Lines } + | Some existing -> + let updatedLines = + cart.Lines + |> List.map (fun line -> + if line.BookId = bookId then + { line with Quantity = existing.Quantity + quantity } + else + line) + + Ok { cart with Lines = updatedLines } +``` + +There are two successful shapes: construct a new line, or replace the matching line with an updated copy. Neither branch changes the original list. + +## Remove and change quantities + +```fsharp +let removeLine bookId cart = + { + cart with + Lines = cart.Lines |> List.filter (fun line -> line.BookId <> bookId) + } +``` + +Removing a missing line currently returns an equivalent cart. If callers need to distinguish “removed” from “not present,” return `Result` or a tuple carrying that information. + +Quantity changes can reuse removal for zero: + +```fsharp +let changeQuantity bookId quantity cart = + if quantity < 0 then + Error InvalidQuantity + elif quantity = 0 then + Ok (removeLine bookId cart) + else + let updated = + cart.Lines + |> List.map (fun line -> + if line.BookId = bookId then { line with Quantity = quantity } + else line) + + Ok { cart with Lines = updated } +``` + +This simple version leaves a missing book unchanged. An alternative API could return `LineNotFound`; the right behavior depends on what callers need to know. + +## Experiment + +- Add the same book twice and predict its quantity. +- Add a second book and remove the first. +- Change a quantity to zero. +- Decide whether changing a missing line should be silent or explicit, then model that choice. + +## Summary + +A cart is immutable data. Its operations receive the current value and return either a complete successor or an explicit refusal. diff --git a/public/documentation/bookshop-domain/46-pricing-and-discounts.fsx b/public/documentation/bookshop-domain/46-pricing-and-discounts.fsx new file mode 100644 index 0000000..3dc9d64 --- /dev/null +++ b/public/documentation/bookshop-domain/46-pricing-and-discounts.fsx @@ -0,0 +1,53 @@ +type BookId = BookId of int +type Money = Money of cents: int + +type Book = { + Id: BookId + Title: string + Price: Money +} + +type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money +} + +type Discount = + | NoDiscount + | PercentOff of int + +let add (Money left) (Money right) = Money(left + right) + +let multiply quantity (Money unitPrice) = Money(quantity * unitPrice) + +let priceLine book quantity = { + BookId = book.Id + Title = book.Title + Quantity = quantity + UnitPrice = book.Price + LineTotal = multiply quantity book.Price +} + +let subtotal lines = + lines |> List.fold (fun total line -> add total line.LineTotal) (Money 0) + +let applyDiscount discount (Money amount) = + match discount with + | NoDiscount -> Money amount + | PercentOff percent -> Money(amount * (100 - percent) / 100) + +let book = { + Id = BookId 1 + Title = "Kindred" + Price = Money 1299 +} + +let lines = [ priceLine book 2 ] +let total = lines |> subtotal |> applyDiscount (PercentOff 10) + +printfn "Discounted total: %A" total + +// Predict the cents for three copies before changing the quantity. diff --git a/public/documentation/bookshop-domain/46-pricing-and-discounts.md b/public/documentation/bookshop-domain/46-pricing-and-discounts.md new file mode 100644 index 0000000..4966dac --- /dev/null +++ b/public/documentation/bookshop-domain/46-pricing-and-discounts.md @@ -0,0 +1,82 @@ +# Pricing and discounts + +## What you will learn + +Turn cart lines into priced lines and compose subtotal, discount, and shipping calculations without floating-point money. + +```fsharp +type Money = Money of cents: int + +let add (Money left) (Money right) = + Money (left + right) + +let multiply quantity (Money unitPrice) = + Money (quantity * unitPrice) +``` + +The wrapper prevents a money value from being passed where a count is expected. Integer cents keep the addition and integer multiplication shown here exact in this single-currency model. The public case still permits negative amounts, and the type carries no currency; those constraints would need validated construction and a richer model if the domain required them. + +## Snapshot the price used by an order + +```fsharp +type PricedLine = + { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money + } +``` + +An order should not recalculate old totals from today's catalog. A priced line records the title and unit price accepted at checkout. + +```fsharp +let priceLine book quantity = + { + BookId = book.Id + Title = book.Title + Quantity = quantity + UnitPrice = book.Price + LineTotal = multiply quantity book.Price + } +``` + +## Fold lines into a subtotal + +```fsharp +let subtotal lines = + lines + |> List.fold (fun total line -> add total line.LineTotal) (Money 0) +``` + +The accumulator and every line total have the same `Money` type. The compiler prevents accidentally adding a quantity directly. + +## Model discount policy explicitly + +```fsharp +type Discount = + | NoDiscount + | PercentOff of int + +let applyDiscount discount (Money subtotal) = + match discount with + | NoDiscount -> Money subtotal + | PercentOff percent -> + Money (subtotal * (100 - percent) / 100) +``` + +For non-negative values, integer division here discards fractions of a cent. That is the function's rounding policy, and a real system should name it, define behavior for negative values, and validate the allowed percentage range. + +Shipping can remain another function from an order subtotal and method to `Money`. A final total is then a composition of named calculations, not one opaque arithmetic expression. + +## Experiment + +- Price two quantities of one book. +- Fold several priced lines into a subtotal. +- Apply a 10-percent discount and calculate the exact cents. +- Add a `FixedAmountOff of Money` case and update the match. + +## Summary + +Pricing functions transform typed money values. Order lines snapshot accepted facts, while explicit discount cases make policy and rounding visible. diff --git a/public/documentation/bookshop-domain/47-validating-orders.fsx b/public/documentation/bookshop-domain/47-validating-orders.fsx new file mode 100644 index 0000000..42b396f --- /dev/null +++ b/public/documentation/bookshop-domain/47-validating-orders.fsx @@ -0,0 +1,71 @@ +type BookId = BookId of int +type Money = Money of int + +type Book = { + Id: BookId + Title: string + Price: Money +} + +type CartLine = { BookId: BookId; Quantity: int } + +type Stock = { OnHand: int; Reserved: int } + +type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money +} + +type CheckoutError = + | BookNotFound of BookId + | InvalidQuantity of BookId + | InsufficientStock of BookId * available: int + +let available stock = stock.OnHand - stock.Reserved + +let priceLine (book: Book) quantity : PricedLine = + let (Money unitPrice) = book.Price + + { + BookId = book.Id + Title = book.Title + Quantity = quantity + UnitPrice = book.Price + LineTotal = Money(unitPrice * quantity) + } + +let validateLine (catalog: Map) (inventory: Map) (line: CartLine) = + match Map.tryFind line.BookId catalog, Map.tryFind line.BookId inventory with + | None, _ -> Error(BookNotFound line.BookId) + | _, None -> Error(InsufficientStock(line.BookId, 0)) + | Some _, Some _ when line.Quantity <= 0 -> Error(InvalidQuantity line.BookId) + | Some _, Some stock when line.Quantity > available stock -> Error(InsufficientStock(line.BookId, available stock)) + | Some book, Some _ -> Ok(priceLine book line.Quantity) + +let validateLines catalog inventory (lines: CartLine list) = + lines + |> List.fold + (fun result line -> + result + |> Result.bind (fun validated -> + validateLine catalog inventory line + |> Result.map (fun priced -> priced :: validated))) + (Ok []) + |> Result.map List.rev + +let book = { + Id = BookId 1 + Title = "Kindred" + Price = Money 1299 +} + +let catalog = Map.ofList [ (book.Id, book) ] +let inventory = Map.ofList [ (book.Id, { OnHand = 3; Reserved = 0 }) ] +let cart: CartLine list = [ { BookId = book.Id; Quantity = 2 } ] + +printfn "Validated lines: %A" (validateLines catalog inventory cart) + +// Change the quantity to four and predict the error before running. diff --git a/public/documentation/bookshop-domain/47-validating-orders.md b/public/documentation/bookshop-domain/47-validating-orders.md new file mode 100644 index 0000000..bba5443 --- /dev/null +++ b/public/documentation/bookshop-domain/47-validating-orders.md @@ -0,0 +1,79 @@ +# Validating an order draft + +## What you will learn + +Combine customer, cart, shipping, catalog, and inventory facts into a validated order draft. + +Checkout is the first workflow that crosses several parts of the shop. A request can fail for different reasons: + +```fsharp +type CheckoutError = + | EmptyCart + | CustomerNotFound + | BookNotFound of BookId + | InvalidQuantity of BookId + | InsufficientStock of BookId * available: int +``` + +The error cases carry only information that helps the caller understand or present the refusal. + +## Validate one line + +```fsharp +let validateLine catalog inventory line = + match Map.tryFind line.BookId catalog, Map.tryFind line.BookId inventory with + | None, _ -> Error (BookNotFound line.BookId) + | _, None -> Error (InsufficientStock (line.BookId, 0)) + | Some book, Some stock when line.Quantity <= 0 -> + Error (InvalidQuantity line.BookId) + | Some _, Some stock when line.Quantity > available stock -> + Error (InsufficientStock (line.BookId, available stock)) + | Some book, Some _ -> + Ok (priceLine book line.Quantity) +``` + +Tuple matching considers two lookups together. Guards add rules that depend on the successful values. + +## Validate every line with a fold + +```fsharp +let validateLines catalog inventory lines = + lines + |> List.fold + (fun result line -> + result + |> Result.bind (fun validated -> + validateLine catalog inventory line + |> Result.map (fun priced -> priced :: validated))) + (Ok []) + |> Result.map List.rev +``` + +The accumulator is `Result`. Each successful line is prepended efficiently. `List.rev` restores cart order once at the end. After the first error, `Result.bind` no longer calls `validateLine` for later elements, although `List.fold` still steps through the remaining list. + +Here, `fold`, `Result.bind`, and immutable lists come together naturally. Their types coordinate a larger rule from ideas we already understand separately. + +## Build only after validation + +```fsharp +type OrderDraft = + { + Customer: Customer + Lines: PricedLine list + Shipping: ShippingMethod + Total: Money + } +``` + +Construct the draft only after the customer and every line have been validated. Later functions can rely on its lines having positive quantities, known books, accepted prices, and sufficient stock for each checked line at validation time. This lesson assumes cart operations have merged duplicate book IDs; lesson 60 carries successor inventory through the fold so aggregate stock remains correct even if that assumption is broken. + +## Experiment + +- Validate an empty cart before validating its lines. +- Make the second cart line unavailable and confirm the first error is retained. +- Reverse the cart and observe which stock error is reported first. +- Explain why the final `List.rev` is required. + +## Summary + +A validation workflow turns several loose inputs into one trustworthy domain value. Carrying `Result` through the fold ensures that partial output never escapes after a failure. diff --git a/public/documentation/bookshop-domain/48-order-states.fsx b/public/documentation/bookshop-domain/48-order-states.fsx new file mode 100644 index 0000000..c739901 --- /dev/null +++ b/public/documentation/bookshop-domain/48-order-states.fsx @@ -0,0 +1,58 @@ +type OrderId = OrderId of int + +type PaymentMethod = + | Card of lastFourDigits: string + | GiftCard of code: string + +type Payment = { + Method: PaymentMethod + PaidOnDay: int +} + +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Shipped of trackingNumber: string + | Cancelled of reason: string + +type Order = { Id: OrderId; Status: OrderStatus } + +type TransitionError = + | AlreadyPaid + | PaymentRequired + | OrderClosed + +let pay payment order = + match order.Status with + | AwaitingPayment -> Ok { order with Status = Paid payment } + | Paid _ -> Error AlreadyPaid + | Shipped _ + | Cancelled _ -> Error OrderClosed + +let ship trackingNumber order = + match order.Status with + | Paid _ -> + Ok { + order with + Status = Shipped trackingNumber + } + | AwaitingPayment -> Error PaymentRequired + | Shipped _ + | Cancelled _ -> Error OrderClosed + +let payment = { + Method = Card "4242" + PaidOnDay = 120 +} + +let order = { + Id = OrderId 1 + Status = AwaitingPayment +} + +let result = order |> pay payment |> Result.bind (ship "TRACK-001") + +printfn "Final order: %A" result + +// Predict the final status. Remove payment and inspect the refusal, then change +// Shipped so it preserves Payment and follow the compiler feedback to repair it. diff --git a/public/documentation/bookshop-domain/48-order-states.md b/public/documentation/bookshop-domain/48-order-states.md new file mode 100644 index 0000000..2c13a7c --- /dev/null +++ b/public/documentation/bookshop-domain/48-order-states.md @@ -0,0 +1,131 @@ +# Order states and valid transitions + +## What you will learn + +Use a discriminated union and Result-returning functions to permit only meaningful order transitions. + +Several boolean flags make contradictory orders possible: + +```text +IsPaid = false +IsShipped = true +IsCancelled = true +``` + +An order status should instead be exactly one possibility: + +```fsharp +type PaymentMethod = + | Card of lastFourDigits: string + | GiftCard of code: string + +type Payment = + { + Method: PaymentMethod + PaidOnDay: int + } + +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Shipped of trackingNumber: string + | Cancelled of reason: string +``` + +The payload for each case exists only when it is meaningful. + +## Transitions are functions + +```fsharp +type TransitionError = + | AlreadyPaid + | PaymentRequired + | OrderClosed + +let pay payment order = + match order.Status with + | AwaitingPayment -> Ok { order with Status = Paid payment } + | Paid _ -> Error AlreadyPaid + | Shipped _ + | Cancelled _ -> Error OrderClosed +``` + +The function receives the current order and returns either a complete successor or a typed refusal. The original order stays unchanged. + +Shipping requires payment: + +```fsharp +let ship trackingNumber order = + match order.Status with + | Paid _ -> Ok { order with Status = Shipped trackingNumber } + | AwaitingPayment -> Error PaymentRequired + | Shipped _ + | Cancelled _ -> Error OrderClosed +``` + +Pattern matching makes the transition table visible. When these matches list cases explicitly instead of using a wildcard, adding a status produces an incomplete-match warning wherever the new case has not been considered. + +Write the policy as a table before changing the functions: + +| Current status | Pay | Ship | +| --- | --- | --- | +| Awaiting payment | become paid | `PaymentRequired` | +| Paid | `AlreadyPaid` | become shipped | +| Shipped | `OrderClosed` | `OrderClosed` | +| Cancelled | `OrderClosed` | `OrderClosed` | + +Each cell becomes one pattern-match branch. The table is not stored separately +in the program; it is a way to check that the implementation covers the policy +without vague fall-through behavior. + +## Preserve facts needed later + +The first shipping model stores only a tracking number: + +```fsharp +| Shipped of trackingNumber: string +``` + +That is sufficient for the current operations, but it discards payment details +when an order ships. If reports or refunds later need those details, preserve +them in the shipped case: + +```fsharp +| Shipped of payment: Payment * trackingNumber: string +``` + +Choosing a union payload is a domain decision: keep the facts later behavior +must know, without copying unrelated data into every case. + +## Trace the composed transition + +```fsharp +order |> pay payment |> Result.bind (ship "TRACK-001") +``` + +`pay payment order` produces `Result`. `ship` needs a +plain `Order`, so `Result.bind` calls it only for `Ok paidOrder`. If payment +fails, the same error continues and shipping is not attempted. + +## Commands and events are optional modeling tools + +A larger system may distinguish requests such as `PayOrder` from accepted facts such as `OrderPaid`. That can be valuable, but it is not required to write functional workflows. Direct functions of shape: + +```text +Order -> Result +``` + +give us a simpler starting point and keep the focus on F# instead of an architectural pattern. + +## Experiment + +- Pay an awaiting order and then try to pay it again. +- Attempt to ship before payment. +- Add cancellation that is allowed only before shipping. +- Add a `Refunded` state and follow the compiler errors through every transition. +- Change `Shipped` to retain its `Payment`, then update construction and matches + until the warnings disappear. + +## Summary + +A union enumerates valid lifecycle states. Transition functions make allowed movement explicit and return new domain values without hidden mutation. diff --git a/public/documentation/collections/30-lists.fsx b/public/documentation/collections/30-lists.fsx new file mode 100644 index 0000000..8685c4d --- /dev/null +++ b/public/documentation/collections/30-lists.fsx @@ -0,0 +1,19 @@ +let catalog = [ "Kindred"; "Dune"; "A Wizard of Earthsea" ] + +let expanded = "Beloved" :: catalog + +let rec count titles = + match titles with + | [] -> 0 + | _ :: rest -> 1 + count rest + +let describeFirst titles = + match titles with + | [] -> "The catalog is empty" + | first :: rest -> $"First: %s{first}; more titles: %d{count rest}" + +printfn "%s" (describeFirst expanded) +printfn "Original count: %d" (count catalog) +printfn "Expanded count: %d" (count expanded) + +// Try tracing count with an empty list and a one-item list. diff --git a/public/documentation/collections/30-lists.md b/public/documentation/collections/30-lists.md new file mode 100644 index 0000000..0655676 --- /dev/null +++ b/public/documentation/collections/30-lists.md @@ -0,0 +1,113 @@ +# Lists and their recursive shape + +## What you will learn + +An F# list is an immutable recursive collection built from an empty case and a head joined to a tail. + +## More than one book + +The bookshop now needs to hold several books of the same type. An F# list is an ordered, immutable collection: + +```fsharp +let titles = [ "Kindred"; "Dune"; "Earthsea" ] +// string list +``` + +Semicolons separate elements; commas would construct tuple values. Every element has the same element type, so a string cannot be inserted into `int list`. + +The empty list needs type context because it contains no element from which to infer a type: + +```fsharp +let noTitles: string list = [] +``` + +## Constructing without changing + +The `::` operator prepends one element: + +```fsharp +let expanded = "Beloved" :: titles +``` + +`expanded` has four values; `titles` still has three. Prepending is efficient because the new list can refer to the unchanged original list as its tail. + +Use `@` to concatenate two complete lists: + +```fsharp +let combined = titles @ [ "Beloved"; "Parable of the Sower" ] +``` + +Concatenation traverses its left list, so repeated appending is not the natural way to build a list one item at a time. Prefer prepending while accumulating, then reverse when order matters—or use a more suitable representation. + +## Lists have two possible shapes + +Every F# list is either: + +```text +[] +``` + +or: + +```text +firstElement :: remainingElements +``` + +Pattern matching follows those exact shapes: + +```fsharp +let describeFirst values = + match values with + | [] -> "The catalog is empty" + | first :: rest -> + $"First: %s{first}; remaining: %d{List.length rest}" +``` + +In the non-empty branch, `first` is one string and `rest` is another `string list`. The wildcard can ignore a part: + +```fsharp +| first :: _ -> first +``` + +## The shape suggests recursion + +Suppose the standard library did not provide `List.length`. A function can process the head and call itself with the smaller tail: + +```fsharp +let rec count values = + match values with + | [] -> 0 + | _ :: rest -> 1 + count rest +``` + +`rec` permits the function to refer to itself. The empty-list branch is the base case. The non-empty branch makes progress by passing `rest`, which is one element shorter. + +Trace `[ "Kindred"; "Dune" ]`: + +```text +count [ "Kindred"; "Dune" ] += 1 + count [ "Dune" ] += 1 + (1 + count []) += 1 + (1 + 0) += 2 +``` + +This small recursive function matters because the next lessons reveal that `map`, `filter`, and `fold` capture recurring traversals over the same empty-or-head-and-tail structure. + +## Compiler and runtime clinic + +If the match omits `[]`, the compiler warns that the function does not handle every possible list. If the recursive call receives `values` instead of `rest`, the type still checks but the function never moves toward its base case. Types verify shapes, not termination. + +Avoid unsafe head access when a list may be empty. Pattern matching makes both possibilities visible, and later `try` functions represent missing values with `Option`. + +## Experiment + +- Prepend a title and verify the original list remains unchanged. +- Match lists containing zero, one, and several values. +- Trace `count` by hand with three titles. +- Write `totalPages` recursively for a list of integers. +- On paper, replace `rest` with the unchanged list and explain why evaluation would never reach the base case. Do not run that non-terminating version in the browser. + +## Summary + +A list is an immutable recursive data structure: empty or one head plus another list. That shape explains both pattern matching and the collection transformations that follow. diff --git a/public/documentation/collections/31-list-map.fsx b/public/documentation/collections/31-list-map.fsx new file mode 100644 index 0000000..2da9c1f --- /dev/null +++ b/public/documentation/collections/31-list-map.fsx @@ -0,0 +1,30 @@ +type Book = { + Title: string + Author: string + Pages: int +} + +let books = [ + { + Title = "Kindred" + Author = "Octavia E. Butler" + Pages = 264 + } + { + Title = "Dune" + Author = "Frank Herbert" + Pages = 412 + } + { + Title = "Earthsea" + Author = "Ursula K. Le Guin" + Pages = 205 + } +] + +let labels = + books |> List.map (fun book -> $"%s{book.Title} (%d{book.Pages} pages)") + +printfn "%A" labels + +// Change the mapping function to return page counts instead of labels. diff --git a/public/documentation/collections/31-list-map.md b/public/documentation/collections/31-list-map.md new file mode 100644 index 0000000..f1a7777 --- /dev/null +++ b/public/documentation/collections/31-list-map.md @@ -0,0 +1,71 @@ +# Transforming lists with map + +## What you will learn + +`List.map` applies one function to every element and returns the transformed list. + +The recursive traversal from the previous lesson can express a specific transformation: + +```fsharp +let rec titleLengths titles = + match titles with + | [] -> [] + | title :: rest -> + String.length title :: titleLengths rest +``` + +If we next need uppercase titles, then catalog labels, the traversal repeats while only the element operation changes. `List.map` names that common pattern. + +```fsharp +let titleLength title = title.Length +let lengths = titles |> List.map titleLength +``` + +The original list remains unchanged. Map preserves element count and order while its output element type may differ. + +Its conceptual recursive shape is: + +```fsharp +let rec map transform values = + match values with + | [] -> [] + | value :: rest -> + transform value :: map transform rest +``` + +Use the library's `List.map`; this definition exists to make its behavior visible. The empty input produces empty output. Each non-empty step transforms exactly one head and recursively maps the tail. + +```text +List.map : ('a -> 'b) -> 'a list -> 'b list +``` + +Read it from left to right: give `map` an element transformation, then a list of input elements, and receive a list of outputs. Partial application lets `List.map titleLength` become a whole-list transformation. + +Records make the domain example richer: + +```fsharp +let label book = $"%s{book.Title} by %s{book.Author}" +let labels = books |> List.map label +``` + +Use `map` when every input has exactly one output. It is not a loop with a hidden mutable accumulator; it describes a transformation. + +## Walk one element through + +Given three `Book` records, map calls `label` three times and builds three strings in the same order. The original records are untouched. An empty input produces an empty output list of the inferred result type. + +A common error is passing `label book` as map's first argument. That expression calls the function immediately and produces a string. Map needs the function itself: `List.map label books`. + +## Read the signature together with the function's contract + +When traversal completes normally, `List.map` has applied the supplied function once to each input element, in list order, and produced one output element for each input element. Its generic signature describes the allowed type relationship; the documented behavior supplies the traversal guarantee. The signature alone cannot prove purity or prevent the supplied function from reading external state, performing an effect, or raising an exception. + +## Try it + +- Map prices to discounted prices. +- Use an anonymous function to uppercase titles. +- Predict the output type before running. + +## Summary + +Map changes each element through a function, preserving collection structure and making element-to-element intent explicit. diff --git a/public/documentation/collections/32-list-filter.fsx b/public/documentation/collections/32-list-filter.fsx new file mode 100644 index 0000000..c7b5ae1 --- /dev/null +++ b/public/documentation/collections/32-list-filter.fsx @@ -0,0 +1,31 @@ +type Book = { + Title: string + Available: bool + Pages: int +} + +let books = [ + { + Title = "Kindred" + Available = true + Pages = 264 + } + { + Title = "Dune" + Available = false + Pages = 412 + } + { + Title = "Earthsea" + Available = true + Pages = 205 + } +] + +let availableShortBooks = + books |> List.filter (fun book -> book.Available && book.Pages < 300) + +printfn "%A" (availableShortBooks |> List.map (fun book -> book.Title)) + +// Predict the titles before running. Change the threshold, then replace filter +// with map and inspect how the result's element type changes. diff --git a/public/documentation/collections/32-list-filter.md b/public/documentation/collections/32-list-filter.md new file mode 100644 index 0000000..112ffe0 --- /dev/null +++ b/public/documentation/collections/32-list-filter.md @@ -0,0 +1,85 @@ +# Selecting values with filter + +## What you will learn + +`List.filter` retains elements for which a predicate returns `true`. + +Its recursive shape differs from map at one decision: + +```fsharp +let rec filter predicate values = + match values with + | [] -> [] + | value :: rest -> + let filteredRest = filter predicate rest + if predicate value then + value :: filteredRest + else + filteredRest +``` + +The element is either prepended unchanged or omitted. The library function packages this recurring traversal. + +```fsharp +let isLong book = book.Pages >= 300 +let longBooks = books |> List.filter isLong +``` + +A predicate is a function that returns `bool`. Filter keeps the original values in their original order, though the result may be shorter. + +```text +List.filter : ('a -> bool) -> 'a list -> 'a list +``` + +Unlike `map`, input and output element types are the same. Combine named predicates when the rule matters: + +```fsharp +let matches query book = book.Title.Contains(query) +let search query = List.filter (matches query) +``` + +Here partial application produces a predicate configured with `query`, and then a catalog transformation. + +Trace `[ shortBook; longBook ] |> List.filter isLong` one item at a time: + +1. `isLong shortBook` returns `false`, so no element is added. +2. `isLong longBook` returns `true`, so that original record is kept. +3. the result is `[ longBook ]`, not a list of boolean answers. + +That last distinction separates `filter` from `map`: + +```fsharp +books |> List.map isLong +// bool list: one answer for every book + +books |> List.filter isLong +// Book list: only books whose answer was true +``` + +Filter answers “which existing values?” It should not change elements or invent fallback values. + +## Predicates are reusable rules + +`isLong` can be called with one book, passed to `List.filter`, or combined with another predicate. It knows nothing about lists; filter supplies one element at a time and keeps it when the answer is `true`. + +An empty result is a valid list, not `None`: the search operation completed and found zero matching elements. Use `tryFind` in the next lesson when the program specifically wants at most one matching value. + +## Filtering answers one kind of question + +A report of in-stock books can quietly omit everything else. A failed attempt to add one particular book to a cart needs an explanation, so a typed error fits better. Choose the collection operation from the caller's question, not just from the desired final count. + +Filtering an empty list returns an empty list. Filtering with a predicate that +always returns `false` does too. Neither situation is exceptional: the question +was answered and no values qualified. + +## Try it + +- Filter short books instead. +- Search using a different query. +- Combine two boolean conditions in one predicate. +- Replace `filter` with `map` without changing the predicate. Predict the new + element type before reading the compiler's inferred type. + +## Summary + +Filter selects values using a boolean-returning function. Its signature reveals that element type stays unchanged. diff --git a/public/documentation/collections/33-finding.fsx b/public/documentation/collections/33-finding.fsx new file mode 100644 index 0000000..16f1165 --- /dev/null +++ b/public/documentation/collections/33-finding.fsx @@ -0,0 +1,33 @@ +type Book = { + Id: int + Title: string + Available: bool +} + +let books = [ + { + Id = 1 + Title = "Kindred" + Available = true + } + { + Id = 2 + Title = "Dune" + Available = false + } + { + Id = 3 + Title = "Earthsea" + Available = true + } +] + +let findBook id = + books |> List.tryFind (fun book -> book.Id = id) + +printfn "Search: %A" (findBook 2) +printfn "Missing search: %A" (findBook 99) +printfn "Any available: %b" (books |> List.exists (fun book -> book.Available)) +printfn "All available: %b" (books |> List.forall (fun book -> book.Available)) + +// Make Dune available, then predict both boolean answers. diff --git a/public/documentation/collections/33-finding.md b/public/documentation/collections/33-finding.md new file mode 100644 index 0000000..df532d9 --- /dev/null +++ b/public/documentation/collections/33-finding.md @@ -0,0 +1,69 @@ +# Finding values in lists + +## What you will learn + +Collection searches use options so “not found” stays explicit. + +```fsharp +let found = books |> List.tryFind (fun book -> book.Title = query) +``` + +`List.tryFind` stops at the first match and returns `Some book`, or `None`. Its type connects predicates, lists, and options: + +```text +('a -> bool) -> 'a list -> 'a option +``` + +Follow the returned option instead of inventing a sentinel: + +```fsharp +books |> List.tryFind (fun book -> book.Id = 2) +// Some matchingBook + +books |> List.tryFind (fun book -> book.Id = 99) +// None +``` + +The first match wins. If several matches are meaningful, use `List.filter`; if duplicate IDs should be impossible, use a map keyed by ID later. + +`List.exists predicate` answers only whether any value matches. `List.forall predicate` checks whether every value matches. + +```fsharp +let anyAvailable = books |> List.exists (fun book -> book.Available) +let allAvailable = books |> List.forall (fun book -> book.Available) +``` + +For an empty list, `exists` is false and `forall` is true: no element disproves the claim that every element satisfies the predicate. Check that this convention matches the question your domain is asking. + +## Choose the question first + +Use `exists` for “does any available copy exist?” because the matching copy itself is irrelevant. Use `tryFind` for “give me the first available copy.” Use `filter` for “give me all available copies.” These similar-looking functions encode different questions in their return types. + +Avoid a made-up fallback record when search fails. `None` preserves the truth that no catalog value was found and forces the caller to decide whether to display a message, try another source, or stop. + +`forall` asks a different question from “find every matching value.” It can +finish as soon as one value disproves the predicate, and it returns only a +Boolean. If the caller needs the offending books, use `filter` with the inverse +predicate instead. + +## Trace the empty-list cases + +`List.exists predicate []` is `false` because there is no matching element. +`List.forall predicate []` is `true` because there is no element that disproves +the predicate. This is sometimes called vacuous truth, but the practical point +is simpler: check whether that behavior answers the domain question you meant +to ask. “Are all cart lines valid?” may need a separate non-empty-cart rule. + +## Try it + +- Search for a missing title. +- Use `exists` to ask whether any copy is available. +- Use `forall` to ask whether every book is available. +- Compare all three operations on an empty catalog. +- Replace `tryFind` with `filter` and compare their result types. + +## Summary + +`exists` answers whether any value matches, `forall` checks every value, and +`tryFind` returns the first matching value as an option. Choose the return type +that preserves exactly what the caller needs. diff --git a/public/documentation/collections/34-choosing-and-partitioning.fsx b/public/documentation/collections/34-choosing-and-partitioning.fsx new file mode 100644 index 0000000..7838789 --- /dev/null +++ b/public/documentation/collections/34-choosing-and-partitioning.fsx @@ -0,0 +1,35 @@ +type Book = { + Title: string + Copies: int + StaffNote: string option +} + +let books = [ + { + Title = "Kindred" + Copies = 3 + StaffNote = Some "Staff pick" + } + { + Title = "Dune" + Copies = 0 + StaffNote = Some "Epic science fiction" + } + { + Title = "Earthsea" + Copies = 2 + StaffNote = None + } +] + +let recommendedLabel book = + book.StaffNote |> Option.map (fun note -> book.Title + ": " + note) + +let recommendations = books |> List.choose recommendedLabel +let inStock, soldOut = books |> List.partition (fun book -> book.Copies > 0) + +printfn "Recommendations: %A" recommendations +printfn "In stock: %A" (inStock |> List.map (fun book -> book.Title)) +printfn "Sold out: %A" (soldOut |> List.map (fun book -> book.Title)) + +// Add a note to Earthsea and restock Dune; predict both kinds of output. diff --git a/public/documentation/collections/34-choosing-and-partitioning.md b/public/documentation/collections/34-choosing-and-partitioning.md new file mode 100644 index 0000000..153c20f --- /dev/null +++ b/public/documentation/collections/34-choosing-and-partitioning.md @@ -0,0 +1,114 @@ +# Choosing and partitioning values + +## What you will learn + +Use `List.choose` when one input may produce one output or no output, and use +`List.partition` when both sides of a predicate matter. + +## A filter and a transformation often travel together + +Suppose the catalog contains optional staff recommendations: + +```fsharp +type Book = + { + Title: string + StaffNote: string option + } +``` + +We want labels only for books that have notes. `List.map` would produce one +result for every book, including `None` values. `List.filter` could select the +books first, but we would then have to inspect the option again to obtain the +text. + +Instead, write one function that returns an optional output: + +```fsharp +let recommendedLabel book = + match book.StaffNote with + | Some note -> Some (book.Title + ": " + note) + | None -> None +``` + +`List.choose` applies that function to every element, discards every `None`, +and unwraps every `Some`: + +```fsharp +let recommendations = + books |> List.choose recommendedLabel +``` + +Its signature explains the relationship: + +```text +List.choose : ('a -> 'b option) -> 'a list -> 'b list +``` + +Each source `'a` gets a chance to produce one `'b`. Absence means that source +contributes nothing to the result. + +## Trace one input at a time + +For three functions results: + +```text +Some "Kindred: Staff pick" +None +Some "Dune: Epic science fiction" +``` + +`List.choose` produces: + +```text +[ "Kindred: Staff pick"; "Dune: Epic science fiction" ] +``` + +It preserves the relative order of the retained outputs. It does not retain an +explanation for discarded values. If rejection reasons matter, keep explicit +`Result` values or accumulate errors instead. + +## Keep both sides with partition + +`List.filter predicate` keeps values for which the predicate returns `true`. +Sometimes the rejected values are equally important. `List.partition` returns +both groups as a tuple: + +```fsharp +let inStock, soldOut = + books |> List.partition (fun book -> book.Copies > 0) +``` + +Its result has type: + +```text +Book list * Book list +``` + +The first list contains the `true` side and the second contains the `false` +side. Relative order is preserved inside each list. + +Choose the operation from the question: + +- `filter`: which values satisfy this rule? +- `choose`: which optional outputs were produced? +- `partition`: what are both sides of this rule? + +## Compiler clinic + +The chooser must return an option. Returning a plain string in one branch and +`None` in the other fails because the branches have different types. Wrap the +present string in `Some` so both branches produce `string option`. + +## Experiment + +- Predict which recommendation labels remain before running. +- Add a book with no note and confirm the output is unchanged. +- Partition the books by stock and inspect both lists. +- Rewrite the chooser as `Option.map` over `book.StaffNote`. + +## Summary + +`choose` combines optional transformation with collection processing. +`partition` preserves both sides of a predicate. Their return types state what +information the caller keeps and what it discards. diff --git a/public/documentation/collections/35-folds.fsx b/public/documentation/collections/35-folds.fsx new file mode 100644 index 0000000..8fb8709 --- /dev/null +++ b/public/documentation/collections/35-folds.fsx @@ -0,0 +1,52 @@ +type Book = { + Title: string + Pages: int + Available: bool +} + +type Summary = { + BookCount: int + PageCount: int + AvailableCount: int +} + +let books = [ + { + Title = "Kindred" + Pages = 264 + Available = true + } + { + Title = "Dune" + Pages = 412 + Available = false + } + { + Title = "Earthsea" + Pages = 205 + Available = true + } +] + +let summarize summary book = { + BookCount = summary.BookCount + 1 + PageCount = summary.PageCount + book.Pages + AvailableCount = summary.AvailableCount + (if book.Available then 1 else 0) +} + +let empty = { + BookCount = 0 + PageCount = 0 + AvailableCount = 0 +} + +printfn "%A" (books |> List.fold summarize empty) + +let labels = [ "A"; "B"; "C" ] + +let fromRight = + List.foldBack (fun label text -> $"(%s{label}+%s{text})") labels "end" + +printfn "Right-associated: %s" fromRight + +// Change the label order and trace the foldBack result on paper first. diff --git a/public/documentation/collections/35-folds.md b/public/documentation/collections/35-folds.md new file mode 100644 index 0000000..78af3b2 --- /dev/null +++ b/public/documentation/collections/35-folds.md @@ -0,0 +1,137 @@ +# Folding collections + +## What you will learn + +`List.fold` combines a list into one accumulated result. + +You have now seen several recursive functions that carry a changing result through a list. Fold makes that state explicit and lets the caller provide the update rule. + +```fsharp +let addPages total book = total + book.Pages +let totalPages = books |> List.fold addPages 0 +``` + +Fold receives an accumulator function, an initial state, and a list: + +```text +('state -> 'item -> 'state) -> 'state -> 'item list -> 'state +``` + +Starting with `0`, it passes state and the first book to `addPages`, then uses that result with the next book, until the list is exhausted. + +For page counts `[ 100; 250; 80 ]`, the states are: + +| Step | Accumulator | Item | New accumulator | +|---|---:|---:|---:| +| Start | `0` | — | `0` | +| 1 | `0` | `100` | `100` | +| 2 | `100` | `250` | `350` | +| 3 | `350` | `80` | `430` | + +The final accumulator, `430`, is the fold's result. + +The state need not be numeric. It can be a record summary: + +```fsharp +type Summary = { Books: int; Pages: int } +``` + +## You rarely need to write `fold` directly + +Many common folds already have names in the collection API. Prefer those names +when they describe the question: + +```fsharp +let pageCounts = books |> List.map (fun book -> book.Pages) +let totalPages = pageCounts |> List.sum +let longest = books |> List.maxBy (fun book -> book.Pages) +``` + +Other specific operations include `map`, `filter`, `choose`, `exists`, +`forall`, `length`, `sumBy`, `minBy`, and `maxBy`. They reveal the shape of the +operation immediately and reduce the amount of accumulator code a reader must +verify. For example, this: + +```fsharp +let totalPages = books |> List.sumBy (fun book -> book.Pages) +``` + +states the calculation more directly than: + +```fsharp +let totalPages = + books |> List.fold (fun total book -> total + book.Pages) 0 +``` + +Use `fold` directly when no more specific operation captures the job—especially +when one pass must build a custom state such as `Summary`, coordinate several +related totals, or carry domain state from one item to the next. + +## `reduce` is a narrower kind of fold + +`List.reduce` also combines a list into one value, but it uses the first element +as the initial accumulator: + +```fsharp +let add left right = left + right +let total = [ 100; 250; 80 ] |> List.reduce add +``` + +Consequently, the accumulator and element must have the same type, and reducing +an empty list raises an exception because there is no first element. `fold` +receives an explicit initial state, can handle an empty list, and may accumulate +a type different from the element type. + +Prefer `sum` for a sum. Use `reduce` only when combining a known non-empty +collection with no natural separate initial value. Use `fold` for genuinely +custom accumulation. + +`List.foldBack` processes from the right and places the list before the initial state. This matters when the combining operation is order-sensitive: + +```fsharp +let labels = [ "A"; "B"; "C" ] + +let fromLeft = + labels |> List.fold (fun text label -> $"(%s{text}+%s{label})") "start" +// "(((start+A)+B)+C)" + +let fromRight = + List.foldBack (fun label text -> $"(%s{label}+%s{text})") labels "end" +// "(A+(B+(C+end)))" +``` + +Pay attention to both direction and argument order. Use `foldBack` when right-associated construction matches the problem; `fold` remains clearer for totals and state that moves forward. + +The conceptual recursive implementation of left fold is: + +```fsharp +let rec fold folder state values = + match values with + | [] -> state + | value :: rest -> + let nextState = folder state value + fold folder nextState rest +``` + +Each recursive call receives the updated state. Unlike map, fold does not preserve the input's list structure. + +## Trace the state + +For books with 100 and 250 pages, a page-count fold starts at zero. The first call receives `0` and the first book, returning `100`; the second receives `100` and the second book, returning `350`. That last state is the result. + +Choosing the initial state is part of the design. Addition starts at zero, multiplication at one, and a record summary starts with zeroed fields. An incorrect initial value systematically biases every result. + +## Try it + +- Multiply a list of numbers starting at one. +- Accumulate both book count and page count. +- Rewrite a page-total fold with `List.sumBy`. +- Compare `List.reduce add []` with `List.fold add 0 []`. Predict what happens + before running either expression. +- Change the initial state and predict the effect. +- Trace the left and right folds above before running them. + +## Summary + +Fold threads explicit state through every element. Most common folds have more +specific names; use `fold` directly when custom accumulated state is the point. diff --git a/public/documentation/collections/36-recursion.fsx b/public/documentation/collections/36-recursion.fsx new file mode 100644 index 0000000..55fffb4 --- /dev/null +++ b/public/documentation/collections/36-recursion.fsx @@ -0,0 +1,28 @@ +type Category = Category of name: string * children: Category list + +let rec countCategories category = + match category with + | Category(_, children) -> + let add total value = total + value + + let childCounts = children |> List.map countCategories + + 1 + (childCounts |> List.fold add 0) + +let rec categoryNames category = + match category with + | Category(name, children) -> name :: (children |> List.collect categoryNames) + +let categoryTree = + Category( + "Bookshop", + [ + Category("Fiction", [ Category("Fantasy", []); Category("Science fiction", []) ]) + Category("Non-fiction", []) + ] + ) + +printfn "Category count: %d" (countCategories categoryTree) +printfn "Category names: %A" (categoryNames categoryTree) + +// Try adding a nested Biography category and predict both outputs. diff --git a/public/documentation/collections/36-recursion.md b/public/documentation/collections/36-recursion.md new file mode 100644 index 0000000..3ca088c --- /dev/null +++ b/public/documentation/collections/36-recursion.md @@ -0,0 +1,134 @@ +# Recursive domain data and deeper recursion + +## What you will learn + +Recursive functions follow recursive data toward a base case, including domain trees rather than only lists. + +## From recursive lists to recursive domains + +The list lesson introduced recursion over `[]` and `head :: tail`. Recursion becomes indispensable when the domain itself can contain more values of the same shape. + +A bookshop category may contain child categories: + +```fsharp +type Category = + | Category of name: string * children: Category list +``` + +This is a recursive discriminated union: `Category` contains a list whose elements are also `Category` values. + +```fsharp +let catalog = + Category ( + "Bookshop", + [ + Category ("Fiction", [ Category ("Fantasy", []) ]) + Category ("Non-fiction", []) + ] + ) +``` + +The value forms a tree. The root has two children; Fiction has one child; the other nodes are leaves with empty child lists. + +## Traverse one level, then recurse + +```fsharp +let rec countCategories category = + match category with + | Category (_, children) -> + let add total value = total + value + + let childCounts = + children |> List.map countCategories + + 1 + (childCounts |> List.fold add 0) +``` + +The current category contributes one. `List.map countCategories` recursively calculates each child's tree size, and fold sums those results. + +It is idiomatic to mix explicit recursion with collection functions. Recursion handles the custom tree, while `map` and `fold` handle each list of children. + +Sometimes each input produces a list and those lists should become one flat list. `List.collect` combines mapping and concatenation: + +```fsharp +let rec categoryNames category = + match category with + | Category (name, children) -> + name :: (children |> List.collect categoryNames) +``` + +For every child, `categoryNames` returns `string list`; `collect` concatenates those child lists into one. Its shape is: + +```text +('a -> 'b list) -> 'a list -> 'b list +``` + +Use `map` when each item produces one result and `collect` when each item produces zero or more results that should be flattened. + +## Base cases may be implicit in the data + +There is only one union case, but a node with `children = []` is effectively a leaf. Mapping an empty list produces an empty list and folding it from zero produces zero, so the result is one for the current leaf. + +A base case need not have its own union case. What matters is that some input finishes without another recursive call. + +## Tail recursion and accumulators + +The simple list count from lesson 30 writes: + +```fsharp +1 + count rest +``` + +The addition must wait for the recursive result. An accumulator can carry completed work so the recursive call is the final operation: + +```fsharp +let count values = + let rec loop total remaining = + match remaining with + | [] -> total + | _ :: rest -> loop (total + 1) rest + + loop 0 values +``` + +This is tail recursion: the recursive call is the branch's final operation, and the accumulator already contains the work completed so far. Small functions do not need to be rewritten mechanically: collection functions usually express list processing better, and rewriting a tree traversal in tail-recursive form can require a different algorithm. + +## Mutually recursive definitions + +Occasionally two functions call one another. F# joins their definitions with `and`: + +```fsharp +let rec describeCategory category = + match category with + | Category (name, children) -> + name + describeChildren children + +and describeChildren children = + match children with + | [] -> "" + | first :: rest -> + " > " + describeCategory first + describeChildren rest +``` + +This syntax is useful when the problem genuinely has two recursive roles. A single local recursive helper is usually simpler when it fits. + +## Compiler and reasoning clinic + +`let rec` permits self-reference but does not prove termination. Before running, identify: + +1. the input that stops; +2. the smaller input used by every recursive call; +3. how recursive results combine. + +If one of those is unclear, the function deserves another design pass. + +## Experiment + +- Add several nested categories and predict the total count. +- Write `maximumDepth` for the category tree. +- Rewrite list counting with an accumulator and trace its state. +- On paper, trace what would happen if the function recurred on the same category. Do not run that non-terminating version in the browser; explain why the type checker cannot reject it. + +## Summary + +Recursion follows data that contains smaller values of its own shape. Collection combinators handle standard list recursion; explicit recursive functions remain essential for custom trees and other recursive domains. diff --git a/public/documentation/collections/37-arrays.fsx b/public/documentation/collections/37-arrays.fsx new file mode 100644 index 0000000..5026ee5 --- /dev/null +++ b/public/documentation/collections/37-arrays.fsx @@ -0,0 +1,19 @@ +type ShelfCopy = { Title: string; Copies: int } + +let shelf = [| + { Title = "Kindred"; Copies = 2 } + { Title = "Dune"; Copies = 0 } + { Title = "Earthsea"; Copies = 3 } +|] + +let labels = + shelf + |> Array.mapi (fun index book -> $"%d{index + 1}. %s{book.Title} — %d{book.Copies} copies") + +let available = shelf |> Array.filter (fun book -> book.Copies > 0) + +printfn "First shelf title: %s" shelf[0].Title +printfn "All labels: %A" labels +printfn "Available titles: %A" (available |> Array.map (fun book -> book.Title)) + +// Try adding a fourth book and changing the filter rule. diff --git a/public/documentation/collections/37-arrays.md b/public/documentation/collections/37-arrays.md new file mode 100644 index 0000000..34d2ecc --- /dev/null +++ b/public/documentation/collections/37-arrays.md @@ -0,0 +1,87 @@ +# Arrays: indexed collections + +## What you will learn + +Arrays provide ordered, homogeneous data with direct indexed access and array-specific transformations. + +## A different collection shape + +Lists are excellent for immutable head-to-tail processing. Some problems instead need efficient indexed access or must interoperate with APIs that naturally use contiguous indexed collections. F# arrays provide that shape. + +An array literal uses `[|` and `|]`: + +```fsharp +let shelf = [| "Kindred"; "Dune"; "Earthsea" |] +``` + +The inferred type is `string array`. As with lists, every element has one element type. + +## Indexes and length + +```fsharp +let first = shelf[0] +let second = shelf[1] +let count = shelf.Length +``` + +Indexes begin at zero. A three-element array has valid indexes `0`, `1`, and `2`. Accessing an invalid index is a runtime error because `string array` records the element type, not a statically known length. + +Use indexing when position is part of the problem. A book ID deserves an explicit lookup structure instead of a fragile array position. + +## Array transformations + +Arrays have their own module functions: + +```fsharp +let titleLengths = + shelf + |> Array.map String.length +// [| 7; 4; 8 |] +``` + +`Array.map` returns a new array and leaves `shelf` unchanged. Its type has the same shape as `List.map`, with `array` replacing `list`: + +```text +('a -> 'b) -> 'a array -> 'b array +``` + +`Array.filter`, `Array.choose`, and folds likewise mirror concepts already learned. The module name tells you which collection representation is returned. + +`Array.mapi` additionally supplies each zero-based index: + +```fsharp +let numbered = + shelf + |> Array.mapi (fun index title -> $"%d{index + 1}. %s{title}") +``` + +It still returns a new array. Receiving an index does not imply mutation. + +## Arrays can be mutated—but not yet + +Arrays permit element assignment, but mutation changes the reasoning model and deserves its own focused lesson after objects and interfaces. For now, use transformations that return new arrays. This lets us compare collection representations without mixing in state changes. + +## List or array? + +Choose from the job the collection needs to do: + +- Prefer lists for immutable domain collections and recursive head-tail processing. +- Prefer arrays for indexed access, array-oriented APIs, or a later deliberately local mutation boundary. +- Convert explicitly with `List.toArray` and `Array.toList` when the representation genuinely needs to change. + +Conversions allocate another collection. Rather than switching representations just to call a familiar function, learn the corresponding operation for the collection you already have. + +## Compiler and runtime clinic + +Passing an array to `List.map` is a compile-time type mismatch: `Book array` is not `Book list`. Accessing `shelf[99]` is different—the type is valid, but the position does not exist at runtime. Static types prevent element-type confusion, not every invalid numeric index. + +## Experiment + +- Create an array of three copy counts and map each to a doubled count. +- Use `Array.mapi` to produce human-friendly shelf numbers beginning at one. +- Convert the array to a list and inspect the resulting type. +- Deliberately use an invalid index, then remove it after observing the runtime failure. + +## Summary + +Arrays are ordered, homogeneous collections whose positions can be accessed directly. Their functional transformations return arrays just as list transformations return lists. diff --git a/public/documentation/collections/38-sequences.fsx b/public/documentation/collections/38-sequences.fsx new file mode 100644 index 0000000..54d5b02 --- /dev/null +++ b/public/documentation/collections/38-sequences.fsx @@ -0,0 +1,26 @@ +type Dispatch = { OrderNumber: int; ShipOnDay: int } + +let dispatches = [ + { OrderNumber = 101; ShipOnDay = 105 } + { OrderNumber = 102; ShipOnDay = 125 } + { OrderNumber = 103; ShipOnDay = 110 } +] + +let imminentLabels = + dispatches + |> Seq.filter (fun dispatch -> dispatch.ShipOnDay <= 110) + |> Seq.map (fun dispatch -> $"Order %d{dispatch.OrderNumber} ships by day %d{dispatch.ShipOnDay}") + +let displayedLabels = imminentLabels |> Seq.toList + +let dispatchWindows = + seq { + for week in 1..3 do + yield 100 + week * 7 + } + |> Seq.toList + +printfn "Imminent dispatches: %A" displayedLabels +printfn "Future dispatch windows: %A" dispatchWindows + +// Try enumerating imminentLabels twice and explain when its work is repeated. diff --git a/public/documentation/collections/38-sequences.md b/public/documentation/collections/38-sequences.md new file mode 100644 index 0000000..207ab17 --- /dev/null +++ b/public/documentation/collections/38-sequences.md @@ -0,0 +1,94 @@ +# Sequences: values produced on demand + +## What you will learn + +Sequences describe values that are produced as a consumer requests them rather than stored eagerly up front. + +## Why another collection abstraction? + +Lists and arrays hold a concrete collection in memory. Sometimes a program wants to describe how values can be produced and transform them without immediately constructing the entire result. F# calls that abstraction a sequence, written `seq<'a>`. + +```fsharp +let days = seq { 1 .. 5 } +// seq +``` + +The range syntax `1 .. 5` represents the inclusive integers from one through five. The sequence expression wraps the rule for producing them. + +## Transformation is delayed + +```fsharp +let doubled = + days + |> Seq.map (fun day -> day * 2) +``` + +`Seq.map` returns another sequence description. Its transformation runs as a consumer asks for elements. This is called *lazy* or *deferred* evaluation. + +Converting to a concrete collection forces enumeration: + +```fsharp +let values = doubled |> Seq.toList +// [ 2; 4; 6; 8; 10 ] +``` + +Printing a sequence with `%A` may show its representation instead of its elements. Convert it to a concrete collection when you want to inspect every value. + +## Enumeration may repeat work + +Consuming a transformed sequence twice may run its production logic twice. The `seq<'a>` abstraction provides enumeration, not a general caching guarantee. Convert the values to a list or array when repeated traversal must reuse the same computed results. + +```fsharp +let labels = + books + |> Seq.map (fun book -> book.Title.ToUpper()) + +let firstPass = labels |> Seq.toList +let secondPass = labels |> Seq.toList +``` + +For pure transformations this repeats computation without changing meaning. For effectful generation it may repeat effects, another reason to keep sequence transformations pure and materialize them when repeated stable data is required. + +## Sequence expressions + +`yield` emits one element from a sequence expression: + +```fsharp +let dispatchDays = + seq { + yield 7 + yield 14 + yield 21 + } +``` + +A `for` inside a sequence expression describes produced values rather than imperatively updating an accumulator: + +```fsharp +let squares = + seq { + for number in 1 .. 5 do + yield number * number + } +``` + +The expression as a whole produces `seq`. The controlled-mutation lesson later contrasts this with imperative loops whose purpose is an effect. + +## Choose the simplest suitable collection + +- Use a list for a small, repeatedly traversed immutable domain collection. +- Use an array for direct indexed access or array-based interoperation. +- Use a sequence when delayed production or a shared enumeration abstraction materially helps. + +Choose `seq` when delayed production helps. Laziness introduces an evaluation question that a concrete collection does not have. + +## Experiment + +- Build a sequence for days 1 through 10 and retain the even days. +- Map a formatting function and convert the result to a list. +- Enumerate the same sequence twice. +- Rewrite a `seq { for ... yield ... }` expression using `Seq.map`. + +## Summary + +A sequence is a recipe for producing values as they are requested. Sequence transformations compose recipes; conversion or another consumer performs the enumeration. diff --git a/public/documentation/collections/39-maps.fsx b/public/documentation/collections/39-maps.fsx new file mode 100644 index 0000000..594b68f --- /dev/null +++ b/public/documentation/collections/39-maps.fsx @@ -0,0 +1,22 @@ +type Book = { Id: int; Title: string; Copies: int } + +let books = [ + { + Id = 1 + Title = "Kindred" + Copies = 2 + } + { Id = 2; Title = "Dune"; Copies = 0 } +] + +let catalog = books |> List.map (fun book -> book.Id, book) |> Map.ofList + +let revised = + catalog |> Map.change 2 (Option.map (fun book -> { book with Copies = 3 })) + +printfn "Book 1: %A" (catalog |> Map.tryFind 1) +printfn "Missing book: %A" (catalog |> Map.tryFind 99) +printfn "Original Dune: %A" (catalog |> Map.tryFind 2) +printfn "Revised Dune: %A" (revised |> Map.tryFind 2) + +// Add a third book, then replace an existing key and compare both maps. diff --git a/public/documentation/collections/39-maps.md b/public/documentation/collections/39-maps.md new file mode 100644 index 0000000..1431c38 --- /dev/null +++ b/public/documentation/collections/39-maps.md @@ -0,0 +1,88 @@ +# Maps and keyed lookup + +## What you will learn + +Maps associate each unique key with one value. + +```fsharp +let catalog = Map.ofList [ (1, "Kindred"); (2, "Dune") ] +let found = catalog |> Map.tryFind 2 +``` + +The type is `Map`. `Map.tryFind` makes both lookup outcomes explicit: + +```fsharp +catalog |> Map.tryFind 1 // Some "Kindred" +catalog |> Map.tryFind 9 // None +``` + +`Map.tryFind` returns an option because a key may be absent. `Map.add key value` returns a new map. Keys must support comparison. + +Adding an existing key replaces its associated value in the returned map while the old map remains unchanged: + +```fsharp +let revised = catalog |> Map.add 2 "Dune Messiah" +``` + +`Map.remove key` returns a map without that association. `Map.empty` constructs an empty map when surrounding type context identifies its key and value types. + +The type has two parameters: `Map` associates integer keys with +string values. Every key appears at most once. Constructing a map from repeated +keys keeps the value associated with the last occurrence, so duplicated keys at +an input boundary may deserve validation rather than silent replacement. + +## Updates return new collections + +`catalog |> Map.add id book` returns a map containing the new association. If the key already exists, the returned map associates it with the new value; the old map remains unchanged. + +`Map.tryFind` represents a missing key with `None`, unlike `Map.find`, which raises when the key is absent. A domain workflow can translate `None` into a meaningful `BookNotFound` result. The collection provides lookup behavior; the domain layer supplies the error vocabulary. + +`Map.containsKey` answers only whether a key exists. `Map.change` can calculate +an updated optional association from the current one: + +```fsharp +let removeIfSoldOut id catalog = + catalog + |> Map.change id (fun current -> + match current with + | Some book when book.Copies = 0 -> None + | other -> other) +``` + +Returning `None` removes the key; returning `Some value` stores that value. +Use `change` when the update depends on whether an association already exists. + +## Choose by meaning + +- A reservation list preserves arrival order. +- A catalog map expresses unique keyed lookup. +- A list of search results preserves ordering and may contain several matches. +- A record has a fixed collection of named fields known when its type is defined. + +Map keys must support comparison. Strings, numbers, tuples, records, and unions +commonly qualify when their contents do. Functions do not. A later lesson +examines equality and comparison directly. + +Convert a map to a list only when a list-shaped operation is genuinely needed: + +```fsharp +let books = + catalog + |> Map.toList + |> List.map (fun (_, book) -> book) +``` + +The tuple contains each key and its value. Discarding the key is deliberate +here because the `Book` value already contains its identifier. + +## Try it + +- Add and remove a catalog entry. +- Search for a missing key. +- Add an existing key and inspect old and new maps. +- Use `Map.change` to remove only a sold-out book. + +## Summary + +Use a map when unique keys identify associated values. Lookup keeps absence +explicit, and updates return a new map rather than changing the existing one. diff --git a/public/documentation/collections/40-sets.fsx b/public/documentation/collections/40-sets.fsx new file mode 100644 index 0000000..2904246 --- /dev/null +++ b/public/documentation/collections/40-sets.fsx @@ -0,0 +1,19 @@ +type Genre = + | Fiction + | History + | Science + | Fantasy + +let customerInterests = Set.ofList [ Fiction; History; Fiction ] +let bookGenres = Set.ofList [ Fiction; Science ] + +let shared = Set.intersect customerInterests bookGenres +let allRelevant = Set.union customerInterests bookGenres +let unexplored = Set.difference customerInterests bookGenres + +printfn "Unique interests: %d" (Set.count customerInterests) +printfn "Shared genres: %A" shared +printfn "All relevant genres: %A" allRelevant +printfn "Interests not covered by this book: %A" unexplored + +// Add Fantasy to both sets and predict how each result changes. diff --git a/public/documentation/collections/40-sets.md b/public/documentation/collections/40-sets.md new file mode 100644 index 0000000..c51dd39 --- /dev/null +++ b/public/documentation/collections/40-sets.md @@ -0,0 +1,93 @@ +# Sets and unique values + +## What you will learn + +A set represents unique values when membership matters but duplicates and +positions do not. + +## A list can represent the wrong promise + +A book may have several genre tags: + +```fsharp +let genres = [ "fiction"; "classic"; "fiction" ] +``` + +The list preserves the duplicate and gives every value a position. Neither fact +has useful meaning for tags. A set states the intended rules directly: + +```fsharp +let genres = Set.ofList [ "fiction"; "classic"; "fiction" ] +// set [ "classic"; "fiction" ] +``` + +The repeated value contributes only one member. Set display order follows the +type's comparison order; insertion order is not part of the abstraction. + +## Ask about membership + +```fsharp +let isFiction = genres |> Set.contains "fiction" +let tagCount = genres |> Set.count +``` + +`Set.contains` returns a boolean. Unlike searching a list for a matching +record, there is no separate associated value to return—the member itself is +the fact. + +Updates return new sets: + +```fsharp +let expanded = genres |> Set.add "award winner" +let reduced = expanded |> Set.remove "classic" +``` + +`genres` is unchanged. Adding an existing member returns an equivalent set. + +## Compare groups + +Set operations express relationships that would otherwise require several list +queries: + +```fsharp +let customerInterests = Set.ofList [ "fiction"; "history" ] +let bookGenres = Set.ofList [ "classic"; "fiction" ] + +let shared = Set.intersect customerInterests bookGenres +let combined = Set.union customerInterests bookGenres +let onlyCustomer = Set.difference customerInterests bookGenres +``` + +- `intersect` keeps members present in both sets; +- `union` keeps members present in either set; +- `difference` keeps members from the first set that are absent from the second. + +These operations return new sets and preserve uniqueness. + +## Set, list, or map? + +- Use a list when order and repeated values are meaningful. +- Use a set for unique membership and set relationships. +- Use a map when each unique key is associated with another value. + +Set elements must support comparison. Strings, numbers, and the domain unions +used later in the course commonly do. Functions do not provide the required +comparison behavior. + +## Domain step + +The catalog will store a book's genres as `Set`. That means one book +cannot carry the same genre twice, and recommendation code can ask whether a +customer's interests intersect the book's genres. + +## Experiment + +- Add the same genre twice and inspect the count. +- Compute the union and intersection of two genre sets. +- Remove a missing member and compare the result with the original. +- Rewrite a tag list as a set and state which information was deliberately lost. + +## Summary + +A set models unique membership. Its operations express adding, removing, and +comparing groups without introducing meaningless duplicates or positions. diff --git a/public/documentation/cover.md b/public/documentation/cover.md index 068a464..c6fbcbf 100644 --- a/public/documentation/cover.md +++ b/public/documentation/cover.md @@ -1,30 +1,35 @@ -# F# Language Tour - -Hello there! Curious about this thing called F# and maybe even interested in learning it? You've come to the right place! - -F# is a versatile, multi-paradigm programming language with a functional-first approach, known for its strong and static typing. It seamlessly runs on Microsoft's `.NET` runtime, offering a powerful integration with the .NET ecosystem. Additionally, F# extends its reach beyond the .NET platform, supporting compilation to JavaScript, TypeScript, Python, and [more](https://fable.io/docs/#available-targets), making it a truly cross-platform language with broad applicability. It's a language that values being *succinct*, *correct*, and *performant*. Now, what does any of that mean? -- Succinct in this case means the ability to convey ideas using - relatively little code. If you've used Python before, F#'s syntax - should seem somewhat familiar -- Correct meaning that with the use of F#'s strong type system, one - can make entire classes of bugs non-applicable - - For instance, using `Option` types to denote optional values - reduces the presence of nulls - - And, using `Result` types reduces the presence of exceptions by - requiring you to handle errors explicitly -- Multi-paradigm means that it supports imperative, object-oriented, - and functional coding styles -- Functional-first means that it defaults to functional programming, - using simple, immutable pieces of data being passed around by - functions that take inputs and return output, without side effects -- Being a `.NET` language, F# has access to a rich standard library - and extensive ecosystem on top of a battle-tested, high-performance - runtime - -F# is particularly good for web applications, machine learning, and data science. It's also great for interactive development, both via -REPL and notebooks. - -This tour covers all aspects of the F# language, and assuming you have some prior programming experience should teach you everything you need to write real programs in F#. If at any point you get stuck or have a question do not hesitate to ask in the -[F# Discord server](https://discord.gg/fsharp-196693847965696000). - -If this sounds appealing to you, then go on ahead and let's get started! \ No newline at end of file +# Learn F# by building a bookshop + +This tour is for programmers who are new to F# and functional programming. It +starts with expressions and immutable values, then develops functions, domain +types, collections, explicit errors, modules, object-oriented interoperability, +and controlled mutation. + +You do not need prior knowledge of .NET. When a lesson uses a property, method, +namespace, or other .NET-style API, it explains the relevant idea in context. + +## How the tour works + +Every lesson has two parts. The reading pane develops one idea through small +examples. The editor contains one complete program that you can change and run. +Do not treat the program as a finished answer: predict what it will do, alter one +thing, and let the compiler show you which assumptions were wrong. + +The examples share a bookshop domain. It begins as a few primitive values and +gradually becomes a catalog, inventory, shopping carts, customers, and orders. +Early representations are intentionally simple. Later lessons replace them when +records, unions, options, results, and domain-specific types can express the same +ideas more accurately. + +## What “functional-first” means here + +F# supports functional, object-oriented, and imperative programming. This tour +starts with immutable data and functions because they make dependencies and +state changes easy to see. Later, it introduces classes, interfaces, exceptions, +mutable values, and loops without presenting those tools as mistakes. + +The aim is practical: learn to choose data that describes the problem, write +small functions with clear types, and combine them into behavior that remains +readable as the program grows. + +Begin with the first lesson. Each later lesson assumes the ideas before it. diff --git a/public/documentation/data-and-types/abbreviations.fsx b/public/documentation/data-and-types/abbreviations.fsx deleted file mode 100644 index 7ba4947..0000000 --- a/public/documentation/data-and-types/abbreviations.fsx +++ /dev/null @@ -1,6 +0,0 @@ -type Logger = string -> unit - -let exclaim (logger: Logger) (value: string) = logger (sprintf "%s!!!" value) - -let logger: Logger = printfn "%s" -exclaim logger "Hello" diff --git a/public/documentation/data-and-types/abbreviations.md b/public/documentation/data-and-types/abbreviations.md deleted file mode 100644 index 4983b63..0000000 --- a/public/documentation/data-and-types/abbreviations.md +++ /dev/null @@ -1,16 +0,0 @@ -# Type Abbreviations - -Using type abbreviations, you can define an alias for any existing type. These are often used to create a shorter and reusable name for an existing type or function signature. - -```fsharp -type Logger = string -> unit -``` - -Because the `Logger` type is just an abbreviation for the function signature `string -> unit` you can use the two interchangeably. - -```fsharp -let exclaim (logger: Logger) (value: string) = logger (sprintf "%s!!!" value) - -let logger: Logger = printfn "%s" -exclaim logger "Hello" -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/discriminated-unions.fsx b/public/documentation/data-and-types/discriminated-unions.fsx deleted file mode 100644 index e6ce142..0000000 --- a/public/documentation/data-and-types/discriminated-unions.fsx +++ /dev/null @@ -1,11 +0,0 @@ -type ContactInfo = - | EmailAddress of string - | PhoneNumber of string - -let contact contactInfo = - match contactInfo with - | EmailAddress email -> sprintf "Sending an email to %s" email - | PhoneNumber number -> sprintf "Sending a text message to %s" number - -let contactInfo = PhoneNumber "000-000-0000" -printfn "%s" (contact contactInfo) diff --git a/public/documentation/data-and-types/discriminated-unions.md b/public/documentation/data-and-types/discriminated-unions.md deleted file mode 100644 index df114dc..0000000 --- a/public/documentation/data-and-types/discriminated-unions.md +++ /dev/null @@ -1,51 +0,0 @@ -# Discriminated Unions - -Discriminated unions represent a single choice between several named cases. Each of these cases has an identifier and optionally, data associated with it of varying types. The identifier and the optional data serve as a _case constructor_ or function that will construct an instance of the specified union type. - -```fsharp -type Color = - | Red - | Green - | Blue - | RGB of int * int * int -``` - -The discriminated union defined above has four cases: `Red`, `Green`, `Blue`, and `Rgb`. -Only the `RGB` case has data associated with it. You can construct instances of these cases using the identifier and any data. - -```fsharp -let red = Red -let black = RGB (0, 0, 0) -``` - -The case constructor for the `RGB` case is a function with the signature of `int * int * int -> Color`. - -```fsharp -let rgb: int * int * int -> Color = RGB -``` - -You can match against the cases of a discriminated union by using the _identifier_ pattern. The _identifier_ pattern allows you to match against the case by its identifier and additionally, supply a pattern for any data associated with it. - -```fsharp -let rgb color = - match color with - | Red -> 255, 0, 0 - | Green -> 0, 255, 0 - | Blue -> 0, 0, 255 - | RGB (r, g, b) -> r, g, b - -let color = Red -let (r, g, b) = rgb color -``` - -When dealing with a case that has data in the form of a tuple, it can be difficult to discern which tuple value corresponds to which piece of the data. In these cases, it is good practice to include labels on tuple elements like so: - -```fsharp -type Color = - | Red - | Green - | Blue - | Rgb of r: int * g: int * b: int - -let color = Rgb (r = 255, g = 255, b = 255) -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/generic-types.fsx b/public/documentation/data-and-types/generic-types.fsx deleted file mode 100644 index 5b54a61..0000000 --- a/public/documentation/data-and-types/generic-types.fsx +++ /dev/null @@ -1,3 +0,0 @@ -type Data<'a> = { Value: 'a } -let data: Data = { Value = "Hello, World!" } -printfn "%s" data.Value diff --git a/public/documentation/data-and-types/generic-types.md b/public/documentation/data-and-types/generic-types.md deleted file mode 100644 index 76dd14e..0000000 --- a/public/documentation/data-and-types/generic-types.md +++ /dev/null @@ -1,27 +0,0 @@ -# Generic Types - -Generic type parameters are placeholders for types that will be filled in by the caller. Generic types allow us to parameterize the types of values being used in bindings, function calls, or type definitions. - -For example, you can have an `int list`, a `string list`, or a `float list`. Each of these will only contain values of their respective types. A `string list` cannot contain `float` values and a `float list` cannot contain `string` values. This is an example of generic type parameters in action. - -You can define a generic type parameter using an apostrophe followed by the name of the type parameter. - -```fsharp -type Data<'a> = { Value: 'a } -``` - -The `'a` in the above definition denotes a generic type parameter. If you wanted to define two generic type parameters you could use `'a` and `'b`. Sometimes, context-specific names are more appropriate choices, such as `'success` and `'error`, to model success and error types. - -The `Value` property in the Data type can only contain a value of type `'a`. For `Data` the `Value` property must contain a `string` value. - -```fsharp -let data: Data = { Value = "Hello, World!" } -let value: string = data.Value -``` - -You can also define generic type parameters in functions and pass them to your desired type. Here we can accept a value of our generic `Data` type, passing a generic type parameter to it in the process. - -```fsharp -let printData (data: Data<'a>) = - printfn "%A" data.Value -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/lists.fsx b/public/documentation/data-and-types/lists.fsx deleted file mode 100644 index 75b43d9..0000000 --- a/public/documentation/data-and-types/lists.fsx +++ /dev/null @@ -1,3 +0,0 @@ -let numbers = [ 1; 2; 3 ] -let numbersAsStrings = List.map string numbers -printfn "%A" numbersAsStrings diff --git a/public/documentation/data-and-types/lists.md b/public/documentation/data-and-types/lists.md deleted file mode 100644 index f1d3b7a..0000000 --- a/public/documentation/data-and-types/lists.md +++ /dev/null @@ -1,72 +0,0 @@ -# Lists - -In F#, lists are an immutable series of elements of the same type implemented as a singly linked list. You can define a list by surrounding semicolon-separated values with square brackets. - -```fsharp -let numbers = [ 1; 2; 3 ] -``` - -There are two primary ways to add values to a list: You can prepend elements using the `::` operator, and concatenate two lists using the `@` operator. - -```fsharp -let numbers2 = 0 :: numbers -let numbers3 = numbers2 @ [4; 5; 6] -``` - -Each list has a `head` and a `tail`. The `head` is the first element of the list, and the `tail` is every subsequence element. - -```fsharp -let numbers = [1; 2; 3] -let head = List.head numbers // 1 -let tail = List.tail numbers // [2; 3] -``` - -There are two patterns that allow us to match against and deconstruct list values. The _list_ pattern and the _cons_ pattern. - -The _list_ pattern allows you to supply a pattern for each value in a list. - -```fsharp -let numbers = [1; 2; 3] -match numbers with -| [] -> "The list is empty" -| [a] -> $"The list has one element: {a}" -| ... -> ... -``` - -The _cons_ pattern allows you to deconstruct a list into N elements and the tail. - -```fsharp -let numbers = [1; 2; 3] -match numbers with -| [] -> "The list is empty" -| head :: tail -> $"Head: {head}, Tail: {tail}" -``` - -The `head :: tail` pattern will deconstruct the list `[1; 2; 3]` into `head = 1` and `tail = [2; 3]`. This can also be done for N number of elements: `first :: second :: tail`. The `head :: tail` pattern will match against any list with a single element. While the `first :: second :: tail` pattern will match against any list with at least two elements, and so on. - -The _cons_ pattern is often used in recursive functions. Using the _cons_ pattern we can process the first element in a list, transforming it, then passing the tail of the list back into the recursive function to continue. - -```fsharp -let rec iter (f: 'a -> unit) (xs: 'a list) = - match xs with - | [] -> () - | x :: xs -> - f x - iter f xs -``` - -The `List` module contains common functions for operating with lists. These functions include but are not limited to: -* `map` which applies a transformation function to every element in a list. -* `filter` which removes elements from a list. -* `iter` which applies an `'a -> unit` function to each element in a list and returns `unit`. - -```fsharp -let isEven x = x % 2 = 0 -let numbers = [0; 1; 2; 3; 4; 5;] - -let evenNumbersAsStrings = - numbers // [0; 1; 2; 3; 4; 5;] - |> List.filter isEven // [0; 2; 4] - |> List.map string // ["0"; "2"; "4"] - |> List.iter (printfn "%s") -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/records.fsx b/public/documentation/data-and-types/records.fsx deleted file mode 100644 index d9bb38f..0000000 --- a/public/documentation/data-and-types/records.fsx +++ /dev/null @@ -1,7 +0,0 @@ -type Person = { FirstName: string; LastName: string } - -let johnDoe = { FirstName = "John"; LastName = "Doe" } -printfn "%A" johnDoe - -let janeDoe = { johnDoe with FirstName = "Jane" } -printfn "%A" janeDoe diff --git a/public/documentation/data-and-types/records.md b/public/documentation/data-and-types/records.md deleted file mode 100644 index 743f673..0000000 --- a/public/documentation/data-and-types/records.md +++ /dev/null @@ -1,51 +0,0 @@ -# Records - -Records represent an immutable series of named values. - -```fsharp -type Person = { FirstName: string; LastName: string } -``` - -You can create an instance of this record type by supplying a value for each named property. - -```fsharp -let johnDoe = { FirstName = "John"; LastName = "Doe" } -``` - -You can access individual properties using the _dot notation_. - -```fsharp -let johnDoe = { FirstName = "John"; LastName = "Doe" } -let fullName = johnDoe.FirstName -``` - -As record values are immutable, their properties can't be changed after creation. Instead, you can copy the contents of a record and update a subset of properties using the _copy and update expression_. - -```fsharp -let janeDoe = { johnDoe with FirstName = "Jane" } -``` - -Here, all the properties of `johnDoe` are copied and the `FirstName` is set to `"Jane"` instead. - -As F# is evaluated from top to bottom, the instance of a record value will be inferred by finding the closest record type with matching properties. - -```fsharp -type Person = { FirstName: string; LastName: string } -type Customer = { FirstName: string; LastName: string } - -let johnDoe = { FirstName = "John"; LastName = "Doe" } // Customer -let johnDoe2: Person = { FirstName: string; LastName: string } // Person -let johnDoe3 = { Person.FirstName = "John"; Person.LastName = "Doe" } // Person -``` - -You can pattern match a record value using the _record pattern_. This pattern allows you to specify a pattern for one or more properties of a record. - -```fsharp -type Person = { FirstName: string; LastName: string } - -let identify person = - match person with - | { FirstName = "John"; LastName = "Doe" } - | { FirstName = "Jane"; LastName = "Doe" } -> "Could not identify this person." - | { FirstName = firstName; LastName = lastName } -> $"Identified as: {firstName} {lastName}" -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/sequence-expressions.fsx b/public/documentation/data-and-types/sequence-expressions.fsx deleted file mode 100644 index 569261e..0000000 --- a/public/documentation/data-and-types/sequence-expressions.fsx +++ /dev/null @@ -1,5 +0,0 @@ -let oneThroughTen = [ 1..10 ] -printfn "%A" oneThroughTen - -let evenNumbers = [ 1..2..10 ] -printfn "%A" evenNumbers diff --git a/public/documentation/data-and-types/sequence-expressions.md b/public/documentation/data-and-types/sequence-expressions.md deleted file mode 100644 index 263b88f..0000000 --- a/public/documentation/data-and-types/sequence-expressions.md +++ /dev/null @@ -1,56 +0,0 @@ -# Sequence Expressions - -Sequence expressions allow us to dynamically build lists and sequences using ranges, loops, and conditional expressions. - -We can produce a list from `a` to `b` using the range operator. If we wanted to produce a sequence of values from `1` to `10` we could use the range `1..10`. - -```fsharp -let oneThroughTen = [ 1..10 ] -let oneThroughTenSeq = seq { 1..10 } -``` - -Ranges can also contain a step amount inserted between the start and end index, -which indicates the number of steps per element. The default step is `1` which indicates a single step from one element to the next, ex: `0` to `1`. If we used a step of `2` it would be `0` to `2` instead. - -```fsharp -let evenNumbers = [ 0..2..10 ] -let evenNumbersSeq = seq { 0..2..10 } -``` - -We can make use of `for..in` expressions to iterate over a series of elements and produce N number of elements into the resulting list or sequence. - -```fsharp -let doubled = [ for number in 0..10 -> number * 2 ] -let doubledSeq = seq { for number in 0..10 -> number * 2 } -``` - -The `->` operator will _yield_ the result of the expression into the resulting list. The `->` operator can only be used if every part of the expression block on the right returns a value. Sometimes, you may want an iteration to produce multiple values into a list. For this, you would substitute the `->` operator with a `do` and an optional `yield` for each value. - -```fsharp -let numbers = [ - for x in 0..10 do - for y in 0..10 do - x + y - x * y -// ^^^^^ -// produce multiple values per iteration. -] -``` - -Sequence expressions can also contain conditional expressions that produce zero or many values conditionally. - -```fsharp -let numbers = [ - for number in 0..10 do - if number % 2 = 0 - then $"{number} is even" - else $"{number} is odd" -] - -let onlyEvenNumbers = [ - for number in 0..10 do - if number % 2 = 0 then number -// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -// only produce even numbers into the resulting list -] -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/sequences.fsx b/public/documentation/data-and-types/sequences.fsx deleted file mode 100644 index 4b06086..0000000 --- a/public/documentation/data-and-types/sequences.fsx +++ /dev/null @@ -1 +0,0 @@ -Seq.initInfinite (fun x -> x * 2) |> Seq.take 10 |> Seq.iter (printfn "%d") diff --git a/public/documentation/data-and-types/sequences.md b/public/documentation/data-and-types/sequences.md deleted file mode 100644 index e61b880..0000000 --- a/public/documentation/data-and-types/sequences.md +++ /dev/null @@ -1,25 +0,0 @@ -# Sequences - -In F#, sequences are a lazily-evaluated, potentially infinite, immutable series of elements of the same type. You can define a sequence using the `seq` computation expression. - -```fsharp -seq { 1; 2; 3 } -``` - -The `Seq` module, like the `List` module, includes helper functions for manipulating sequences in certain ways. -Many of these functions are also present in the `List` module, such as `map`, `filter`, `head`, etc... - -```fsharp -let double x = x * 2 - -let sequence = seq { 1; 2; 3 } -let doubledSequence = Seq.map double sequence -``` - -Unlike with Lists, which are eagerly evaluated, the elements of a sequence are only evaluated/produced when necessary. We can see this by creating an infinite sequence from `1` to `infinity` and only evaluating the first 10 elements. - -```fsharp -Seq.initInfinite (fun x -> x * 2) // produce numbers 1 to infinity and multiply each by two -|> Seq.take 10 // take the first 10 numbers -|> Seq.iter (printfn "%d") // print each number to the console. -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/the-option-type.fsx b/public/documentation/data-and-types/the-option-type.fsx deleted file mode 100644 index 50c92d9..0000000 --- a/public/documentation/data-and-types/the-option-type.fsx +++ /dev/null @@ -1,8 +0,0 @@ -type User = { Id: int; Name: string } - -let tryFindUserById id users = - List.tryFind (fun user -> user.Id = id) users - -let users = [ { Id = 1; Name = "John Doe" }; { Id = 2; Name = "Jane Doe" } ] -let foundUser = tryFindUserById 1 users -printfn "Found user: %A" foundUser diff --git a/public/documentation/data-and-types/the-option-type.md b/public/documentation/data-and-types/the-option-type.md deleted file mode 100644 index b5e2d9f..0000000 --- a/public/documentation/data-and-types/the-option-type.md +++ /dev/null @@ -1,77 +0,0 @@ -# The Option Type - -The built-in option type allows us to represent a value that may or may not exist in a composable manner. - -Let's take a look at the definition of the option type - -```fsharp -type Option<'a> = - | Some of 'a - | None -``` - -Here we can see that the option type has 2 potential cases: - 1. `Some` represent a present value - 2. `None` represents the absence of a value. - -There are standard library functions that return `Option` values. One of these is `List.tryFind`, which tries to find the first value in a list matching a predicate. - -```fsharp -type User = { Id: int; Name: string } - -let tryFindUserById id users = - List.tryFind (fun user -> user.Id = id) users -``` - -Options are composable using the `bind` and `map` functions. - -The `map` function allows us to apply a transformation function the inner `Some` value of an option. The `map` function is defined as: - -```fsharp -let map (f: 'a -> 'b) (option: 'a option) : 'b option = - match option with - | None -> None - | Some value -> Some (f value) -``` - -Using the `map` function, we can transform a `User option` to a `string option` which represents the found user's name (if present). - -```fsharp -let users = [ { Id = 1; Name = "John Doe" }; { Id = 2; Name = "Jane Doe" } ] - -let optionalUsername = - users - |> tryFindUserById 1 // Some { Id = 1; Name = "John Doe" } - |> Option.map (fun user -> user.Name) // Some "John Doe" - -let optionalUsername' = - users - |> tryFindUserById 3 // None - |> Option.map (fun user -> user.Name) // None -``` - -The `bind` function is similar, but the inner `Some` value is transformed into another `Option` value. It's defined as: - -```fsharp -let bind (f: 'a -> 'b option) (option: 'a option) : 'b option = - match option with - | None -> None - | Some value -> f value -``` - -With the bind function we can conditionally transform the `User` into a different type using `Option` values. - -```fsharp -type User = { Id: int; Name: string; Age: int } -type AdultUser = { Name: string } - -let userToAdultUser (user: User) : AdultUser option = - if user.Age >= 18 - then Some { AdultUser.Name = user.Name } - else None - -let getAdultUser userId users = - users - |> tryFindUserById userId - |> Option.bind userToAdultUser -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/the-result-type.fsx b/public/documentation/data-and-types/the-result-type.fsx deleted file mode 100644 index 9fd8b8e..0000000 --- a/public/documentation/data-and-types/the-result-type.fsx +++ /dev/null @@ -1,38 +0,0 @@ -type Account = - { Username: string - EmailAddress: string } - -type CreateAccountError = - | UsernameNotAvailable - | EmailAddressNotAvailable - -let ensureUsernameIsAvailable existingAccounts account = - if List.exists (fun x -> x.Username = account.Username) existingAccounts then - Error UsernameNotAvailable - else - Ok account - -let ensureEmailAddressIsAvailable existingAccounts account = - if List.exists (fun x -> x.EmailAddress = account.EmailAddress) existingAccounts then - Error EmailAddressNotAvailable - else - Ok account - -let createAccount existingAccounts account = - account - |> ensureUsernameIsAvailable existingAccounts - |> Result.bind (ensureEmailAddressIsAvailable existingAccounts) - -let existingAcounts = - [ { Username = "john" - EmailAddress = "johndoe@site.com" } - { Username = "jane" - EmailAddress = "janedoe@site.com" } ] - -let accountToCreate = - { Username = "chris" - EmailAddress = "chris@site.com" } - -match createAccount existingAcounts accountToCreate with -| Ok account -> printfn "Created account: %A" account -| Error error -> printfn "Failed to create account: %A" error diff --git a/public/documentation/data-and-types/the-result-type.md b/public/documentation/data-and-types/the-result-type.md deleted file mode 100644 index dddb880..0000000 --- a/public/documentation/data-and-types/the-result-type.md +++ /dev/null @@ -1,60 +0,0 @@ -# The Result Type - -The built-in result type allows us to model success and failure states in a composable manner. - -Let's take a look at the definition of the result type: - -```fsharp -type Result<'ok, 'error> = - | Ok of 'ok - | Error of 'error -``` - -Here, we can see that the result type is a discriminated union with 2 cases: -1. `Ok` to represent success states. -2. `Error` to represent failure states. - -Let's use the result type to create a `safeDivide` function that returns either the result after division or an error message. - -```fsharp -let safeDivide x y = - if y = 0 then - Error "Can't divide by 0" - else - Ok (x / y) - -safeDivide 4 2 // Ok 2 -safeDivide 5 0 // Error "Can't divide by 0" -``` - -It's quite common to model possible errors with a discriminated union, creating a predefined set of possible errors for the given operation. - -```fsharp -type AccountCreationDetails = { Username: string; EmailAddress: string } - -type CreateAccountError = - | UsernameNotAvailable - | EmailAddressNotAvailable - -let createAccount details = ... -``` - -Like the `Option` type, you can compose and transform `Result` types using the `map` and `bind` operations. - -```fsharp -(* - ensureUsernameIsAvailable : Account list -> Account -> Result - ensureEmailIsAvailable : Account list -> Account -> Result -*) - -type Account = { Username: string; EmailAddress: string } - -type CreateAccountError = - | UsernameNotAvailable - | EmailAddressNotAvailable - -let createAccount existingAccounts account = - account - |> ensureUsernameIsAvailable existingAccounts - |> Result.bind (ensureEmailAddressIsAvailable existingAccounts) -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/tuples.fsx b/public/documentation/data-and-types/tuples.fsx deleted file mode 100644 index 82031ac..0000000 --- a/public/documentation/data-and-types/tuples.fsx +++ /dev/null @@ -1,2 +0,0 @@ -let (firstName, lastName) = ("John", "Doe") -printfn "Hello %s %s!" firstName lastName diff --git a/public/documentation/data-and-types/tuples.md b/public/documentation/data-and-types/tuples.md deleted file mode 100644 index 0604826..0000000 --- a/public/documentation/data-and-types/tuples.md +++ /dev/null @@ -1,19 +0,0 @@ -# Tuples - -Tuples allow us to define a set of unnamed values of various types. - -```fsharp -let person = ("John", "Doe") -``` - -The length of a tuple and the types of each element are known at compile time and are present in its signature. The type of the tuple value `(1, 5)` is `int * int`. - -Tuples can be deconstructed in let bindings and function arguments using pattern matching. The tuple pattern allows you to define a pattern for each element in a tuple. You can use the variable pattern to bind each tuple value to a name like so: - -Two useful built-in functions for extracting tuple values are `fst` and `snd`. These functions will extract the first and second element of a two-value tuple respectively. - -```fsharp -let person = ("John", "Doe") -let firstName = fst person -let lastName = snd person -``` \ No newline at end of file diff --git a/public/documentation/data-and-types/units-of-measure.fsx b/public/documentation/data-and-types/units-of-measure.fsx deleted file mode 100644 index 697cd1d..0000000 --- a/public/documentation/data-and-types/units-of-measure.fsx +++ /dev/null @@ -1,16 +0,0 @@ -[] -type mile - -[] -type kilometer - -let miles = 10 -let kilometers = 10 - -let calculateDistanceBetween (startMile: int) (endMile: int) = endMile - startMile - - -let startMile = 10 -let endMile = 20 -let distance = calculateDistanceBetween startMile endMile -printfn "Distance = %d" distance diff --git a/public/documentation/data-and-types/units-of-measure.md b/public/documentation/data-and-types/units-of-measure.md deleted file mode 100644 index 6a52738..0000000 --- a/public/documentation/data-and-types/units-of-measure.md +++ /dev/null @@ -1,35 +0,0 @@ -# Units of Measure - -When working with numerical values representing units of measurement like miles, kilometers, pounds, kilograms, you may be tempted to use primitive values such as: - -```fsharp -let miles = 10 -let kilometers = 10 -``` - - -Both of these values are simple `int`'s. When passing these values around, how do you make the distinction between the use of kilometers and miles in your code? The answer: Units of Measure - -Units of Measure are marker values for numerical primitives like `int`, `float`, `decimal`, and more. These are often used for units of measurement and to create distinct and specific primitive values for domain modeling. Let's define a simple Unit of Measure for `mile` and `kilometer`. - -```fsharp -[] type mile -[] type kilometer -``` - -Now, we can specifically tag numerical values with these Units of Measure. - -```fsharp -let miles = 10 -let kilometers = 10 - -let calculateDistanceBetween - (startMile: int) - (endMile: int) - = - ... - -let startMile = 10 -let endMile = 20 -let distance = calculateDistanceBetween startMile endMile -``` \ No newline at end of file diff --git a/public/documentation/foundations/01-expressions.fsx b/public/documentation/foundations/01-expressions.fsx new file mode 100644 index 0000000..31da693 --- /dev/null +++ b/public/documentation/foundations/01-expressions.fsx @@ -0,0 +1,5 @@ +// Change the values, then run the program again. +printfn "Featured title: %s" "The Hobbit" +printfn "Page count: %d" 310 +printfn "In stock: %b" true +printfn "Two-week reading target: %d pages" (14 * 12) diff --git a/public/documentation/foundations/01-expressions.md b/public/documentation/foundations/01-expressions.md new file mode 100644 index 0000000..c70a07f --- /dev/null +++ b/public/documentation/foundations/01-expressions.md @@ -0,0 +1,83 @@ +# Everything starts with expressions + +## What you will learn + +F# programs are built from expressions: pieces of code that produce values. + +## Values appear when expressions are evaluated + +The smallest useful F# examples are literals. A literal is a textual representation of a value in source code: + +```fsharp +42 +"The Hobbit" +true +``` + +These expressions produce an integer, a string, and a boolean value. An expression can also combine smaller expressions: + +```fsharp +1 + 2 +``` + +The result is `3`. F# is expression-oriented: calculations, decisions, and eventually whole workflows produce values that other expressions can consume. + +Try reading a larger expression from the inside out: + +```fsharp +(10 - 4) * 3 +// 18 +``` + +The parenthesized expression produces `6`; multiplication consumes that value and produces `18`. Nothing in that description is a statement that changes stored state. It is one expression assembled from smaller expressions. + +Text following `//` is a comment. The compiler ignores it: + +```fsharp +42 // this explanation is for the reader +``` + +The playgrounds use comments for suggested experiments. A comment ends at the +end of its line; it does not affect the value produced by the code before it. + +To see those values in the playground, we use F#'s `printfn` function. For now, read it as “print a line.” `%d` marks a place for an integer, `%s` for a string, and `%b` for a boolean. + +```fsharp +printfn "%d" 42 +printfn "%s" "The Hobbit" +printfn "%b" true +``` + +Function calls and formatting get careful treatment later. They are introduced here only as the playground's display controls. + +After printing, `printfn` produces `()`, pronounced “unit.” Unit means that there is no useful calculated value to pass along. We will return to it when we study functions. + +## In the bookshop + +Our application begins as a few facts: a title, a page count, and whether a book is in stock. They are not connected yet. That simplicity is useful; each later lesson will give the facts more structure. + +```fsharp +printfn "%s" "Kindred" +printfn "%d" (264 - 40) +printfn "%b" true +``` + +Before running, predict the three lines. The second argument to the middle call is itself an arithmetic expression, so it is evaluated before `printfn` displays its value. + +## A useful first error + +```fsharp +printfn "%d" "Kindred" +``` + +`%d` indicates that an integer will be supplied, but the argument is a string. The compiler can reject this before the program runs. For now, look for the two disagreeing types in a diagnostic: expected `int`, received `string`. + +## Try it + +- Change each literal and predict the output. +- Replace `1 + 2` with `10 - 3`. +- Try printing a string with `%d`. Read the type error as “an integer was expected, but a string was supplied.” + +## Summary + +Expressions evaluate to values. Values have types, even before we have named or formally discussed those types. diff --git a/public/documentation/foundations/02-bindings.fsx b/public/documentation/foundations/02-bindings.fsx new file mode 100644 index 0000000..0e88a9d --- /dev/null +++ b/public/documentation/foundations/02-bindings.fsx @@ -0,0 +1,10 @@ +let bookTitle = "The Hobbit" +let pageCount = 310 +let pagesRead = 125 +let pagesRemaining = pageCount - pagesRead + +printfn "Reading: %s" bookTitle +printfn "Pages read: %d" pagesRead +printfn "Pages remaining: %d" pagesRemaining + +// Try changing pagesRead while leaving pageCount alone. diff --git a/public/documentation/foundations/02-bindings.md b/public/documentation/foundations/02-bindings.md new file mode 100644 index 0000000..fe927dc --- /dev/null +++ b/public/documentation/foundations/02-bindings.md @@ -0,0 +1,72 @@ +# Values and `let` bindings + +## What you will learn + +How `let` gives a stable name to a value. + +## Naming a result + +Repeating a literal makes its meaning unclear. F# uses `let` to introduce a binding: + +```fsharp +let pageCount = 310 +``` + +Read this as “bind the name `pageCount` to the value `310`.” It does not declare a mutable local variable. F# bindings are immutable by default: `pageCount` continues to mean the same value throughout its scope. + +Names use camel case by convention: + +```fsharp +let bookTitle = "The Hobbit" +let isAvailable = true +``` + +The `=` has two closely related jobs in F#. In a `let` binding it separates the pattern being bound from the expression being evaluated. Later, inside an expression, `=` tests equality. + +Bindings can depend on earlier bindings: + +```fsharp +let pagesRead = 125 +let pagesRemaining = pageCount - pagesRead +``` + +`pagesRemaining` is a new value. Nothing changed `pageCount` or `pagesRead`. + +Evaluation happens before the name is bound. In this example, F# calculates `310 - 125`, then binds `pagesRemaining` to `185`: + +```fsharp +let pagesRemaining = 310 - 125 +// 185 +``` + +The expression is evaluated once, and the name refers to its result. It is not a formula that recalculates itself later. + +## Shadowing is a new binding + +F# permits a later binding to reuse a name: + +```fsharp +let label = "Kindred" +let label = label + " — available" +``` + +This is *shadowing*, not mutation. The right side of the second line refers to the earlier `label`; afterward the newer binding is the one in scope. Shadowing is useful in short transformation sequences, but repeatedly reusing a name can obscure which value is meant. Prefer distinct names while learning. + +## Why immutability helps + +Code is easier to follow when a name keeps the same meaning. F# supports mutation, but you must request it explicitly. We will wait until immutable transformations feel familiar before using it. + +## In the bookshop + +We can now name one book's facts and derive its reading progress. The model is still primitive, but the intent is becoming visible. + +## Try it + +- Change `pagesRead` and predict `pagesRemaining`. +- Add a `dailyTarget` binding calculated from `pagesRemaining`. +- Try writing `pageCount = 400` on a later line. Notice that this is an equality expression, not assignment. +- Shadow `bookTitle` with `bookTitle + " — available"` and confirm the earlier string was used to create the new value. + +## Summary + +`let` binds a name to an evaluated value. Bindings are immutable unless you explicitly mark them otherwise. diff --git a/public/documentation/foundations/03-numbers.fsx b/public/documentation/foundations/03-numbers.fsx new file mode 100644 index 0000000..5dad635 --- /dev/null +++ b/public/documentation/foundations/03-numbers.fsx @@ -0,0 +1,12 @@ +let unitPrice = 14.95 +let quantity = 3 +let lineTotal = float quantity * unitPrice + +let discountRate = 0.10 +let discount = lineTotal * discountRate +let finalTotal = lineTotal - discount + +printfn "Line total: %.2f" lineTotal +printfn "After discount: %.2f" finalTotal + +// Try changing quantity or discountRate. diff --git a/public/documentation/foundations/03-numbers.md b/public/documentation/foundations/03-numbers.md new file mode 100644 index 0000000..bf47231 --- /dev/null +++ b/public/documentation/foundations/03-numbers.md @@ -0,0 +1,70 @@ +# Numbers and arithmetic + +## What you will learn + +How F# distinguishes numeric types and evaluates arithmetic expressions. + +## Whole numbers + +`int` is the usual whole-number type: + +```fsharp +let copiesInStock = 12 +let copiesReserved = 5 +let availableCopies = copiesInStock - copiesReserved +``` + +The familiar operators `+`, `-`, `*`, and `/` produce values. Multiplication and division have higher precedence than addition and subtraction. Parentheses make grouping explicit: + +```fsharp +let result = (2 + 3) * 4 +``` + +Integer division produces an integer and truncates toward zero: `7 / 2` is `3`, while `-7 / 2` is `-3`. Use `%` for the remainder: + +```fsharp +let completeShelves = 17 / 5 +let booksLeftOver = 17 % 5 +// 3 complete shelves, 2 books left over +``` + +## Floating-point numbers + +A literal with a decimal point is a `float`: + +```fsharp +let replacementCost = 14.95 +``` + +F# does not silently mix `int` and `float`. Convert deliberately: + +```fsharp +let totalCost = float copiesInStock * replacementCost +``` + +That explicit conversion makes the numeric intent visible. `float` produces a new value and leaves the original integer alone. + +```fsharp +let pages = 264 +let half = float pages / 2.0 +// 132.0 +``` + +Other numeric types exist, including `int64` and `decimal`, but choosing among them involves range, precision, and runtime considerations beyond this first arithmetic lesson. This course initially uses `int` for counts and `float` for approximate rates and teaching-domain prices; later it models money more deliberately. + +> .NET note: `int` is the F# name for `System.Int32`, and `float` is the name commonly used for `System.Double`. Floating-point arithmetic is approximate, which is why the later money model uses integer cents. + +## In the bookshop + +Inventory uses integers. Introductory prices and discount rates use floats. Conversions happen only where those two worlds meet. + +## Try it + +- Change `quantity` to `5` and predict the total. +- Remove `float` from a mixed calculation and read the compiler error. +- Compare `7 / 2` with `7.0 / 2.0`. +- Predict `-7 / 2` and `-7 % 2`, then run them. + +## Summary + +Numeric types are distinct. Arithmetic produces new values, and conversions are explicit. diff --git a/public/documentation/foundations/04-text.fsx b/public/documentation/foundations/04-text.fsx new file mode 100644 index 0000000..5e89ba6 --- /dev/null +++ b/public/documentation/foundations/04-text.fsx @@ -0,0 +1,10 @@ +let title = "A Wizard of Earthsea" +let author = "Ursula K. Le Guin" +let categoryCode = 'F' +let label = $"%s{title} by %s{author} — category %c{categoryCode}" + +printfn "%s" label +printfn "Title length: %d" title.Length +printfn "Contains Earthsea: %b" (title.Contains("Earthsea")) + +// Try changing the title and search text. diff --git a/public/documentation/foundations/04-text.md b/public/documentation/foundations/04-text.md new file mode 100644 index 0000000..996c0fc --- /dev/null +++ b/public/documentation/foundations/04-text.md @@ -0,0 +1,104 @@ +# Strings, characters, and text + +## What you will learn + +How to represent and combine text without treating strings as mysterious objects. + +## Strings and characters + +A string is text between double quotes: + +```fsharp +let title = "A Wizard of Earthsea" +``` + +A `char` literal is one UTF-16 code unit between single quotes: + +```fsharp +let shelfLetter = 'F' +``` + +A `char` and a one-character `string` are different F# types. A single visible Unicode symbol can sometimes require more than one UTF-16 code unit, so `char` is best for constrained values such as an ASCII category code. Use `string` for general text. + +Escape sequences represent otherwise awkward characters: + +```fsharp +let quoted = "She said, \"Read this.\"" +let twoLines = "First line\nSecond line" +``` + +Strings concatenate with `+`: + +```fsharp +let description = title + " by " + author +``` + +String interpolation is often clearer: + +```fsharp +let description = $"%s{title} by %s{author}" +``` + +The `$` enables interpolation. Each inserted expression has a format specifier +that states the type of value expected: `%s` for a string, `%d` for an integer, +`%f` for a floating-point value, `%b` for a boolean, and `%c` for a character. +The expression to insert follows in braces. Typed interpolation lets the +compiler check the format and avoids leaving the inserted value's type +unconstrained. + +```fsharp +let copies = 3 +let price = 8.50 +let stockLabel = $"%d{copies} copies at %f{price} each" +``` + +## A few common operations + +Strings have members, accessed with a dot: + +```fsharp +title.Length +title.ToUpper() +title.Contains("Earthsea") +``` + +`.Length` is a property: it is read like data and has no call parentheses. It counts UTF-16 code units, not necessarily user-perceived characters. `.ToUpper()` and `.Contains(...)` are methods and use parentheses for their arguments. Case conversion can depend on culture; use `.ToUpperInvariant()` when a culture-independent normalization rule is intended. + +F# also provides functions in the `String` module: + +```fsharp +let characterCount = String.length title +let divider = String.replicate 3 "-" +``` + +F# works comfortably with both object-oriented members and function-oriented modules. Compare their call styles: + +```fsharp +title.Contains("Earthsea") // receiver.Member(argument) +String.length title // function argument +``` + +Choose the form that makes the operation clearest. There is no benefit in mechanically converting every member call into a module call. + +## Indexing needs care + +```fsharp +let firstLetter = title[0] +``` + +Indexes start at zero. Indexing an empty string or using an out-of-range index fails at runtime because the type `string` does not encode its length. Prefer operations such as `Contains` when you do not actually need a position. + +## In the bookshop + +We can produce a readable catalogue label and check a simple title search. + +## Try it + +- Add the page count to the interpolated description. +- Search for a different word with `.Contains`. +- Print the first character with `title[0]`. +- Write the same character count once with `.Length` and once with `String.length`. + +## Summary + +Strings and characters are typed values. Interpolation combines values into readable text; members provide common operations. diff --git a/public/documentation/foundations/05-booleans.fsx b/public/documentation/foundations/05-booleans.fsx new file mode 100644 index 0000000..c163793 --- /dev/null +++ b/public/documentation/foundations/05-booleans.fsx @@ -0,0 +1,12 @@ +let title = "The Left Hand of Darkness" +let availableCopies = 2 +let isForSale = true + +let hasCopies = availableCopies > 0 +let canOrder = hasCopies && isForSale +let needsAttention = availableCopies = 0 || not isForSale + +printfn "%s can be ordered: %b" title canOrder +printfn "Needs staff attention: %b" needsAttention + +// Try zero copies, then try making the book unavailable for sale. diff --git a/public/documentation/foundations/05-booleans.md b/public/documentation/foundations/05-booleans.md new file mode 100644 index 0000000..aa929d0 --- /dev/null +++ b/public/documentation/foundations/05-booleans.md @@ -0,0 +1,83 @@ +# Booleans and comparisons + +## What you will learn + +How expressions answer yes-or-no questions. + +## Boolean values + +`bool` has two values: `true` and `false`. + +```fsharp +let isAvailable = true +let isReferenceOnly = false +``` + +Comparisons produce boolean values: + +```fsharp +let hasCopies = availableCopies > 0 +let exactlyOne = availableCopies = 1 +let notEmpty = title <> "" +``` + +In expressions, `=` tests equality and `<>` tests inequality. This differs from languages that use `==` or `!=`. + +Other comparison operators are `<`, `>`, `<=`, and `>=`. + +## Combining questions + +`&&` means both expressions must be true. `||` means at least one must be true. `not` reverses a boolean: + +```fsharp +let canBorrow = hasCopies && not isReferenceOnly +let needsAttention = isReferenceOnly || availableCopies = 0 +``` + +`not` is a function. Function application has higher precedence than infix operators, so `not isReferenceOnly` is evaluated before `&&` combines the results. + +Boolean operators short-circuit. In `left && right`, F# evaluates `right` only when `left` is true; in `left || right`, it evaluates `right` only when `left` is false. This matters when the second expression performs work or could fail, although domain predicates are usually easiest to reason about when they are pure. + +Parentheses can make a mixed rule unambiguous: + +```fsharp +let canUseCopy = + isAvailable && (isMember || not isRestricted) +``` + +When grouping makes the business rule clearer, prefer it to relying on remembered precedence. + +## Equality is an expression + +In `let exactlyOne = availableCopies = 1`, the first `=` belongs to the binding +syntax and the second compares two integers. Read the right-hand side as a +complete expression: + +```text +availableCopies = 1 +→ true or false +``` + +This is why writing `availableCopies = 4` by itself does not assign four. It +asks whether the current value equals four. + +## Structural equality + +F# can compare many values structurally. Strings and numbers compare by value. Records, tuples, and lists will later compare their contents when their contained values support equality. + +## In the bookshop + +Our first business rule asks whether a book can be ordered. It is still only a boolean; later a discriminated union will explain *why* ordering may be impossible. + +The limitation matters: `false` cannot distinguish “sold out” from “not currently for sale.” booleans answer yes-or-no questions well, but richer outcomes need richer types. + +## Try it + +- Set `availableCopies` to zero. +- Make the book unavailable for sale. +- Predict each intermediate boolean before running. +- Expand `canOrder` into its smaller comparisons and evaluate them one at a time. + +## Summary + +Comparisons produce booleans. boolean operators combine small questions into larger rules. diff --git a/public/documentation/foundations/06-conditionals.fsx b/public/documentation/foundations/06-conditionals.fsx new file mode 100644 index 0000000..4d3c025 --- /dev/null +++ b/public/documentation/foundations/06-conditionals.fsx @@ -0,0 +1,11 @@ +let title = "Kindred" +let availableCopies = 1 + +let stockLabel = + if availableCopies = 0 then "Unavailable" + elif availableCopies = 1 then "Last available copy" + else "Available" + +printfn "%s — %s" title stockLabel + +// Try availableCopies values of 0, 1, and 5. diff --git a/public/documentation/foundations/06-conditionals.md b/public/documentation/foundations/06-conditionals.md new file mode 100644 index 0000000..d71d8ed --- /dev/null +++ b/public/documentation/foundations/06-conditionals.md @@ -0,0 +1,128 @@ +# Conditional expressions + +## What you will learn + +How an `if` expression chooses which expression to evaluate. + +## A decision produces a result + +In F#, an `if` expression selects one branch and evaluates that branch’s expression to produce a value: + +```fsharp +let availabilityLabel = + if availableCopies > 0 then + "Available" + else + "Unavailable" +``` + +The condition after `if` must be a `bool`. If it is true, the expression after `then` becomes the result. Otherwise the expression after `else` does. + +F# has no general “truthy” conversion for conditions. An integer or string is +not silently treated as a boolean: + +```fsharp +if availableCopies then "yes" else "no" +``` + +This is rejected because `availableCopies` is `int`, not `bool`. State the +actual question instead: `availableCopies > 0`. + +Both branches must produce compatible types. This is invalid: + +```fsharp +if availableCopies > 0 then + "Available" +else + 0 +``` + +The surrounding program needs to know the type of the whole `if`, so one branch cannot produce a `string` while the other produces an `int`. + +The condition is evaluated first, but only the selected branch is evaluated. +This matters when one branch contains an operation that is valid only for that +case: + +```fsharp +let firstLetter = + if title = "" then + '?' + else + title[0] +``` + +For an empty title, the indexing expression is never evaluated. This does not +make indexing generally safe; the protective check and the guarded operation +must remain connected. + +Because the conditional itself is a value-producing expression, it can appear wherever an expression is expected: + +```fsharp +let loanDays = if isReferenceBook then 7 else 21 +let dueDay = checkoutDay + (if isHolidayWeek then 7 else 0) +``` + +The parentheses in the second example group the conditional as the right operand of `+`. Usually a named binding like `extensionDays` would be easier to read. + +## Indentation is syntax + +F# uses indentation to group code. The two branch expressions are indented beneath `if` and `else`. Consistent four-space indentation makes the structure visible without braces. + +## Several conditions + +Use `elif` for another condition: + +```fsharp +let stockLabel = + if copies = 0 then + "Out of stock" + elif copies = 1 then + "Last copy" + else + "In stock" +``` + +Later, pattern matching will express decisions based on the *shape* of richer data. `if` remains excellent for boolean conditions. + +An `elif` chain chooses the first true condition. Put narrower cases before +broader ones: + +```fsharp +if copies = 0 then + "Out of stock" +elif copies < 5 then + "Low stock" +else + "In stock" +``` + +If the `copies < 5` branch came first, zero would be classified merely as low +stock and the exact zero branch would never be reached. + +## An `if` without `else` + +F# permits an omitted `else` only when the `then` branch returns `unit`, commonly for an effect: + +```fsharp +if availableCopies = 0 then + printfn "%s" "No copies available" +``` + +The missing branch produces `()`. When an `if` calculates a value, write both branches so its result is clear. + +## In the bookshop + +We turn the stock calculation into a message a customer can understand, without creating a mutable temporary variable. + +## Try it + +- Test zero, one, and several copies. +- Add a branch for five or more copies. +- Deliberately return an integer from one branch and inspect the error. +- Reorder overlapping conditions and explain the changed result. +- Guard a string index with an empty-string check. +- Try using an integer directly as a condition and find the two types in the error. + +## Summary + +`if` is an expression that produces a value. Its branches agree on a result type. diff --git a/public/documentation/foundations/07-types.fsx b/public/documentation/foundations/07-types.fsx new file mode 100644 index 0000000..bdc8716 --- /dev/null +++ b/public/documentation/foundations/07-types.fsx @@ -0,0 +1,16 @@ +let maximumQuantity: int = 5 +let memberDiscount: float = 0.10 +let customerName: string = "Amina" + +let requestedQuantity = 3 +let remainingAllowance = maximumQuantity - requestedQuantity +let discountOnTwenty = memberDiscount * 20.0 +let wideMaximum: int64 = maximumQuantity +let floatingMaximum: float = maximumQuantity + +printfn "%s may add %d more book(s)." customerName remainingAllowance +printfn "The discount on 20.00 is %.2f." discountOnTwenty +printfn "Implicit widenings: %A and %A" wideMaximum floatingMaximum + +// Remove the annotations F# can infer, then try narrowing a float to int +// without calling int. Read the error before adding the explicit conversion. diff --git a/public/documentation/foundations/07-types.md b/public/documentation/foundations/07-types.md new file mode 100644 index 0000000..fe5416a --- /dev/null +++ b/public/documentation/foundations/07-types.md @@ -0,0 +1,120 @@ +# Types and type inference + +## What you will learn + +How F# infers types, how to read simple annotations, and how type errors help. + +## The compiler has been tracking types all along + +F# is statically typed. Every well-typed expression has a type established before it runs. You usually do not write those types because the compiler infers them: + +```fsharp +let copyCount = 3 // int +let lateFee = 1.5 // float +let title = "Kindred" // string +let available = true // bool +``` + +Comments after `//` show the inferred types; they are not required code. + +The compiler follows how a value is used. In this expression: + +```fsharp +let nextCount = copyCount + 1 +``` + +`+` and the integer literal constrain `nextCount` to `int`. + +Inference also flows through a chain of bindings: + +```fsharp +let maximum = 5 +let requested = 2 +let remaining = maximum - requested +``` + +The integer literals and subtraction constrain all three values to `int`. The +next lesson introduces function parameters; after that, the course develops how +inference follows values through function calls as well. + +## Explicit annotations + +Add an annotation after a name when it clarifies a boundary: + +```fsharp +let maximumQuantity: int = 5 +let memberDiscount: float = 0.1 +``` + +An annotation supplies an expected type. Modern F# supports a limited set of +type-directed implicit conversions when both the source and destination types +are known. In particular, an `int` can be widened implicitly to `int64`, +`nativeint`, or `float`: + +```fsharp +let fee: float = 2 +let largeCount: int64 = 2 +``` + +An unsuffixed whole-number literal such as `2` normally has type `int`. Here the +annotations provide known destination types, so the compiler inserts safe +widening conversions. Every `int` value can be represented by `int64`, and every +32-bit integer can be represented exactly by `float`, which is F#'s name for +the double-precision floating-point type. + +This is not restricted to literals. An existing `int` value can be widened when +the destination is also known: + +```fsharp +let copyCount = 2 +let wideCount: int64 = copyCount +let averageCopies: float = copyCount +``` + +F# does not apply implicit numeric conversion generally. Narrowing conversions, +conversions that may lose information, and many other numeric combinations +remain explicit: + +```fsharp +let average = 2.75 +let wholeCopies: int = int average +``` + +The call to `int` makes the potentially lossy conversion visible. Even some +widenings outside F#'s supported implicit set require an explicit conversion. +Treat implicit conversion as a small, type-directed convenience rather than a +rule that all numeric types mix automatically. + +Annotate the smallest useful boundary. Writing types on every local value fights inference and adds noise; refusing all annotations can leave overloaded members or public domain boundaries ambiguous. + +## Read a diagnostic from the outside in + +For this mistake: + +```fsharp +let available = 4 +let label = if available then "yes" else "no" +``` + +The compiler expected the `if` condition to be `bool` but found `int`. Start at the construct imposing the expectation (`if`), then inspect the supplied expression (`available`). This technique scales better than reading a long diagnostic as one sentence. + +## Reading errors + +Many F# errors reduce to “expected one type, received another.” Look for the two type names and then inspect the expression joining them. A message mentioning `string` and `int` often means a string was supplied where arithmetic expected a number. + +## In the bookshop + +Annotations can document boundaries such as a maximum order quantity. Inference keeps the calculations inside those boundaries uncluttered. + +## Try it + +- Annotate `title` as `string`. +- Widen the same `int` binding to both `int64` and `float`. +- Try converting a `float` to `int` without calling `int`, read the error, and + then add the explicit conversion. +- Give `maximumQuantity` a string value and read the error. +- Remove all annotations and verify that the program still works. + +## Summary + +F# infers types from expressions. Annotations communicate intent; they are not mandatory ceremony. diff --git a/public/documentation/functions/08-functions.fsx b/public/documentation/functions/08-functions.fsx new file mode 100644 index 0000000..cbf908a --- /dev/null +++ b/public/documentation/functions/08-functions.fsx @@ -0,0 +1,9 @@ +let calculateSalePrice price = price * 0.9 + +let firstPrice = calculateSalePrice 12.0 +let secondPrice = calculateSalePrice (10.0 + 15.0) + +printfn "Sale price for 12.00: %.2f" firstPrice +printfn "Sale price for 25.00: %.2f" secondPrice + +// Try changing the discount or adding another call. diff --git a/public/documentation/functions/08-functions.md b/public/documentation/functions/08-functions.md new file mode 100644 index 0000000..20b9f01 --- /dev/null +++ b/public/documentation/functions/08-functions.md @@ -0,0 +1,79 @@ +# Your first functions + +## What you will learn + +A function gives a name to a calculation that can be repeated with different input. + +## From one calculation to a reusable rule + +We can calculate one sale price with values and expressions: + +```fsharp +let price = 12.0 +let salePrice = price * 0.9 +``` + +A shop needs the same ten-percent rule for many prices. Put the varying value after the function's name: + +```fsharp +let calculateSalePrice price = + price * 0.9 +``` + +`price` is a *parameter*. The indented expression is the function body. A function produces the value of its final expression; there is normally no `return` keyword. + +Call, or *apply*, the function by putting an argument after it: + +```fsharp +let salePrice = calculateSalePrice 12.0 +``` + +This is how F# applies a function. Unlike several other languages, F# does not use parentheses as call punctuation. Parentheses group an expression when grouping is needed: + +```fsharp +let result = calculateSalePrice (10.0 + 2.0) +``` + +The compiler infers `calculateSalePrice : float -> float`: it accepts a `float` and produces a `float`. Read the arrow as “to.” + +```fsharp +let first = calculateSalePrice 10.0 +let second = calculateSalePrice 25.0 +// 9.0 and 22.5 +``` + +Each call evaluates the function with the supplied argument. This function keeps no memory of earlier calls. + +## Functions are values, and effects are functions too + +A function definition is still a `let` binding: the name is bound to a function value. Applying it does not print automatically; `printfn` is a separate function with the observable effect of displaying text. + +`calculateSalePrice` is *pure*: for the same price it always returns the same result and changes nothing elsewhere. F# is not a purely functional language, but pure calculations are especially easy to reason about. + +## Parentheses group an argument + +```fsharp +calculateSalePrice 10.0 + 2.0 +``` + +means “calculate the sale price, then add two.” By contrast, `calculateSalePrice (10.0 + 2.0)` adds first and passes twelve. Function application binds more tightly than arithmetic. + +## The next missing piece + +A reusable discount really needs both a rate and a price: + +```fsharp +let discountAmount rate price = price * rate +``` + +That function has two inputs, which the next lesson examines carefully. + +## Try it + +- Predict `calculateSalePrice 0.0` before running it. +- Change the fixed discount from ten to twenty percent. +- Define `addTax price = price * 1.2` and call it. + +## Summary + +A function maps input to output. Define it with `let`, apply it with whitespace, and look to its final expression for its result. diff --git a/public/documentation/functions/09-unit-and-effects.fsx b/public/documentation/functions/09-unit-and-effects.fsx new file mode 100644 index 0000000..a357920 --- /dev/null +++ b/public/documentation/functions/09-unit-and-effects.fsx @@ -0,0 +1,13 @@ +let makeLabel title = "Featured book: " + title + +let printDivider () = + printfn "%s" "------------------------------" + +let announce title = + printDivider () + printfn "%s" (makeLabel title) + printDivider () + +announce "Kindred" + +// Predict the output order, then change makeLabel without changing announce. diff --git a/public/documentation/functions/09-unit-and-effects.md b/public/documentation/functions/09-unit-and-effects.md new file mode 100644 index 0000000..1a20d98 --- /dev/null +++ b/public/documentation/functions/09-unit-and-effects.md @@ -0,0 +1,123 @@ +# Unit, effects, and functions that do things + +## What you will learn + +Some functions calculate a useful value. Others perform an observable action +and return `unit` to say that there is no useful result to pass onward. + +## A function result is not always interesting data + +The sale-price function from the previous lesson returns a number: + +```fsharp +let calculateSalePrice price = + price * 0.9 +// float -> float +``` + +`printfn` behaves differently. Its important outcome is that text appears in +the output pane: + +```fsharp +printfn "%s" "Kindred" +``` + +After printing, the expression produces `()`. This value has type `unit`. +There is exactly one ordinary `unit` value, so it carries no choice or domain +information. It lets an expression fit into F#'s type system even when its +purpose is an effect such as displaying text. + +```fsharp +let printed = printfn "%s" "Kindred" +// printed : unit +// printed = () +``` + +An *effect* is an observable interaction beyond returning a value. Printing is +an effect. Later examples include mutation and exceptions. F# permits effects; +functional style asks us to keep them visible instead of pretending they are +ordinary calculations. + +## Functions can return unit + +```fsharp +let announce title = + printfn "Featured book: %s" title +// string -> unit +``` + +The arrow still means “input to output.” `announce` accepts a string and +returns `unit`. Calling it performs the print: + +```fsharp +announce "Kindred" +``` + +Compare that with a function that only calculates: + +```fsharp +let makeLabel title = + "Featured book: " + title +// string -> string +``` + +`makeLabel` is easier to reuse because it does not decide where the string goes. +The caller can print it, store it, or combine it with other text. A common +design is to calculate first and perform the effect at the edge: + +```fsharp +let label = makeLabel "Kindred" +printfn "%s" label +``` + +## A unit input means “no information required” + +A function may require no meaningful input but still need an explicit call: + +```fsharp +let printDivider () = + printfn "%s" "----------------" +// unit -> unit +``` + +Call it with the unit value: + +```fsharp +printDivider () +``` + +Without `()`, `printDivider` refers to the function value; it does not call the +function. This is the same distinction as referring to `calculateSalePrice` +without supplying a price. + +## Several effects run in order + +Indented expressions in a function body are evaluated from top to bottom: + +```fsharp +let showBook title = + printDivider () + printfn "%s" title + printDivider () +``` + +Each of the first two expressions returns `unit`, and evaluation continues. The +final expression is also unit-producing, so `showBook` has type +`string -> unit`. + +A non-final expression in such a sequence is normally expected to return +`unit`. Accidentally discarding a useful value often produces a warning, +because it may mean a calculation was forgotten. + +## Experiment + +- Predict the order of three printed lines before running them. +- Remove `()` from a `printDivider` call and inspect the type error. +- Change `makeLabel` without changing the printing code. +- Bind the result of `announce "Dune"` and inspect its type. + +## Summary + +`unit` represents the absence of a useful result. A unit-returning function can +perform an effect, while a unit input makes a no-information call explicit. +Separating calculations from effects usually leaves both easier to understand. diff --git a/public/documentation/functions/10-multiple-inputs.fsx b/public/documentation/functions/10-multiple-inputs.fsx new file mode 100644 index 0000000..2e50033 --- /dev/null +++ b/public/documentation/functions/10-multiple-inputs.fsx @@ -0,0 +1,11 @@ +let applyDiscount rate price = price * (1.0 - rate) + +let describeLine title quantity = $"%d{quantity} × %s{title}" + +let price = applyDiscount 0.10 20.0 +let description = describeLine "The Left Hand of Darkness" 2 + +printfn "%s" description +printfn "Discounted unit price: %.2f" price + +// Try swapping the argument order of applyDiscount and update its call. diff --git a/public/documentation/functions/10-multiple-inputs.md b/public/documentation/functions/10-multiple-inputs.md new file mode 100644 index 0000000..b8b1568 --- /dev/null +++ b/public/documentation/functions/10-multiple-inputs.md @@ -0,0 +1,85 @@ +# Functions with multiple inputs + +## What you will learn + +F# functions commonly receive inputs one at a time, separated by spaces. + +## Adding the missing context + +A discount depends on both its rate and the original price: + +```fsharp +let applyDiscount rate price = + price * (1.0 - rate) +``` + +Apply it by supplying arguments in the same order: + +```fsharp +let salePrice = applyDiscount 0.10 20.0 +``` + +Trace the names rather than reading the numbers as an undifferentiated argument +list: + +```text +rate = 0.10 +price = 20.0 +result = 20.0 * (1.0 - 0.10) = 18.0 +``` + +Each call creates fresh parameter bindings for that evaluation. Calling +`applyDiscount 0.25 8.0` does not change what `rate` meant in the earlier call. + +```text +float -> float -> float +``` + +Read the signature from left to right for now: the function receives a `float` +rate, receives a `float` price, and produces a `float` result. Lesson 15 will +look beneath that convenient reading and explain why the arrows are written as +a chain. + +Parentheses control which expression becomes an argument: + +```fsharp +let price = applyDiscount (0.05 + 0.05) (15.0 + 5.0) +``` + +They do not surround a whole argument list. Each argument remains a separate +expression following the function name. + +## Parameter order is part of the contract + +The first argument supplied becomes `rate`; the second becomes `price`. +Reversing the parameters changes every call even though the arithmetic can +remain the same: + +```fsharp +let applyDiscountTo price rate = + price * (1.0 - rate) + +let salePrice = applyDiscountTo 20.0 0.10 +``` + +Choose an order that makes ordinary calls readable. Later lessons show how +partial application and pipelines give the final parameter an additional role. + +## Compiler clinic + +If the rate is text, the diagnostic points toward the arithmetic that expected +a number. If rate and price are accidentally reversed, the program still +type-checks because both are `float`; types cannot distinguish two values with +the same representation. Clear parameter names, argument order, and realistic +examples still matter after code compiles. + +## Try it + +- Predict `rate`, `price`, and the result for two calls before running them. +- Reverse the parameters and update every call. +- Add `lineTotal quantity price = float quantity * price`. +- Deliberately pass text where a number is expected and locate the mismatched input. + +## Summary + +Multiple-input functions use space-separated parameters and arguments. Their arrow signatures describe a sequence of inputs leading to an output. diff --git a/public/documentation/functions/11-local-bindings.fsx b/public/documentation/functions/11-local-bindings.fsx new file mode 100644 index 0000000..4bbc04d --- /dev/null +++ b/public/documentation/functions/11-local-bindings.fsx @@ -0,0 +1,13 @@ +let calculateCharge price quantity memberDiscount = + let subtotal = price * float quantity + let discountAmount = subtotal * memberDiscount + let discounted = subtotal - discountAmount + discounted + +let regularCharge = calculateCharge 12.0 2 0.0 +let memberCharge = calculateCharge 12.0 2 0.10 + +printfn "Regular charge: %.2f" regularCharge +printfn "Member charge: %.2f" memberCharge + +// Change the quantity and predict which local values are affected. diff --git a/public/documentation/functions/11-local-bindings.md b/public/documentation/functions/11-local-bindings.md new file mode 100644 index 0000000..cc5e7aa --- /dev/null +++ b/public/documentation/functions/11-local-bindings.md @@ -0,0 +1,70 @@ +# Local bindings and multi-step functions + +## What you will learn + +A function can name intermediate results without turning its calculation into mutable steps. + +## Making a calculation readable + +This works, but asks the reader to untangle everything at once: + +```fsharp +let checkoutTotal price quantity discount = + price * float quantity * (1.0 - discount) +``` + +Bindings indented inside the function are local to that call: + +```fsharp +let checkoutTotal price quantity discount = + let subtotal = price * float quantity + let discountAmount = subtotal * discount + subtotal - discountAmount +``` + +Each `let` names a value. Nothing is reassigned. The last expression is the result, so this function still has type: + +```text +float -> int -> float -> float +``` + +`subtotal` and `discountAmount` cannot be used outside the function. Their small scope is useful: those names explain the calculation exactly where they matter. + +## Branches can be local values too + +Because `if` is an expression, it fits naturally on the right side of a binding: + +```fsharp +let loanDays isChildrensBook = + let standardDays = 21 + let allowedDays = if isChildrensBook then 14 else standardDays + allowedDays +``` + +The final `allowedDays` could be replaced by the `if`, but the name may make the business rule clearer. + +## Scope follows indentation + +```fsharp +let calculateFine daysLate = + let dailyRate = 0.25 + float daysLate * dailyRate +``` + +`dailyRate` is available only inside `calculateFine`. A later function cannot accidentally depend on it. Narrow scope reduces the number of meanings a reader must keep in mind. + +Bindings are evaluated in order, so a local value can use an earlier local value but not one declared below it. This is a flow of dependencies, not a sequence of assignments. + +## Give names to ideas, not punctuation + +Names should explain something. `let convertedDays = float daysLate` may help when conversion matters; `let one = 1` usually adds ceremony. Prefer the version whose intermediate values reveal domain steps such as subtotal, discount, and final charge. + +## Try it + +- Add a local `tax` value to the total. +- Return `subtotal` temporarily and observe the result. +- Move a local binding outside and notice how its scope changes. + +## Summary + +Local bindings split a function into named expressions. They improve clarity while preserving immutability and a simple input-to-output shape. diff --git a/public/documentation/functions/12-function-values.fsx b/public/documentation/functions/12-function-values.fsx new file mode 100644 index 0000000..5c02995 --- /dev/null +++ b/public/documentation/functions/12-function-values.fsx @@ -0,0 +1,14 @@ +let regularPrice price = price +let memberPrice price = price * 0.9 + +let choosePolicy isMember = + if isMember then memberPrice else regularPrice + +let calculatePrice policy price = policy price + +let policy = choosePolicy true +let finalPrice = calculatePrice policy 20.0 + +printfn "Final price: %.2f" finalPrice + +// Try choosing the regular-price policy and predict the new result. diff --git a/public/documentation/functions/12-function-values.md b/public/documentation/functions/12-function-values.md new file mode 100644 index 0000000..76645a1 --- /dev/null +++ b/public/documentation/functions/12-function-values.md @@ -0,0 +1,105 @@ +# Functions are values + +## What you will learn + +Function names can be bound, selected, and passed around just like number or string values. + +## A name for behavior + +```fsharp +let regularPrice price = price +let memberPrice price = price * 0.9 + +let pricingPolicy = memberPrice +let finalPrice = pricingPolicy 20.0 +``` + +There is no argument after `memberPrice`, so `pricingPolicy` refers to the function itself, not to the result of calling it. + +Compare the types: + +```fsharp +let policy = memberPrice // float -> float +let price = memberPrice 20.0 // float +``` + +The presence of an argument changes the expression from a function value to the result of applying it. + +That distinction becomes especially useful when reading unfamiliar code. Ask +whether the function is named by itself or whether an argument follows it. In +`let chosen = memberPrice`, the right side has type `float -> float`. In +`let chosen = memberPrice 20.0`, it has type `float`. + +An `if` expression can choose behavior: + +```fsharp +let selectedPolicy = + if isMember then memberPrice else regularPrice +``` + +Both branches must have the same function type. An `if` cannot choose between incompatible behaviors. + +For example, this cannot compile: + +```fsharp +let selectedPolicy = + if isMember then memberPrice else "regular" +``` + +One branch produces a function and the other produces a string. The surrounding +binding needs one type regardless of which branch runs. + +## Passing behavior into a function + +```fsharp +let calculatePrice policy price = + policy price +``` + +The `policy` parameter is applied inside the function. Its signature is: + +```text +(float -> float) -> float -> float +``` + +The parentheses tell us that the first input is itself a function. Here are two behaviors with that type: + +```fsharp +let takeTenPercent price = price * 0.9 +let addGiftWrap price = price + 2.0 + +calculatePrice takeTenPercent 20.0 // 18.0 +calculatePrice addGiftWrap 20.0 // 22.0 +``` + +Calling functions “first-class” means we can bind them to names, pass them as arguments, select them with expressions, and return them from other functions. + +## Follow one call + +```fsharp +calculatePrice takeTenPercent 20.0 +``` + +`policy` is bound to the `takeTenPercent` function. `price` is bound to `20.0`. +The body evaluates `policy price`, which is the same calculation as +`takeTenPercent 20.0`. + +Nothing reflective happens. The function value has an ordinary statically +checked type, and the compiler verifies that its input and output fit the place +where it is used. + +Selecting a function is often clearer than selecting a string such as +`"member"` and interpreting that label elsewhere. Later, discriminated unions +will fit cases where the named choice itself must remain available as data. + +## Try it + +- Bind `chosen = memberPrice` and apply it. +- Choose a policy with a boolean and predict its type before calling it. +- Add a pricing policy that subtracts `5.0`. +- Try choosing between functions with different input types and read the error. +- Trace the parameter bindings inside one `calculatePrice` call. + +## Summary + +Omitting an argument refers to a function value. That lets programs select and pass behavior instead of hard-coding every rule. diff --git a/public/documentation/functions/13-lambdas.fsx b/public/documentation/functions/13-lambdas.fsx new file mode 100644 index 0000000..becb40e --- /dev/null +++ b/public/documentation/functions/13-lambdas.fsx @@ -0,0 +1,10 @@ +let applyPricingPolicy policy price = policy price + +let memberPrice = applyPricingPolicy (fun price -> price * 0.9) 20.0 +let clearancePrice = applyPricingPolicy (fun price -> price * 0.6) 20.0 + +printfn "Member price: %.2f" memberPrice +printfn "Clearance price: %.2f" clearancePrice + +// Predict both prices before running. Then rewrite one lambda as a named +// function. Finally remove its grouping parentheses, read the error, and repair it. diff --git a/public/documentation/functions/13-lambdas.md b/public/documentation/functions/13-lambdas.md new file mode 100644 index 0000000..3bec078 --- /dev/null +++ b/public/documentation/functions/13-lambdas.md @@ -0,0 +1,100 @@ +# Anonymous functions + +## What you will learn + +Use `fun` to write a small function directly where a function value is needed. + +## Behavior without another name + +Named functions remain the clearest default: + +```fsharp +let takeTenPercent price = price * 0.9 +``` + +F# can express the same value anonymously: + +```fsharp +let takeTenPercent = fun price -> price * 0.9 +``` + +Read `fun price -> ...` as “a function that receives `price` and produces …”. The arrow separates parameters from the body; it is not the type-signature arrow, though the ideas are related. + +Anonymous functions are useful when behavior is tiny and local: + +```fsharp +let applyPolicy policy price = policy price +let salePrice = applyPolicy (fun price -> price * 0.8) 25.0 +``` + +The same call with a named function is: + +```fsharp +let takeTwentyPercent price = price * 0.8 +let salePrice = applyPolicy takeTwentyPercent 25.0 +``` + +Both forms describe the same function. Choose based on whether a name would help the reader. + +Parentheses group the anonymous function so it becomes the first argument. Multiple parameters appear before the arrow: + +```fsharp +let total = (fun quantity price -> float quantity * price) 2 12.0 +``` + +The lambda is curried in exactly the same way as a named function. Its inferred +type is `int -> float -> float`: receive an `int`, then receive a `float`, then +produce a `float`. `fun (quantity, price) -> ...` would instead receive one +tuple, just as a tupled named function does. + +## Closures remember surrounding values + +```fsharp +let discount = 0.15 +let discounted = fun price -> price * (1.0 - discount) +``` + +The function *closes over* the immutable `discount` binding. The resulting value is called a closure. Capturing immutable values is predictable; capturing mutable state is possible later, but changes the reasoning model. + +## A common parsing mistake + +`applyPolicy fun price -> price * 0.8 25.0` is not grouped as intended. Parenthesize a lambda when it appears as one argument among others: + +```fsharp +applyPolicy (fun price -> price * 0.8) 25.0 +``` + +The parentheses do not call the lambda. They mark where that function value +ends so F# can pass it as one argument. The final `25.0` is then applied by +`applyPolicy`. + +## When a name earns its keep + +Compare these two pricing calls: + +```fsharp +applyPolicy (fun price -> price * 0.8) 25.0 +``` + +```fsharp +let clearancePrice price = + price * 0.8 + +applyPolicy clearancePrice 25.0 +``` + +The lambda keeps a one-use mechanical condition close to the call. The named +version gives a domain rule a reusable vocabulary. Anonymous does not mean +better or more functional; it means the function has no binding of its own. + +## Try it + +- Change the anonymous discount from twenty to thirty percent. +- Rewrite a named one-line function using `fun`. +- Rewrite it back and decide which reads better. +- Remove the parentheses around the lambda passed to `applyPolicy`. Read where + the compiler says the expression became incomplete, then restore them. + +## Summary + +`fun parameter -> expression` constructs a function value. Lambdas are most readable for short, local behavior. diff --git a/public/documentation/functions/14-higher-order.fsx b/public/documentation/functions/14-higher-order.fsx new file mode 100644 index 0000000..5646314 --- /dev/null +++ b/public/documentation/functions/14-higher-order.fsx @@ -0,0 +1,21 @@ +let applyTwice transform value = transform (transform value) + +let reduceFivePercent price = price * 0.95 + +let addPrefix text = "Bookshop: " + text + +let minimumLength minimum = + fun (text: string) -> text.Length >= minimum + +let atLeast minimum = fun amount -> amount >= minimum + +let freeStandardDelivery = atLeast 30.0 +let freeExpressDelivery = atLeast 60.0 + +printfn "Two reductions: %.2f" (applyTwice reduceFivePercent 20.0) +printfn "%s" (applyTwice addPrefix "Kindred") +printfn "Title is at least three characters: %b" (minimumLength 3 "Dune") +printfn "Free standard delivery at 35.00: %b" (freeStandardDelivery 35.0) +printfn "Free express delivery at 35.00: %b" (freeExpressDelivery 35.0) + +// Try applyTwice with a function whose input and output types differ. diff --git a/public/documentation/functions/14-higher-order.md b/public/documentation/functions/14-higher-order.md new file mode 100644 index 0000000..5c291a5 --- /dev/null +++ b/public/documentation/functions/14-higher-order.md @@ -0,0 +1,92 @@ +# Higher-order functions + +## What you will learn + +A function can receive or return another function, allowing reusable code to accept changing behavior. + +## The repetition hiding in plain sight + +Suppose a price receives the same five-percent reduction twice: + +```fsharp +let reduceFivePercent price = price * 0.95 + +let finalPrice = + reduceFivePercent (reduceFivePercent 20.0) +``` + +The repeated idea is “apply the same transformation twice.” We can separate that repetition from the particular transformation: + +```fsharp +let applyTwice transform value = + transform (transform value) + +let finalPrice = applyTwice reduceFivePercent 20.0 +``` + +Try another function with the same input and output type: + +```fsharp +let addPrefix text = "Bookshop: " + text +let label = applyTwice addPrefix "Kindred" +// "Bookshop: Bookshop: Kindred" +``` + +The result is not a useful label, but it demonstrates that `applyTwice` is about repeated transformation rather than prices. + +## Give the idea a name + +A function that receives or returns another function is called a *higher-order function*. It needs no special declaration syntax: `transform` is a parameter whose value happens to be callable. + +```text +applyTwice : ('a -> 'a) -> 'a -> 'a +``` + +Read it one piece at a time: + +1. `('a -> 'a)` is a function whose input and output types match. +2. The next input is a value of that same type `'a`. +3. The final result is also `'a`. + +The apostrophe introduces a generic type variable. It stands for one type the compiler need not choose in advance. Within one use, all occurrences of `'a` must agree. `float -> float` and `string -> string` fit; `string -> int` does not, because its output cannot be fed back into itself. + +## Functions can produce functions + +```fsharp +let minimumLength minimum = + fun (text: string) -> text.Length >= minimum + +let validShortTitle = minimumLength 3 +let validLongTitle = minimumLength 10 +``` + +`minimumLength 3` returns a `string -> bool` function that remembers `minimum`. That returned function is a closure over an immutable value. + +## A domain-shaped use + +```fsharp +let atLeast minimum amount = amount >= minimum + +let freeStandardDelivery = atLeast 30.0 +let freeExpressDelivery = atLeast 60.0 + +let evaluateRule rule basketTotal = + rule basketTotal +``` + +The threshold-specific predicates share the same evaluator. The caller supplies the rule instead of leaving it hidden inside `evaluateRule`. + +## Compiler clinic + +`applyTwice String.length "Kindred"` cannot compile: `String.length` produces an `int`, but a second call would require a `string`. The error exposes a real broken connection between output and input. + +## Try it + +- Define `double number = number * 2`, then evaluate `applyTwice double 3`. +- Define a transformation returning a different type and predict why it fails. +- Build `maximumLength maximum` as a returned predicate. +- Write `applyThreeTimes` without copying a particular pricing rule into it. + +## Summary + +Functions are values, so they can serve as inputs and outputs. Generic type variables describe the relationships that must remain true without fixing one concrete domain type. diff --git a/public/documentation/functions/15-partial-application.fsx b/public/documentation/functions/15-partial-application.fsx new file mode 100644 index 0000000..0b5f586 --- /dev/null +++ b/public/documentation/functions/15-partial-application.fsx @@ -0,0 +1,19 @@ +let applyDiscount rate price = price * (1.0 - rate) + +let isAtLeast minimum amount = amount >= minimum + +let addHandling amount total = total + amount + +let regularPrice = applyDiscount 0.0 +let memberPrice = applyDiscount 0.10 +let clearancePrice = applyDiscount 0.40 +let qualifiesForFreeDelivery = isAtLeast 30.0 +let addGiftWrap = addHandling 2.0 + +printfn "Regular price: %.2f" (regularPrice 20.0) +printfn "Member price: %.2f" (memberPrice 20.0) +printfn "Clearance price: %.2f" (clearancePrice 20.0) +printfn "35.00 qualifies for free delivery: %b" (qualifiesForFreeDelivery 35.0) +printfn "20.00 with gift wrap: %.2f" (addGiftWrap 20.0) + +// Try hovering each partially applied binding and predict its remaining input. diff --git a/public/documentation/functions/15-partial-application.md b/public/documentation/functions/15-partial-application.md new file mode 100644 index 0000000..279a2ac --- /dev/null +++ b/public/documentation/functions/15-partial-application.md @@ -0,0 +1,83 @@ +# Partial application and currying + +## What you will learn + +Curried functions accept inputs one at a time, so supplying only some inputs creates a useful specialized function. + +## One function, one input at a time + +Consider the familiar discount function: + +```fsharp +let applyDiscount rate price = + price * (1.0 - rate) +``` + +Its inferred type is `float -> float -> float`, which means `float -> (float -> float)` because arrows associate to the right. The function receives a rate and returns a function awaiting a price. + +When both arguments are written together, application associates to the left: + +```fsharp +applyDiscount 0.10 20.0 +// equivalent to +(applyDiscount 0.10) 20.0 +``` + +This one-input-at-a-time representation is called *currying*. It is how F# normally represents functions with several parameters. + +## Stop after the first application + +```fsharp +let memberPrice = applyDiscount 0.10 +// float -> float + +let finalPrice = memberPrice 20.0 +// 18.0 +``` + +Creating `memberPrice` is *partial application*. The resulting closure remembers `0.10`. + +```fsharp +let regularPrice = applyDiscount 0.0 +let memberPrice = applyDiscount 0.10 +let clearancePrice = applyDiscount 0.40 +``` + +Each binding has type `float -> float`, but remembers a different rate. + +## Argument order becomes design + +```fsharp +let isAtLeast minimum amount = amount >= minimum +let qualifiesForStandard = isAtLeast 30.0 +let qualifiesForExpress = isAtLeast 60.0 +``` + +Stable configuration often belongs first because it makes specialization convenient, while changing data often belongs last because it flows well through pipelines. If that order reads awkwardly in the domain, choose clarity instead. + +## Pause and predict + +```fsharp +let addHandling amount total = total + amount +let addGiftWrap = addHandling 2.0 +let result = addGiftWrap 20.0 +``` + +```text +addHandling : float -> float -> float +addGiftWrap : float -> float +result : float, with value 22.0 +``` + +Trying to print `addGiftWrap` with a numeric formatter fails because it is still a function, not a number. Supply its remaining argument first. + +## Try it + +- Create regular, member, and clearance pricing functions. +- Reverse the parameters of `applyDiscount` and inspect the partial application. +- Fully parenthesize a three-input function one application at a time. +- Find one function where configuration-first ordering helps and one where it harms readability. + +## Summary + +A curried function is a chain of one-input functions. Partial application follows part of that chain and keeps the remaining function for later. diff --git a/public/documentation/functions/16-pipelines.fsx b/public/documentation/functions/16-pipelines.fsx new file mode 100644 index 0000000..2593f03 --- /dev/null +++ b/public/documentation/functions/16-pipelines.fsx @@ -0,0 +1,18 @@ +let tidyTitle (title: string) = title.Trim() + +let addCatalogPrefix title = "Catalog: " + title + +let surround left right text = left + text + right + +let countCharacters (text: string) = text.Length + +let rawTitle = " A Wizard of Earthsea " + +let catalogLabel = rawTitle |> tidyTitle |> addCatalogPrefix |> surround "[" "]" + +let characterCount = catalogLabel |> countCharacters + +printfn "%s" catalogLabel +printfn "The final label has %d characters" characterCount + +// Rewrite catalogLabel with nested function application. diff --git a/public/documentation/functions/16-pipelines.md b/public/documentation/functions/16-pipelines.md new file mode 100644 index 0000000..061378d --- /dev/null +++ b/public/documentation/functions/16-pipelines.md @@ -0,0 +1,116 @@ +# The pipeline operator + +## What you will learn + +The forward-pipe operator lets a sequence of function calls read in the same direction as the data moves. + +## Nested calls become difficult to read + +Suppose a catalog title passes through three functions: + +```fsharp +let tidyTitle (title: string) = title.Trim() +let addCatalogPrefix title = "Catalog: " + title +let countCharacters (text: string) = text.Length +``` + +`Trim()` is another string method. It returns text without whitespace at either end; it does not alter the original string. + +Normal application is clearest for one step: + +```fsharp +let cleaned = tidyTitle " Kindred " +``` + +Nested application works for several steps, but its execution order is visually inside-out: + +```fsharp +let count = countCharacters (addCatalogPrefix (tidyTitle " Kindred ")) +// 16 +``` + +F# provides the forward pipeline operator for the same applications: + +```fsharp +let count = + " Kindred " + |> tidyTitle + |> addCatalogPrefix + |> countCharacters +``` + +Read it top to bottom: start with this value, pass it to this function, then pass the result onward. + +## `|>` is still function application + +The rule is small: + +```fsharp +value |> function +``` + +means: + +```fsharp +function value +``` + +The pipeline adds no concurrency, mutation, collection behavior, or hidden error handling. It only rearranges a function application so the input appears first. + +## Functions with earlier arguments + +The piped value becomes the final unapplied argument: + +```fsharp +let surround left right text = + left + text + right + +let label = + "Kindred" + |> surround "[" "]" +// "[Kindred]" +``` + +`surround "[" "]"` is partially applied first, producing `string -> string`; the pipeline supplies the title. This is why transformation-oriented APIs often place configuration before data. + +## Pipelines are a readability choice + +This is unnecessarily ceremonial: + +```fsharp +let cleaned = title |> tidyTitle +``` + +`let cleaned = tidyTitle title` may be more direct. Pipelines earn their space when they expose a meaningful sequence or avoid deeply nested calls. + +Named intermediate values are also valuable: + +```fsharp +let cleaned = tidyTitle rawTitle +let labelled = addCatalogPrefix cleaned +let count = countCharacters labelled +``` + +This version is longer but makes intermediate values inspectable. Choose between them based on what a reader needs to understand. + +## Debug a pipeline using types + +If one stage fails to type-check, write it as normal application: + +```fsharp +let cleaned = tidyTitle rawTitle +let labelled = addCatalogPrefix cleaned +``` + +Hover `cleaned`, then compare its type with the next function's input. A pipeline error usually means one stage produced a type the next stage cannot accept. + +## Experiment + +- Rewrite the nested catalog expression as a pipeline, then back again. +- Change the stage order and explain why the result changes. +- Partially apply a function so its remaining input fits the pipeline. +- Replace a long pipeline with named bindings and compare readability. + +## Summary + +A pipeline sends a value through a series of functions. It changes the reading direction, not what the calls mean. diff --git a/public/documentation/functions/17-composition.fsx b/public/documentation/functions/17-composition.fsx new file mode 100644 index 0000000..500f0e7 --- /dev/null +++ b/public/documentation/functions/17-composition.fsx @@ -0,0 +1,20 @@ +let tidyTitle (title: string) = title.Trim() + +let normalizeCase (title: string) = title.ToUpperInvariant() + +let addCatalogPrefix title = "Catalog: " + title + +let countCharacters (text: string) = text.Length + +let prepareCatalogTitle = tidyTitle >> normalizeCase >> addCatalogPrefix + +let prepareAndCount = prepareCatalogTitle >> countCharacters + +let first = prepareCatalogTitle " the dispossessed " +let second = prepareCatalogTitle " kindred " + +printfn "%s" first +printfn "%s" second +printfn "First label length: %d" (prepareAndCount " the dispossessed ") + +// Rewrite prepareCatalogTitle with << and verify the output is unchanged. diff --git a/public/documentation/functions/17-composition.md b/public/documentation/functions/17-composition.md new file mode 100644 index 0000000..af57339 --- /dev/null +++ b/public/documentation/functions/17-composition.md @@ -0,0 +1,111 @@ +# Function composition + +## What you will learn + +Composition connects compatible functions now to create a new function for values supplied later. + +## A pipeline needs a value now + +This pipeline performs work on one title: + +```fsharp +let label = + title + |> tidyTitle + |> addCatalogPrefix +``` + +Sometimes the title will arrive later, but we still want to prepare the transformation now. The forward composition operator `>>` connects the functions: + +```fsharp +let prepareCatalogTitle = + tidyTitle >> addCatalogPrefix + +let first = prepareCatalogTitle " Kindred " +let second = prepareCatalogTitle " Dune " +``` + +Composition produces a new function without running either input function yet. When the new function is called, it runs `tidyTitle` and feeds that result into `addCatalogPrefix`. + +## Types are the joints + +Suppose: + +```text +tidyTitle : string -> string +countCharacters : string -> int +``` + +Then: + +```fsharp +let tidyAndCount = tidyTitle >> countCharacters +// string -> int +``` + +The output type of the first function must be compatible with the input type of the second. Composition cannot connect `string -> int` to a function expecting `bool`. + +The general shape is: + +```text +('a -> 'b) -> ('b -> 'c) -> ('a -> 'c) +``` + +Start with an `'a`, let the first function produce `'b`, let the second consume that `'b` and produce `'c`, and the composed function connects `'a` directly to `'c`. + +## Backward composition + +`<<` writes the outer function first: + +```fsharp +let prepareCatalogTitle = + addCatalogPrefix << tidyTitle +``` + +It still runs `tidyTitle` first. Forward composition usually matches F#'s left-to-right pipeline style; backward composition can be helpful when reading a definition as conventional nested application. Prefer one direction consistently within a short expression. + +## Composition versus piping + +```fsharp +let output = input |> first |> second +``` + +passes a value now. + +```fsharp +let combined = first >> second +``` + +constructs behavior to call later. + +That distinction matters more than the operators' visual similarity. + +## Prefer clarity over point-free style + +This is concise: + +```fsharp +let prepare = tidyTitle >> normalizeCase >> addCatalogPrefix +``` + +It is readable only when each named step tells a clear story. When business decisions, branching, or several inputs become involved, explicit parameters and local bindings usually communicate more: + +```fsharp +let prepare title = + let cleaned = tidyTitle title + let normalized = normalizeCase cleaned + addCatalogPrefix normalized +``` + +F# expertise is not measured by how many parameters can be hidden. + +## Experiment + +- Compose `string -> string` with `string -> int` and predict the final signature. +- Attempt to compose incompatible functions and identify the mismatched boundary. +- Rewrite a composition as a pipeline and as nested application. +- Decide which of three versions best explains the catalog operation. + +## Summary + +Piping supplies a value; composition builds a function. In both cases, the output type of one step must fit the input type of the next. diff --git a/public/documentation/functions/currying-and-partial-application.fsx b/public/documentation/functions/currying-and-partial-application.fsx deleted file mode 100644 index 1a52c90..0000000 --- a/public/documentation/functions/currying-and-partial-application.fsx +++ /dev/null @@ -1,9 +0,0 @@ -let add x y = x + y - -let addFive = add 5 // partially applied -let fifteen = addFive 10 -printfn "%d" fifteen - -let criticalOperation (logger: string -> unit) (value: string) = logger $"{value}!!!" -let criticalOperationWithConsoleLogger = criticalOperation (printfn "%s") -criticalOperationWithConsoleLogger "Hello, World!" diff --git a/public/documentation/functions/currying-and-partial-application.md b/public/documentation/functions/currying-and-partial-application.md deleted file mode 100644 index 097220b..0000000 --- a/public/documentation/functions/currying-and-partial-application.md +++ /dev/null @@ -1,26 +0,0 @@ -# Currying and Partial Application - -All `let` bound functions in F# are automatically curried. This means that every function has a single input and a single output. - -Let's take a look at the `add` function introduced earlier to see what is going on. - -```fsharp -let add x y = x + y -``` - -This function has a signature of `int -> int -> int`. What does that mean? The arrows in a function signature indicate the inputs and outputs of a function: `input -> output`. The add function has a single `int` input and returns another function with a signature of `int -> int`. - -We can see this clearly by redefining the `add` function to explicitly return another function. - -```fsharp -let add x = fun y -> x + y -``` - -This new `add` function has a signature of `int -> int -> int`, just like the original. - -An advantage of curried functions is the ability to partially apply parameters. This is often used to compose two functions or build functions from existing ones. You can supply a single parameter to the `add` function to get back a new function with the `x` parameter filled in. The implicit parameter to this new function refers to the `y` parameter in the original function as it hasn't been filled in yet. - -```fsharp -let add x y = x + y -let addFive = add 5 -``` \ No newline at end of file diff --git a/public/documentation/functions/defining-functions.fsx b/public/documentation/functions/defining-functions.fsx deleted file mode 100644 index 3599863..0000000 --- a/public/documentation/functions/defining-functions.fsx +++ /dev/null @@ -1,3 +0,0 @@ -let add x y = x + y -let ten = add 5 5 -printfn "%d" ten diff --git a/public/documentation/functions/defining-functions.md b/public/documentation/functions/defining-functions.md deleted file mode 100644 index 40c1bc0..0000000 --- a/public/documentation/functions/defining-functions.md +++ /dev/null @@ -1,15 +0,0 @@ -# Defining Functions - -In F#, functions are defined using the `let` syntax followed by a list of parameters and an expression. - -```fsharp -let add x y = x + y -``` - -As you can see, no type annotations are required. The compiler will infer the type of the parameters and the return value from the function definitions. Here, the type of `x`, `y`, and the return value are all `int`. - -Sometimes, the compiler doesn't have enough information to infer the types of the parameters or the return type from the definition. In these cases, you can supply type annotations to any parameters and the return type if necessary. - -```fsharp -let add (x: float) (y: float) : float = x + y -``` \ No newline at end of file diff --git a/public/documentation/functions/functions-as-values.fsx b/public/documentation/functions/functions-as-values.fsx deleted file mode 100644 index 5b801a8..0000000 --- a/public/documentation/functions/functions-as-values.fsx +++ /dev/null @@ -1,8 +0,0 @@ -let double x = x * 2 -let apply (f: int -> int) = f 5 - -let ten = apply double -printfn "5 * 2 = %d" ten - -let twentyFive = apply (fun value -> value * 5) -printfn "5 * 5 = %d" twentyFive diff --git a/public/documentation/functions/functions-as-values.md b/public/documentation/functions/functions-as-values.md deleted file mode 100644 index 4a51e2f..0000000 --- a/public/documentation/functions/functions-as-values.md +++ /dev/null @@ -1,21 +0,0 @@ -# Functions as Values - -In F#, all functions are values and can be passed around as such. These are called _higher order functions_ or functions that accept other functions as parameters. - -Let's define a `double` function which has a single parameter. - -```fsharp -let double x = x * 2 -``` - -This function will have a signature of `int -> int`. If we wanted to define a function which takes the `double` function as a parameter, that parameter would have a type of `int -> int`. - -```fsharp -let apply (f: int -> int) = f 5 -``` - -You don't need to pass named functions to `apply`. Anonymous functions, sometimes called lambda functions, allow you to pass an inline function as a parameter. - -```fsharp -apply (fun value -> value * 5) -``` diff --git a/public/documentation/functions/pipelines-and-composition.fsx b/public/documentation/functions/pipelines-and-composition.fsx deleted file mode 100644 index 8c82f66..0000000 --- a/public/documentation/functions/pipelines-and-composition.fsx +++ /dev/null @@ -1,11 +0,0 @@ -let negative x = x * -1 -let double x = x * 2 - -3 |> negative |> double |> printfn "double(negative(3)) = %d" - -let add x y = x + y -let multiply x y = x * y -let operation = add 3 >> multiply 3 - -let result = operation 5 -printfn "multiply 3 (add 3 5) = %d" result diff --git a/public/documentation/functions/pipelines-and-composition.md b/public/documentation/functions/pipelines-and-composition.md deleted file mode 100644 index 982f6a1..0000000 --- a/public/documentation/functions/pipelines-and-composition.md +++ /dev/null @@ -1,44 +0,0 @@ -# Pipelines and Composition - -The pipeline operator `|>` is a very simple operator that allows you to pipe a value into a function. We can see this by defining our own version of it. - -```fsharp -let (|>) value f = f value -``` - -With the pipe operator, the function application of `f(g(x))` can be replaced with `x |> g |> f`. First, the value of `x` is applied to the function `g` and the result is applied to the function `f`. - -As the pipeline operator applies a value to a single parameter function (all F# functions), often times it's used in conjunction with partial application. - -```fsharp -let add x y = x + y - -// produces int -> int function (not what we want) -5 |> add - -// `add 3` is evaluated - producing an int -> int function -// 5 is piped into that function. -5 |> add 3 -``` - -You can also combine two or more functions into a single function with the composition operator: `>>`. Let's define our own version of it to see how it works. - -```fsharp -let (>>) function1 function2 = - fun value -> - function2(function1(value)) -``` - -As you can see, a value is passed into the left-hand function and the result is passed into the right-hand function as an input. The function definition of `let func x = f(g(x))` can be replaced with `let func = g >> f`. - -As you may notice, this operator is also often used in conjunction with partial application. This operator will create an `a -> c` function from the usage `(a -> b) >> (b -> c)` - -```fsharp -let add x y = x + y -let multiply x y = x * y -let operation = add 3 >> multiply 3 -// equivalent to: -// let operation x = multiply 3 (add 3 x) -``` - -The expression `add 3 >> multiply 3` works because the function signature of each function is `int -> int`. This will result in a function with an implicit parameter that will first be passed into `add 3` and the resulting `int` value will be passed into `multiply 3`. \ No newline at end of file diff --git a/public/documentation/functions/recursive-functions.fsx b/public/documentation/functions/recursive-functions.fsx deleted file mode 100644 index 5bc57ee..0000000 --- a/public/documentation/functions/recursive-functions.fsx +++ /dev/null @@ -1,4 +0,0 @@ -let rec fib n = - if n <= 1 then n else fib (n - 1) + fib (n - 2) - -printfn "%d" (fib 10) diff --git a/public/documentation/functions/recursive-functions.md b/public/documentation/functions/recursive-functions.md deleted file mode 100644 index 2dd8d9d..0000000 --- a/public/documentation/functions/recursive-functions.md +++ /dev/null @@ -1,20 +0,0 @@ -# Recursive Functions - -In F#, recursive functions must be explicitly marked with the `rec` keyword. - -```fsharp -let rec fib n = - if n <= 1 - then n - else fib (n - 1) + fib (n - 2) -// ^^^ ^^^ -// function is allowed to be recursive -// as the `rec` keyword is present. -``` - -Mutually recursive functions can be defined using the `and` keyword. - -```fsharp -let rec first x = if x > 0 then second (x - 1) else x -and second x = if x > 0 then first (x - 1) else x -``` \ No newline at end of file diff --git a/public/documentation/modeling-data/18-tuples.fsx b/public/documentation/modeling-data/18-tuples.fsx new file mode 100644 index 0000000..4bf7c96 --- /dev/null +++ b/public/documentation/modeling-data/18-tuples.fsx @@ -0,0 +1,10 @@ +let describeBook (title, author, pages) = + $"%s{title} by %s{author} has %d{pages} pages" + +let book = ("Kindred", "Octavia E. Butler", 264) +let (title, _, pages) = book + +printfn "%s" (describeBook book) +printfn "%s has %d pages" title pages + +// Add a genre as a fourth position, then decide whether a record would read better. diff --git a/public/documentation/modeling-data/18-tuples.md b/public/documentation/modeling-data/18-tuples.md new file mode 100644 index 0000000..996f8d9 --- /dev/null +++ b/public/documentation/modeling-data/18-tuples.md @@ -0,0 +1,97 @@ +# Tuples: values that travel together + +## What you will learn + +A tuple groups a fixed number of values without defining a new named type. + +```fsharp +let book = ("Kindred", 264) +``` + +The comma constructs the pair; its type is `string * int`. The `*` in a tuple type means “and,” not multiplication. A triple has three positions: + +```fsharp +let copy = ("Kindred", 264, true) +``` + +Use a tuple pattern to take a tuple apart: + +```fsharp +let (title, pages) = book +``` + +Patterns describe the shape of data. Each name is bound to the corresponding part. + +The whole tuple is one value. This matters when a function returns more than one result: + +```fsharp +let readingProgress pageCount pagesRead = + let remaining = pageCount - pagesRead + let completed = float pagesRead / float pageCount * 100.0 + (remaining, completed) + +let progress = readingProgress 264 66 +// int * float +``` + +Destructure at the point where names become useful: + +```fsharp +let (remaining, completed) = progress +``` + +The function returns one value: a pair that contains two values. + +## Curried and tupled functions + +These are different shapes: + +```fsharp +let add x y = x + y // int -> int -> int +let addPair (x, y) = x + y // int * int -> int +``` + +Call them with `add 2 3` and `addPair (2, 3)`. The curried form supports partial application; the tupled form receives one paired value. + +Tuples are handy for temporary, obvious groupings and for returning two results. Once positions become hard to remember, a record gives the data names. + +Compare these values: + +```fsharp +let book = ("Kindred", 264) +let customer = ("Ada", 5) +``` + +Both have type `string * int`, even though their meanings differ. A function expecting one can accidentally receive the other. Tuples preserve types and positions, not domain names. That limitation motivates records. + +## Ignoring one position + +The wildcard pattern `_` recognizes a value without binding a name: + +```fsharp +let (title, _) = book +``` + +Use it when the position is genuinely irrelevant. Naming a value and then never using it makes readers wonder whether something was forgotten. + +## Follow the types + +If `book` is `string * int`, then this function has type `string * int -> string`: + +```fsharp +let progressLabel (title, pagesRead) = + $"%s{title}: %d{pagesRead} pages read" +``` + +The parentheses belong to the tuple pattern, not to function-call syntax. `progressLabel ("Kindred", 40)` constructs a tuple and passes that single value. Writing `progressLabel "Kindred" 40` instead supplies two curried arguments to a function that expects one pair. That is what the compiler means when it says it expected a tuple. + +## Try it + +- Destructure a triple. +- Swap the fields and observe the changed type. +- Convert a curried function into a tupled one. +- Return two calculated values from one function and destructure them immediately. + +## Summary + +Tuples group values by position. Tuple patterns deconstruct them, and a tupled parameter is not the same as several curried parameters. diff --git a/public/documentation/modeling-data/19-records.fsx b/public/documentation/modeling-data/19-records.fsx new file mode 100644 index 0000000..e9638f8 --- /dev/null +++ b/public/documentation/modeling-data/19-records.fsx @@ -0,0 +1,18 @@ +type Book = { + Title: string + Author: string + PageCount: int +} + +let book = { + Title = "Kindred" + Author = "Octavia E. Butler" + PageCount = 264 +} + +let corrected = { book with PageCount = 266 } + +printfn "%s by %s" corrected.Title corrected.Author +printfn "Original pages: %d; corrected: %d" book.PageCount corrected.PageCount + +// Add a Year field and let the compiler identify every construction to update. diff --git a/public/documentation/modeling-data/19-records.md b/public/documentation/modeling-data/19-records.md new file mode 100644 index 0000000..fec2c8e --- /dev/null +++ b/public/documentation/modeling-data/19-records.md @@ -0,0 +1,75 @@ +# Records: data with names + +## What you will learn + +Records define related data with names for every field. + +```fsharp +type Book = + { + Title: string + Author: string + PageCount: int + } +``` + +`type` introduces the type. Construct a value with matching field names: + +```fsharp +let book = + { + Title = "Kindred" + Author = "Octavia E. Butler" + PageCount = 264 + } +``` + +Access fields with a dot: `book.Title`. The dot means “the named member or field on this value,” as it did for string members. + +Unlike the earlier tuple `("Kindred", 264)`, this value carries field names in its type. Construction is all-or-nothing: the compiler rejects a missing field, an unknown field, or a field with the wrong type. That catches incomplete data at its boundary rather than when some later calculation happens to use it. + +Record fields are immutable by default. Copy-and-update creates a new value: + +```fsharp +let revised = { book with PageCount = 266 } +``` + +Plain functions make record transformations explicit: + +```fsharp +let addPages additionalPages book = + { book with PageCount = book.PageCount + additionalPages } + +let corrected = book |> addPages 2 +``` + +`book` is unchanged. The compiler normally infers a function parameter from distinctive fields; an annotation resolves ambiguity: + +```fsharp +let describe (book: Book) = book.Title +``` + +By default, records support structural equality when all their field types support equality: two separate `Book` values with equal fields compare equal. F# attributes can explicitly disable generated equality for a record type, a detail you may encounter in wider code. + +## Why the type definition matters + +The compiler checks construction against the complete record shape. Omitting `PageCount`, misspelling `Author`, or using a string where an integer is expected produces an error at the value's boundary rather than much later. Field names also remove the positional uncertainty of `("Kindred", "Butler", 264)`. + +Copy-and-update is shallow: updating `PageCount` copies every other field value unchanged. If a field refers to another object, the old and new records still refer to that same object. To change a nested record, update the nested value explicitly and place it in the outer copy. + +## Formatting and compiler feedback + +Prefer one field per line once a record grows. Semicolons are permitted in compact values, but vertical formatting makes additions and comparisons easier to scan. + +If two record types share field names, inference may need an annotation such as `(book: Book)`. Annotate the parameter whose intended record type is ambiguous rather than annotating every local binding. + +## Try it + +- Add a `Year: int` field and fix construction. +- Create an updated title. +- Compare two equal book values. +- Omit one field and read which complete shape the compiler expected. + +## Summary + +Records replace positional data with explicit field names. With the default immutable fields, copy-and-update creates a revised value without changing the original. diff --git a/public/documentation/modeling-data/20-record-modeling.fsx b/public/documentation/modeling-data/20-record-modeling.fsx new file mode 100644 index 0000000..4a792a6 --- /dev/null +++ b/public/documentation/modeling-data/20-record-modeling.fsx @@ -0,0 +1,34 @@ +type Author = { Name: string; Country: string } + +type Book = { + Title: string + Author: Author + PageCount: int +} + +type InventoryItem = { Book: Book; QuantityOnHand: int } + +let sellOne item = { + item with + QuantityOnHand = item.QuantityOnHand - 1 +} + +let author = { + Name = "Ursula K. Le Guin" + Country = "United States" +} + +let book = { + Title = "A Wizard of Earthsea" + Author = author + PageCount = 205 +} + +let stocked = { Book = book; QuantityOnHand = 3 } + +let afterSale = stocked |> sellOne + +printfn "%s by %s" afterSale.Book.Title afterSale.Book.Author.Name +printfn "Before: %d; after: %d" stocked.QuantityOnHand afterSale.QuantityOnHand + +// Try adding a second InventoryItem that shares the same Book value. diff --git a/public/documentation/modeling-data/20-record-modeling.md b/public/documentation/modeling-data/20-record-modeling.md new file mode 100644 index 0000000..8cbf9db --- /dev/null +++ b/public/documentation/modeling-data/20-record-modeling.md @@ -0,0 +1,65 @@ +# Modeling relationships with records + +## What you will learn + +Several focused record types communicate more than one oversized bundle of primitive fields. + +```fsharp +type Author = { Name: string; Country: string } +type Book = { Title: string; Author: Author; PageCount: int } +``` + +The `Author` field contains another record. Access nested fields with `book.Author.Name`. + +The relationship is part of the type: `Book.Author` must be an `Author`, not an arbitrary string. If the shop later needs an author's country, the model already says where that fact belongs. + +Functions can preserve the model's immutability: + +```fsharp +let retitle newTitle book = + { book with Title = newTitle } +``` + +Putting the record last permits `book |> retitle "New title"`. A record is data, not a class that must own every operation. Plain functions make transformations explicit. + +Use records when field names matter and several values belong together. There is no need to wrap every string immediately; add precision when it solves a real modeling problem. + +## Domain step + +A catalog book and an inventory item describe related but distinct facts: + +```fsharp +type InventoryItem = + { + Book: Book + QuantityOnHand: int + } +``` + +`Book` holds descriptive information. `InventoryItem` holds the stock fact that changes as the shop receives and sells copies. One book value can safely appear in several immutable calculations. + +```fsharp +let stocked = + { Book = book; QuantityOnHand = 3 } + +let afterSale = + { stocked with QuantityOnHand = stocked.QuantityOnHand - 1 } +``` + +The update creates a new value. `stocked.QuantityOnHand` remains `3`; `afterSale.QuantityOnHand` is `2`. + +## Ask what one value represents + +`QuantityOnHand` does not belong on `Book`. A title and its current stock have different lifecycles: changing stock should not mean rewriting bibliographic information. The useful question is “what does this value represent?” + +Likewise, one giant record containing book metadata, customer contact, stock, and order facts would make every function depend on unrelated fields. Split records when the domain describes distinct things. Keep simple data simple; introduce a separate type when it clarifies a real boundary. + +## Try it + +- Add a nested publisher record. +- Write `sellOne` with copy-and-update. +- Confirm the original inventory item retains its quantity. + +## Summary + +Nested records describe relationships without merging distinct concepts. Functions over records keep data and transformations clear while the model evolves. diff --git a/public/documentation/modeling-data/21-unions.fsx b/public/documentation/modeling-data/21-unions.fsx new file mode 100644 index 0000000..3e82352 --- /dev/null +++ b/public/documentation/modeling-data/21-unions.fsx @@ -0,0 +1,20 @@ +type BookFormat = + | Hardcover + | Paperback + | Ebook + | Audiobook of durationMinutes: int + +type PaymentMethod = + | Cash + | Card of lastFourDigits: string + | GiftCard of code: string + +let format = Paperback +let audioFormat = Audiobook 615 +let payment = Card "4242" + +printfn "Format: %A" format +printfn "Audio format: %A" audioFormat +printfn "Payment: %A" payment + +// Try constructing GiftCard, then give Audiobook a different duration. diff --git a/public/documentation/modeling-data/21-unions.md b/public/documentation/modeling-data/21-unions.md new file mode 100644 index 0000000..20ded96 --- /dev/null +++ b/public/documentation/modeling-data/21-unions.md @@ -0,0 +1,98 @@ +# Discriminated unions: naming the possibilities + +## What you will learn + +A discriminated union defines the exact alternatives a value may have. + +## Strings do not define a vocabulary + +An early model might store a format as text: + +```fsharp +let format = "paperbak" +``` + +The misspelling is still a valid string. Every function must remember the same +spellings, and unrelated strings can be passed wherever a format is expected. A +union gives this small vocabulary its own type: + +```fsharp +type BookFormat = + | Hardcover + | Paperback + | Ebook +``` + +Each case is a value of type `BookFormat`. Cases are not strings or numeric enum labels; the compiler knows they belong to this type. + +Construction now rejects `"paperbak"`, and a function accepting `BookFormat` +cannot accidentally receive a customer name. The next lesson uses pattern +matching to consume every case. + +Cases can carry different data: + +```fsharp +type PaymentMethod = + | Cash + | Card of lastFourDigits: string + | GiftCard of code: string +``` + +Construct a payload case with normal function application: `Card "4242"`. The label `lastFourDigits` documents the payload; it does not create a field accessed with a dot. + +A union replaces loosely coordinated strings and fields. A gift-card code matters only for `GiftCard`, so that case carries the code itself. + +The next lesson shows how to inspect cases. Even before that, construction prevents misspelled case names and mismatched payloads. + +## Cases are constructors + +`Cash` needs no data, so it is already a complete `PaymentMethod` value. `Card` needs text, so `Card` by itself behaves like a function from `string` to `PaymentMethod`; `Card "4242"` is the completed value. This connects unions to the function model you already know. + +Compare their inferred shapes: + +```text +Cash : PaymentMethod +Card : string -> PaymentMethod +Card "4242" : PaymentMethod +``` + +Trying to store `Card` where a completed payment method is required produces a +function-type mismatch because its payload is still missing. + +Case names conventionally begin with capitals. Type context usually identifies their union; qualification such as `BookFormat.Hardcover` can make intent explicit. + +## Payloads can have several parts + +```fsharp +type DeliveryMethod = + | Collection + | Posted of street: string * postalCode: string +``` + +`Posted ("12 Elm Road", "AB12 3CD")` constructs one case carrying a tuple payload. When several positions become hard to distinguish, use a record as the payload. Unions answer “which possibility?”; records name facts that coexist. + +```fsharp +type PostalDetails = + { Street: string; PostalCode: string } + +type DeliveryMethod = + | Collection + | Posted of PostalDetails +``` + +The record payload makes construction slightly longer but gives both facts +names. Choose the representation that makes accidental exchanges least likely. + +The playground uses `%A` to inspect these new values. `%A` asks F# for a general structural representation. It is useful diagnostic output while learning, but it is not carefully designed customer-facing text. + +## Try it + +- Add an `Audiobook of durationMinutes: int` format. +- Construct every payment case. +- Try giving `Card` an integer and read the type error. +- Bind `Card` without its payload and inspect the inferred function type. +- Replace the two-part postal payload with a record. + +## Summary + +Discriminated unions model alternatives. Payload cases attach exactly the information relevant to one alternative. diff --git a/public/documentation/modeling-data/22-pattern-matching.fsx b/public/documentation/modeling-data/22-pattern-matching.fsx new file mode 100644 index 0000000..68866c2 --- /dev/null +++ b/public/documentation/modeling-data/22-pattern-matching.fsx @@ -0,0 +1,26 @@ +type BookFormat = + | Hardcover + | Paperback + | Ebook + | Audiobook of durationMinutes: int + +let describe format = + match format with + | Hardcover -> "Hardcover" + | Paperback -> "Paperback" + | Ebook -> "Ebook" + | Audiobook minutes when minutes > 600 -> $"Long audiobook: %d{minutes} minutes" + | Audiobook minutes -> $"Audiobook: %d{minutes} minutes" + +let shortLabel = + function + | Hardcover -> "hardback" + | Paperback -> "paperback" + | Ebook -> "digital" + | Audiobook _ -> "audio" + +printfn "%s" (describe Paperback) +printfn "%s" (describe (Audiobook 615)) +printfn "%s" (shortLabel Ebook) + +// Try adding a LargePrint case and let the warnings guide both functions. diff --git a/public/documentation/modeling-data/22-pattern-matching.md b/public/documentation/modeling-data/22-pattern-matching.md new file mode 100644 index 0000000..f7c0362 --- /dev/null +++ b/public/documentation/modeling-data/22-pattern-matching.md @@ -0,0 +1,77 @@ +# Pattern matching: reasoning about shapes + +## What you will learn + +`match` chooses an expression by deconstructing a value according to its shape. + +```fsharp +let describe format = + match format with + | Hardcover -> "hardcover" + | Paperback -> "paperback" + | Ebook -> "ebook" + | Audiobook minutes -> $"audiobook, %d{minutes} minutes" +``` + +Each line begins with a pattern. `Audiobook minutes` both recognizes the case and binds its payload. The expression after `->` becomes the match result. + +For `describe (Audiobook 615)`, the first three patterns fail and the fourth succeeds. Within that branch, `minutes` is bound to `615`. Matching reads and unpacks the value; it never changes it. + +Matches are expressions, so every branch must produce a compatible type. The compiler warns when a case is missing—valuable feedback when a domain grows. + +## Constants, wildcards, and guards + +Patterns can also recognize constants: + +```fsharp +let stockLabel quantity = + match quantity with + | 0 -> "sold out" + | 1 -> "last copy" + | _ -> "in stock" +``` + +The wildcard `_` accepts any remaining value without naming it. A guard adds a condition: + +```fsharp +let stockLabel quantity = + match quantity with + | quantity when quantity < 0 -> "invalid stock" + | 0 -> "sold out" + | 1 -> "last copy" + | _ -> "in stock" +``` + +Guards run only after their pattern matches. Order matters because the first matching branch wins. Keep a wildcard last because it matches anything. + +## Patterns are not assignments + +In `Audiobook minutes`, the name receives data because the existing value has the `Audiobook` shape. Constant patterns such as `0` test equality; case patterns test and unpack a union; tuple patterns unpack positions. + +Prefer listing meaningful union cases over a wildcard. Add a new format and the compiler can then point to every decision that needs reconsideration. + +## `function` matches its argument immediately + +When a function immediately matches its only input, F# offers a shorthand: + +```fsharp +let formatLabel = + function + | Hardcover -> "hardcover" + | Paperback -> "paperback" + | Ebook -> "digital" + | Audiobook _ -> "audio" +``` + +This means the same as `let formatLabel format = match format with ...`. Use it when the matched input is obvious and the shorter form remains readable. + +## Try it + +- Delete a format branch and inspect the warning. +- Add a guarded description for audiobooks longer than 600 minutes. +- Match a tuple using `(title, pages)`. +- Rewrite a one-argument match with `function`, then rewrite it back. + +## Summary + +Pattern matching recognizes and deconstructs data. Exhaustiveness checking turns domain changes into useful compiler guidance. diff --git a/public/documentation/modeling-data/23-records-and-unions.fsx b/public/documentation/modeling-data/23-records-and-unions.fsx new file mode 100644 index 0000000..12c5fd2 --- /dev/null +++ b/public/documentation/modeling-data/23-records-and-unions.fsx @@ -0,0 +1,31 @@ +type ListingState = + | ForSale + | SoldOut + | Discontinued of reason: string + +type BookListing = { + Title: string + Price: float + State: ListingState +} + +let markSoldOut listing = { listing with State = SoldOut } + +let describe listing = + match listing.State with + | ForSale -> $"%s{listing.Title} costs %f{listing.Price}" + | SoldOut -> $"%s{listing.Title} is sold out" + | Discontinued reason -> $"%s{listing.Title} was discontinued: %s{reason}" + +let listing = { + Title = "Kindred" + Price = 9.99 + State = ForSale +} + +let soldOut = listing |> markSoldOut +printfn "%s" (describe listing) +printfn "%s" (describe soldOut) + +// Predict both descriptions. Add ComingSoon of releaseDay: int, run once to see +// the incomplete-match warning, then update describe and construct the new case. diff --git a/public/documentation/modeling-data/23-records-and-unions.md b/public/documentation/modeling-data/23-records-and-unions.md new file mode 100644 index 0000000..711ec32 --- /dev/null +++ b/public/documentation/modeling-data/23-records-and-unions.md @@ -0,0 +1,97 @@ +# Records and unions together + +## What you will learn + +Records describe information that exists together; unions describe which alternative exists. + +```fsharp +type ListingState = + | ForSale + | SoldOut + | Discontinued of reason: string + +type BookListing = + { Title: string; Price: float; State: ListingState } +``` + +The record always has a title, price, and state. Its `State` is exactly one possibility. Update the outer record while constructing a new union value: + +```fsharp +let markSoldOut listing = + { listing with State = SoldOut } +``` + +This first version is still permissive: it can mark an already discontinued listing as sold out. The type removes contradictory *states*; later, functions returning `Result` will enforce valid *transitions*. + +Pattern matching expresses queries: + +```fsharp +let mayOrder listing = + match listing.State with + | ForSale -> true + | SoldOut -> false + | Discontinued _ -> false +``` + +The underscore shows that this function does not need the discontinuation reason. That reads more clearly than coordinating several boolean flags and unrelated string fields. + +Choose records for “and”: a listing has a title *and* price *and* state. Choose unions for “or”: its state is for sale *or* sold out *or* discontinued. + +## Trace an update + +Given a for-sale listing, `markSoldOut listing` constructs `SoldOut` and places it in a new record. The original listing remains for sale. + +That matters when several functions share the old value: each sees the same immutable facts. The caller deliberately chooses whether to keep the returned successor. + +## Put facts where they are meaningful + +`Title` and `Price` remain record fields because every listing has them. A discontinuation reason belongs inside `Discontinued` because it is meaningful only in that state. + +Use this practical rule while modeling: + +- facts that always coexist belong in a record; +- facts relevant to one alternative belong in that union case; +- operations that transform values belong in functions. + +## Read the types from the outside inward + +`BookListing` is one record type. Its `State` field contains one +`ListingState` value. A value such as this therefore has two construction +layers: + +```fsharp +let unavailable = + { + Title = "Kindred" + Price = 9.99 + State = Discontinued "publisher request" + } +``` + +`Discontinued "publisher request"` constructs the inner union value; the +braces construct the outer record. Pattern matching reverses that process: the +match first reads the `State` field and then deconstructs the selected case. + +A separate `Reason: string` field would be present for every state. That would +force meaningless values such as an empty reason on a for-sale listing. Putting +the reason in `Discontinued` makes it exist only in the alternative that needs +it. + +## Structure and transition rules are different guarantees + +The union prevents simultaneous states. It does not decide whether a move from +one state to another is allowed. `markSoldOut` currently accepts every listing, +including a discontinued one. Lesson 27 introduces a type for returning either +the successor or a reason for refusal. + +## Try it + +- Add a `ComingSoon of releaseDay: int` case and follow the warnings. +- Write `discontinue reason listing`. +- Confirm updates do not alter the original. +- Temporarily add `Reason: string` to the record. List the meaningless + combinations it permits, then remove the field again. + +## Summary + +Records and unions complement each other. Together they give domain values stable structure and explicit alternatives. diff --git a/public/documentation/modeling-data/24-valid-states.fsx b/public/documentation/modeling-data/24-valid-states.fsx new file mode 100644 index 0000000..e52d23b --- /dev/null +++ b/public/documentation/modeling-data/24-valid-states.fsx @@ -0,0 +1,24 @@ +type CustomerStatus = + | Active + | Suspended of reason: string + | Closed + +type Customer = { Name: string; Status: CustomerStatus } + +let mayOrder customer = + match customer.Status with + | Active -> true + | Suspended _ -> false + | Closed -> false + +let ada = { Name = "Ada"; Status = Active } + +let ben = { + Name = "Ben" + Status = Suspended "payment review" +} + +printfn "%s may order: %b" ada.Name (mayOrder ada) +printfn "%s may order: %b" ben.Name (mayOrder ben) + +// Try adding a Guest case and decide whether that customer may order. diff --git a/public/documentation/modeling-data/24-valid-states.md b/public/documentation/modeling-data/24-valid-states.md new file mode 100644 index 0000000..5f80060 --- /dev/null +++ b/public/documentation/modeling-data/24-valid-states.md @@ -0,0 +1,88 @@ +# Making invalid states hard to represent + +## What you will learn + +Types can encode business rules so contradictory values cannot be constructed casually. + +Consider flags: + +```fsharp +type CustomerFlags = { IsActive: bool; IsSuspended: bool } +``` + +What does `{ IsActive = true; IsSuspended = true }` mean? Replace the contradictory combination with explicit states: + +```fsharp +type CustomerStatus = + | Active + | Suspended of reason: string + | Closed +``` + +Now a suspended customer must carry a reason, while an active customer cannot carry an irrelevant one. + +The same improvement applies to an order: + +```fsharp +type OrderState = + | AwaitingPayment + | Paid of paidOnDay: int + | Cancelled of reason: string +``` + +An order has exactly one lifecycle state, so it cannot be both paid and cancelled. + +Two independent booleans create four combinations whether the business recognizes them or not. More flags multiply that accidental state space. A union lists the intended alternatives directly, so adding a new possibility is an explicit domain change. + +Three independent flags create eight boolean combinations. If the domain has +only four meaningful states, half of that representable space consists of +contradictions or undefined combinations. The problem grows faster than the +number of flags. + +A union does not merely improve naming. It changes which values can be +constructed: callers choose one case rather than coordinating several fields. + +The type cannot enforce every rule: a day may still be negative and a reason may still be blank. Validated constructors will tighten those primitive boundaries later. Good modeling develops in steps—first remove contradictory structures, then validate the remaining values that matter. + +## Preserve the facts later questions need + +Suppose payment reports need both the method and day. Put both facts in the relevant case: + +```fsharp +type PaymentDetails = + { + Method: string + PaidOnDay: int + } + +type OrderState = + | AwaitingPayment + | Paid of PaymentDetails + | Cancelled of reason: string +``` + +The best model is not the one with the fewest fields. It is the one in which representable values are coherent and necessary questions are straightforward to answer. + +## State shape and transition rules are different + +The union determines which individual state values are coherent. It does not by +itself determine which changes are permitted. A later transition function might +allow `AwaitingPayment` to become `Cancelled`, while refusing to cancel a paid +order until a refund workflow exists. + +This distinction prevents a common overclaim. A precise union can make +contradictory values unrepresentable, but business rules involving history or a +change from one valid value to another still belong in functions. The Result +lessons will give those transition functions an explicit error vocabulary. + +## Try it + +- Try representing “active and suspended” with `CustomerStatus`. +- Add `Shipped of trackingCode: string` to `OrderState`. +- Compare the information required by each case. +- List the boolean combinations needed to represent the same order states. +- Identify one rule encoded by the state shape and one requiring a transition function. + +## Summary + +Precise unions move assumptions from comments into checked data shapes. Good models make valid values natural and invalid combinations difficult. diff --git a/public/documentation/modeling-data/25-options.fsx b/public/documentation/modeling-data/25-options.fsx new file mode 100644 index 0000000..1604796 --- /dev/null +++ b/public/documentation/modeling-data/25-options.fsx @@ -0,0 +1,21 @@ +type Book = { + Title: string + Subtitle: string option +} + +let fullTitle book = + match book.Subtitle with + | Some subtitle -> $"%s{book.Title}: %s{subtitle}" + | None -> book.Title + +let first = { Title = "Kindred"; Subtitle = None } + +let second = { + Title = "Earthsea" + Subtitle = Some "The First Three Books" +} + +printfn "%s" (fullTitle first) +printfn "%s" (fullTitle second) + +// Try giving the first book a subtitle, then predict the full title. diff --git a/public/documentation/modeling-data/25-options.md b/public/documentation/modeling-data/25-options.md new file mode 100644 index 0000000..2b813aa --- /dev/null +++ b/public/documentation/modeling-data/25-options.md @@ -0,0 +1,61 @@ +# Optional values + +## What you will learn + +`Option` represents a value that may deliberately be absent. + +A catalog search may find a book or find nothing. Returning an empty title would blur a real title with absence. F# provides two cases: + +```fsharp +Some "Kindred" +None +``` + +Their type is `string option`, also written `Option`. Match both possibilities before using the inner value: + +```fsharp +let describe result = + match result with + | Some title -> $"Found %s{title}" + | None -> "No matching book" +``` + +`Some` is not a decorative wrapper: a `string option` is a different type from `string`. That distinction records possible absence in the function's type. + +`Option` is a generic union. Its type argument says what a present value contains: `int option`, `Book option`, and `string option` share the same present-or-absent structure but are different types. + +```fsharp +let subtitle = None +``` + +This alone can be too ambiguous, so annotate when no `Some` value supplies evidence: + +```fsharp +let subtitle: string option = None +``` + +Option differs from `null`: it is a union with two cases that pattern matching can cover exhaustively. Null still appears at some interop boundaries, and `Some null` is technically possible for a nullable reference type, so Option alone does not validate its inner value. Domain code is clearest when it uses `None` for absence and validates nullable input at the boundary. + +## Absence can mean different things + +An optional subtitle means a book legitimately may not have one. A catalog lookup returning `None` means no matching book was found. Both need only presence or absence, so both fit `Option`. + +If a caller must distinguish malformed input from a missing catalog entry, `None` loses too much information. The next Result lessons attach an explicit reason. + +## Follow the wrapper + +Suppose `findBook` returns `Book option`. A successful match binds a plain `Book` inside the `Some` branch; the `None` branch has no book to bind. Code after the match receives only the match's common output type. + +A frequent beginner error is accessing `result.Title`. The compiler complains because `result` is an option, not a book. This is helpful pressure: first decide what “not found” means for the current operation, then access the value only where its presence is established. + +An empty string is not a substitute for `None`. It is still a present string, and its type cannot communicate whether it means “missing,” “invalid,” or genuinely empty. + +## Try it + +- Change `Some` to `None`. +- Add an optional middle name to a record. +- Try using an option as a plain string and read the mismatch. + +## Summary + +Options make absence explicit with `Some value` or `None`. Pattern matching handles both paths safely. diff --git a/public/documentation/modeling-data/26-option-functions.fsx b/public/documentation/modeling-data/26-option-functions.fsx new file mode 100644 index 0000000..a927b0f --- /dev/null +++ b/public/documentation/modeling-data/26-option-functions.fsx @@ -0,0 +1,16 @@ +let nonBlank (text: string) = + let cleaned = text.Trim() + if cleaned = "" then None else Some cleaned + +let displaySubtitle subtitle = + subtitle + |> Option.bind nonBlank + |> Option.map (fun text -> "Subtitle: " + text) + |> Option.defaultValue "No subtitle" + +printfn "%s" (displaySubtitle (Some " A Novel ")) +printfn "%s" (displaySubtitle (Some " ")) +printfn "%s" (displaySubtitle None) + +// Predict all three lines. Replace bind with map and inspect the nested option, +// then restore bind and change only the display default. diff --git a/public/documentation/modeling-data/26-option-functions.md b/public/documentation/modeling-data/26-option-functions.md new file mode 100644 index 0000000..0b35659 --- /dev/null +++ b/public/documentation/modeling-data/26-option-functions.md @@ -0,0 +1,101 @@ +# Working with options + +## What you will learn + +Option helpers transform present values while preserving absence. + +Pattern matching remains the foundation. Once that shape is familiar, `Option.map` removes repetition: + +```fsharp +let length = subtitle |> Option.map (fun text -> text.Length) +``` + +For `Some text`, the function runs and the result becomes `Some length`. For `None`, the result stays `None`. Its useful shape is: + +```text +('a -> 'b) -> 'a option -> 'b option +``` + +The helper is equivalent to this match: + +```fsharp +let mapOption transform optionalValue = + match optionalValue with + | Some value -> Some (transform value) + | None -> None +``` + +Writing the match once removes mystery: `Option.map` packages a recurring two-branch transformation. + +`Option.defaultValue` unwraps with a fallback: + +```fsharp +let display = subtitle |> Option.defaultValue "No subtitle" +``` + +Use a default only when it tells the truth. `"No subtitle"` is a useful display value; a fabricated customer would hide the fact that lookup failed. + +`Option.bind` is for a function that already returns an option. It avoids `Some (Some value)`: + +```fsharp +let nonBlank text = + if text = "" then None else Some text + +let cleaned = subtitle |> Option.bind nonBlank +``` + +Choose a match when branches tell a domain story; choose helpers for a small standard transformation. + +## Trace map and bind + +For `Some "Earthsea" |> Option.map String.length`, map runs the function and returns `Some 8`. For `None`, it returns `None` without calling the function. Map never removes the optional layer. + +If `nonBlank` returns `string option`, mapping it over another `string option` produces `string option option`: two independent layers of absence. `bind` flattens them into one. If the right helper is unclear, write the pattern match first; the branch types will show whether the next step returns a plain value or another option. + +```fsharp +let bindOption next optionalValue = + match optionalValue with + | Some value -> next value + | None -> None +``` + +`next` already returns the next option, so bind does not wrap it in another `Some`. + +## Follow the types, not the helper names + +Suppose `subtitle` is `string option` and `nonBlank` is +`string -> string option`: + +```text +Option.map nonBlank subtitle : string option option +Option.bind nonBlank subtitle : string option +``` + +With `map`, the outer option describes whether a subtitle existed and the inner +one describes whether its cleaned string was nonblank. With `bind`, either reason +for absence becomes the same `None`. Use `bind` when that flattening matches the +meaning of the workflow. + +`Option.defaultValue` ends optional processing by choosing an ordinary value. +Once you default to a string, later code cannot distinguish a missing subtitle +from a real subtitle containing the same text. Put defaults near display or +other boundaries where losing that distinction is intentional. + +## Read a common type error + +If a function needs `string` but receives `string option`, F# is not asking for +a cast. It is pointing out an unhandled possibility. Match the option, map a +function over it, or choose an honest default. Each choice states what `None` +means. + +## Try it + +- Map a title to uppercase. +- Compare `map nonBlank` with `bind nonBlank`. +- Supply a different default. +- Pass `Some "A Novel"` to a function requiring `string`. Read the mismatch, + then repair it by matching or choosing an honest default. + +## Summary + +`map` transforms a possible value, `bind` chains a possibly absent result, and `defaultValue` chooses a fallback. diff --git a/public/documentation/modeling-data/27-results.fsx b/public/documentation/modeling-data/27-results.fsx new file mode 100644 index 0000000..93c90ab --- /dev/null +++ b/public/documentation/modeling-data/27-results.fsx @@ -0,0 +1,23 @@ +type AddToCartError = + | CustomerInactive + | BookUnavailable + | InvalidQuantity of attempted: int + +let addToCart isActive available quantity = + if not isActive then Error CustomerInactive + elif quantity <= 0 then Error(InvalidQuantity quantity) + elif quantity > available then Error BookUnavailable + else Ok quantity + +let describe result = + match result with + | Ok quantity -> $"Added %d{quantity} book(s)" + | Error CustomerInactive -> "Customer is inactive" + | Error BookUnavailable -> "Not enough stock" + | Error(InvalidQuantity attempted) -> $"Invalid quantity: %d{attempted}" + +printfn "%s" (describe (addToCart true 4 2)) +printfn "%s" (describe (addToCart true 4 5)) + +// Predict which validation wins when several inputs are bad. Run it, then add a +// new error case and follow the compiler warning to every match that needs it. diff --git a/public/documentation/modeling-data/27-results.md b/public/documentation/modeling-data/27-results.md new file mode 100644 index 0000000..9f41ded --- /dev/null +++ b/public/documentation/modeling-data/27-results.md @@ -0,0 +1,109 @@ +# Modeling success and failure with Result + +## What you will learn + +`Result` represents either a successful value or an expected, described failure. + +```fsharp +type AddToCartError = + | CustomerInactive + | BookUnavailable + +let addToCart mayOrder inStock = + if not mayOrder then Error CustomerInactive + elif not inStock then Error BookUnavailable + else Ok "book added" +``` + +The type is `Result`: `Ok` carries the success type, while `Error` carries the error type. Match both: + +```fsharp +match result with +| Ok message -> message +| Error CustomerInactive -> "Contact customer support" +| Error BookUnavailable -> "Choose another book" +``` + +Use `Option` when absence is enough information. Use `Result` when a caller needs to know why an expected operation failed. + +Errors are values here, not thrown control flow. Exceptions still have a place for unexpected runtime failures and are covered later. + +Trace the possible calls: + +```fsharp +addToCart true true +// Ok "book added" + +addToCart false true +// Error CustomerInactive + +addToCart true false +// Error BookUnavailable +``` + +The first failing prerequisite determines the error, and later rules are skipped. That ordering is part of this cart decision. + +## Option or Result? + +Searching for a subtitle normally needs no reason, so `string option` is enough. Refusing to add an item must tell the caller whether the customer, stock, or quantity caused the refusal, so `Result` fits. + +The two generic positions may differ completely. Success might carry a `CartLine` record while failure carries a union case. A complete pattern match handles both broad outcomes and binds the appropriate payload in each branch. + +## Give errors useful structure + +```fsharp +type AddToCartError = + | CustomerInactive + | BookUnavailable + | InvalidQuantity of attempted: int +``` + +The payload gives a caller useful information without forcing domain logic to choose display wording. Prefer structured cases when callers must branch; convert them to prose at the presentation boundary. + +Returning `Error` does not unwind the call stack or require `try/with`. It is data, so the caller chooses whether to handle it or pass it along. + +## Result does not mean an error was handled + +Creating `Error BookUnavailable` records a failure. A caller must still decide +what happens next: + +```fsharp +let message = + match addToCart true 0 1 with + | Ok quantity -> $"Added %d{quantity}" + | Error BookUnavailable -> "That book is out of stock" + | Error CustomerInactive -> "This account cannot order" + | Error (InvalidQuantity attempted) -> + $"Quantity %d{attempted} is invalid" +``` + +The compiler checks that the match accounts for every error case. Adding a new +case can therefore reveal every decision point that needs a policy. + +## The two type arguments answer different questions + +Read `Result` as: + +- on success, what trustworthy value is now available? the accepted `int`; +- on failure, what information explains refusal? `AddToCartError`. + +It is common for a beginner to return `Ok true`. That loses the validated value +the next function needs. Prefer returning the accepted quantity, cart line, or +order so success carries evidence that the checks passed. + +If F# reports that it expected `int` but found +`Result`, the failure branch has not been handled yet. +Pattern matching exposes both paths; the next lessons introduce helpers for +transforming and chaining results. + +## Try it + +- Trigger every result case. +- Add `InvalidQuantity` carrying the attempted quantity. +- Change the success payload to the accepted quantity. +- Intentionally return a plain quantity from one branch and `Error` from + another. Read the branch-type mismatch, then wrap success with `Ok`. + +## Summary + +`Result<'ok,'error>` makes expected success and failure part of a function's visible type. Typed error cases make recovery explicit. diff --git a/public/documentation/modeling-data/28-validation.fsx b/public/documentation/modeling-data/28-validation.fsx new file mode 100644 index 0000000..0527951 --- /dev/null +++ b/public/documentation/modeling-data/28-validation.fsx @@ -0,0 +1,23 @@ +type ValidationError = + | EmptyName + | NameTooLong of maximum: int + | InvalidQuantity + +let validateName (name: string) = + let cleaned = name.Trim() + + if cleaned = "" then Error EmptyName + elif cleaned.Length > 40 then Error(NameTooLong 40) + else Ok cleaned + +let describe result = + match result with + | Ok name -> $"Valid customer: %s{name}" + | Error EmptyName -> "Name cannot be empty" + | Error(NameTooLong maximum) -> $"Name must be at most %d{maximum} characters" + | Error InvalidQuantity -> "Quantity must be positive" + +printfn "%s" (describe (validateName " Grace Hopper ")) +printfn "%s" (describe (validateName " ")) + +// Add a minimum-name-length error and update describe exhaustively. diff --git a/public/documentation/modeling-data/28-validation.md b/public/documentation/modeling-data/28-validation.md new file mode 100644 index 0000000..45993d6 --- /dev/null +++ b/public/documentation/modeling-data/28-validation.md @@ -0,0 +1,63 @@ +# Validation as domain logic + +## What you will learn + +Validation functions convert untrusted primitive values into explicit success or failure. + +```fsharp +type ValidationError = EmptyName | NameTooLong of maximum: int + +let validateName (name: string) = + let cleaned = name.Trim() + if cleaned = "" then Error EmptyName + elif cleaned.Length > 40 then Error (NameTooLong 40) + else Ok cleaned +``` + +The successful value is cleaned and ready for the next step in this workflow. Because it is still a plain string, its type alone does not prove that every string came through this function; a later lesson introduces wrapper types and private construction when that guarantee matters. The error is structured data, so a caller can choose its own wording. + +Trace the boundary with several inputs: + +```fsharp +validateName " Ada " +// Ok "Ada" + +validateName " " +// Error EmptyName +``` + +Normalization happens first, so whitespace-only input becomes empty and successful names never retain accidental surrounding spaces. + +Validation belongs at the boundary where loose values become trusted domain values. A boolean such as `isValidName` loses both the cleaned result and the reason for failure. + +Keep one rule per small function when that improves clarity. We will soon combine fallible steps without deeply nested matches. + +Validation order is observable because this function returns its first error. Check fundamental rules first. If an interface eventually needs every independent error at once, it will need a collection-based design after collections have been introduced. + +## Validate at a useful boundary + +Trimming a customer name every time it is displayed scatters the same rule throughout the program. Clean it once during construction and return the useful normalized value, not just `Ok true`. + +A blank name is expected input that the program can reject and explain; it is not an exceptional runtime failure. Representing it as data leaves the caller free to show a message, retry, or stop. + +## A boolean loses the successful value + +```fsharp +let isValidName name = name.Trim() <> "" +``` + +This answers yes or no but discards both the cleaned name and the reason for rejection. Good validation should make the next function easier to write, not just block bad input. + +## Reading errors + +If one branch returns `Error EmptyName` and another returns a plain string, the compiler reports incompatible branch types. Every branch must return the same `Result` shape. + +## Try it + +- Add a minimum length rule. +- Return the cleaned value and prove spaces were removed. +- Write validation for a positive cart quantity. + +## Summary + +Validation can produce a trustworthy value or a precise domain error. This gives later functions simpler assumptions. diff --git a/public/documentation/modeling-data/29-result-chains.fsx b/public/documentation/modeling-data/29-result-chains.fsx new file mode 100644 index 0000000..7d9b857 --- /dev/null +++ b/public/documentation/modeling-data/29-result-chains.fsx @@ -0,0 +1,20 @@ +type CustomerError = + | EmptyName + | ReservedName + +let validateName (name: string) = + let cleaned = name.Trim() + if cleaned = "" then Error EmptyName else Ok cleaned + +let rejectReservedName name = + if name = "Bookshop" then Error ReservedName else Ok name + +let register name = + validateName name + |> Result.bind rejectReservedName + |> Result.map (fun validName -> $"Registered: %s{validName}") + +printfn "%A" (register " Ada ") +printfn "%A" (register "Bookshop") + +// Add a third validation step and make each step fail separately. diff --git a/public/documentation/modeling-data/29-result-chains.md b/public/documentation/modeling-data/29-result-chains.md new file mode 100644 index 0000000..459d674 --- /dev/null +++ b/public/documentation/modeling-data/29-result-chains.md @@ -0,0 +1,83 @@ +# Chaining fallible operations + +## What you will learn + +`Result.map` and `Result.bind` continue a workflow only while it remains successful. + +`Result.map` transforms the value inside `Ok` and leaves `Error` untouched: + +```fsharp +let displayName = validateName input |> Result.map (fun name -> name.ToUpper()) +``` + +Its behavior is the same shape as: + +```fsharp +let mapResult transform result = + match result with + | Ok value -> Ok (transform value) + | Error error -> Error error +``` + +Use `Result.bind` when the next function can itself fail: + +```fsharp +let createCustomer name = + validateName name + |> Result.bind checkNotReserved +``` + +If validation fails, `checkNotReserved` is not called. If it succeeds, `bind` passes the inner value onward without nesting results. + +```fsharp +let bindResult next result = + match result with + | Ok value -> next value + | Error error -> Error error +``` + +```text +Result.map : ('a -> 'b) -> Result<'a,'e> -> Result<'b,'e> +Result.bind : ('a -> Result<'b,'e>) -> Result<'a,'e> -> Result<'b,'e> +``` + +Both steps must agree on the error type. That constraint is often helpful: it encourages a workflow-level error union. + +Choose from the next function's signature: + +```text +string -> Customer // use map +string -> Result // use bind +``` + +Pattern matching is still preferable when different failures require different branches of domain behavior. Helpers are best for linear transformations. + +## Evaluate the chain + +With `"Ada"`, `validateName` produces `Ok "Ada"`; bind extracts the name for `rejectReservedName`; map then formats the final success. With an empty string, the first function returns `Error EmptyName`. Neither later function runs, and the exact error reaches the end unchanged. + +`Result.bind` provides the short-circuiting without exceptions or a hidden error type. If two steps use different error unions, convert them to one workflow error type explicitly before chaining them. + +## Branch when the domain branches + +A pipeline of binds suits a linear workflow. If one error triggers a retry, compensation, or different operation, use an explicit match. Idiomatic functional code is not code with the fewest `match` expressions; it is code whose control flow is visible in values and types. + +## Seeing structured values in the playground + +Several later playgrounds use `%A`: + +```fsharp +printfn "%A" (register "Ada") +``` + +`%A` prints a general structural representation such as `Ok "Ada"` or `Error EmptyName`. It is handy for inspecting records, unions, options, results, and collections while learning. For customer-facing output, format the message yourself. + +## Try it + +- Change a successful input into an empty one. +- Replace `bind` with `map` and inspect the nested type. +- Add a third validation step. + +## Summary + +`map` transforms successful values; `bind` sequences operations that may fail. Errors pass through unchanged until handled. diff --git a/public/documentation/object-programming/abstract-classes-and-inheritance.fsx b/public/documentation/object-programming/abstract-classes-and-inheritance.fsx deleted file mode 100644 index 94a3538..0000000 --- a/public/documentation/object-programming/abstract-classes-and-inheritance.fsx +++ /dev/null @@ -1,13 +0,0 @@ -[] -type Drawable() = - member _.Description = "A drawable shape." - abstract member Draw: float * float -> unit - -type Square(size: int) = - inherit Drawable() - - override this.Draw(x: float, y: float) = - printfn $"Drawing a square @ X: {x}, Y: {y}" - -let square = Square(10) -square.Draw(0, 0) diff --git a/public/documentation/object-programming/abstract-classes-and-inheritance.md b/public/documentation/object-programming/abstract-classes-and-inheritance.md deleted file mode 100644 index 3b37441..0000000 --- a/public/documentation/object-programming/abstract-classes-and-inheritance.md +++ /dev/null @@ -1,52 +0,0 @@ -# Abstract Classes and Inheritance - -Objects can also inherit functionality from other objects. Inheriting creates a hierarchical relationship between two objects (parent and child), where the child has all the behavior of the parent, but implements abstract members that are present, but not implemented, in the parent object. - -Abstract classes, unlike interfaces, can have: behavior, data, and abstract members. To define an abstract class you have to annotate the type with `[]`. - -```fsharp -[] -type Drawable() = - member _.Description = "A drawable shape." - abstract member Draw : float * float -> unit -``` - -Then, you can inherit the behavior and/or data from this type while also implementing the abstract `Draw` member. - -```fsharp -[] -type Drawable() = - member _.Description = "A drawable shape." - abstract member Draw : float * float -> unit - -type Square(size: int) = - inherit Drawable() -// ^^ -// make sure to pass any required constructor parameters - - override this.Draw(x: float, y: float) = - printfn $"Drawing a square @ X: {x}, Y: {y}" - -let square = Square(10) -square.Description // "A drawable shape." -square.Draw(10, 20) // "Drawing a square @ X: 10, Y: 20" -``` - -You can inherit behavior and data from non-abstract classes too. The difference being, abstract classes can provide abstract members that inheritors must provide an implementation for, while normal classes can not. - -```fsharp -type Rockstar() = - member _.PlayMusic() = ... - abstract member Sing: unit -> unit - -type FreddieMercury() = - inherit Rockstar() - override this.Sing() = ... - -let rockstar = Rockstar() -rockstar.PlayMusic() - -let freddie = FreddieMercury() -freddie.PlayMusic() // inherits behavior of the parent. -freddie.Sing() // overrides abstract behavior in the parent. -``` \ No newline at end of file diff --git a/public/documentation/object-programming/interfaces.fsx b/public/documentation/object-programming/interfaces.fsx deleted file mode 100644 index 9c2a5a7..0000000 --- a/public/documentation/object-programming/interfaces.fsx +++ /dev/null @@ -1,18 +0,0 @@ -type IShape = - abstract member Name: string - -type IDrawable = - inherit IShape - abstract member Draw: float * float -> unit - -type Square(size: int) = - interface IDrawable with - member this.Name = "Square" - - member this.Draw(x: float, y: float) = - printfn $"Drawing a square with a size of {size} @ X: {x}, Y: {y}" - - -let drawableShape: IDrawable = Square(10) -printfn $"Shape Name: {drawableShape.Name}" -drawableShape.Draw(10, 20) diff --git a/public/documentation/object-programming/interfaces.md b/public/documentation/object-programming/interfaces.md deleted file mode 100644 index 7a4de31..0000000 --- a/public/documentation/object-programming/interfaces.md +++ /dev/null @@ -1,54 +0,0 @@ -# Interfaces - -An interface defines a contract or set of obligations that implementing types must fullfill. - -```fsharp -type IDrawable = - abstract member Draw : float * float -> unit -``` - -The above drawable interface provides a `Draw` member that implementing types must provide an implementation for. -These interfaces serve as contracts that must be fulfilled by the client. - -```fsharp -type Square(size: int) = - interface IDrawable with - member this.Draw(x: float, y: float) = - printfn $"Drawing a square @ X: {x}, Y: {y}" - -let square: IDrawable = Square(10) -square.Draw(10, 20) -``` - -In the above example, the square object implements the interface `IDrawable` and provides an implementation for the `Draw` member that matches the signature defined in the interface. Also notice how the Square type is annotated as an `IDrawable`. In F#, to call interface members like `Draw`, a type must be convered from the concrete implementation, in this case a `Square`, into an instance of the interface, an `IDrawable`. - -If you don't have an instance of `IDrawable`, you can convert an instance of `Square` into one by upcasting it using the cast (`:>`) operator. - -```fsharp -type Square(size: int) = - interface IDrawable with - member this.Draw(x: float, y: float) = - printfn $"Drawing a square @ X: {x}, Y: {y}" - -let square = Square(10) - -let drawable = square :> IDrawable -drawable.Draw(10, 20) -``` - -Interfaces can also implement other interfaces! This would require the implementing type to supply implementations for abstract members present in both interfaces like so: - -```fsharp -type IShape = - abstract member Name: string - -type IDrawable = - inherit IShape - abstract member Draw : float * float -> unit - -type Square() = - interface IDrawable with - member this.Name = "Square" - member this.Draw(x: float, y: float) = - printfn $"Drawing a square @ X: {x}, Y: {y}" -``` \ No newline at end of file diff --git a/public/documentation/object-programming/object-expressions.fsx b/public/documentation/object-programming/object-expressions.fsx deleted file mode 100644 index 8ab83b9..0000000 --- a/public/documentation/object-programming/object-expressions.fsx +++ /dev/null @@ -1,9 +0,0 @@ -type IDrawable = - abstract member Draw: float * float -> unit - -let square = - { new IDrawable with - member this.Draw(x: float, y: float) = - printfn $"Drawing a square @ X: {x}, Y: {y}" } - -square.Draw(0.0, 0.0) diff --git a/public/documentation/object-programming/object-expressions.md b/public/documentation/object-programming/object-expressions.md deleted file mode 100644 index 78474bb..0000000 --- a/public/documentation/object-programming/object-expressions.md +++ /dev/null @@ -1,41 +0,0 @@ -# Object Expressions - -An object expression allows us to create an anonymous object from an existing base type, interface, or abstract class. - -```fsharp -type IDrawable = - abstract member Draw : float * float -> unit - -let square = - { new IDrawable with - member this.Draw(x: float, y: float) = - printfn $"Drawing a square @ X: {x}, Y: {y}" } - -square.Draw(0.0, 0.0) // "Drawing a square @ X: 0.0, Y: 0.0" -``` - -An object expression can also implement more than one interface. - -```fsharp -type IShape = - abstract Kind: string - -type IDrawable = - abstract member Draw : float * float -> unit - -type ISquare = - inherit IShape - inherit IDrawable - -let square = - { new ISquare with - member this.Kind = "square" - - member this.Draw(x: float, y: float) = - printfn $"Drawing a square @ X: {x}, Y: {y}" } - -square.Kind // "square" -square.Draw(0.0, 0.0) // "Drawing a square @ X: 0.0, Y: 0.0" -``` - -Notice that the type of `square` is an `ISquare` as that's what it's created as when using `new ISquare with`. diff --git a/public/documentation/object-programming/objects-and-members.fsx b/public/documentation/object-programming/objects-and-members.fsx deleted file mode 100644 index 7550406..0000000 --- a/public/documentation/object-programming/objects-and-members.fsx +++ /dev/null @@ -1,12 +0,0 @@ -type Person(firstName: string, lastName: string) = - member val FirstName = firstName with get - member val LastName = lastName with get - - member this.Greet(?greeting: string, ?punctuation: string) = - let greeting = Option.defaultValue "Hello" greeting - let punctuation = Option.defaultValue "!" punctuation - $"{greeting} {this.FirstName} {this.LastName}{punctuation}" - -let person = Person("John", "Doe") -let greeting = person.Greet(greeting = "Greetings", punctuation = "!!!") -printfn "%s" greeting diff --git a/public/documentation/object-programming/objects-and-members.md b/public/documentation/object-programming/objects-and-members.md deleted file mode 100644 index 6f5d09e..0000000 --- a/public/documentation/object-programming/objects-and-members.md +++ /dev/null @@ -1,116 +0,0 @@ -# Objects and Members - -Objects encapsulate data and behavior into a single fundamental unit that can be operated on. The data and behavior of an object can be inherited from another object or be implemented based on a set of requirements provided by an interface. Objects can also have members, which are functions or values that can be used to access and operate on data within an object. - -Let's start with the basics. First, you can define a type named `Person` that is created with `firstName` and `lastName` values. - -```fsharp -type Person(firstName: string, lastName: string) = ... -``` - -The `firstName` and `lastName` parameters make up the objects constructor. To create an instance of this object, you need to pass these values to the constructor. - -```fsharp -let person = Person("John", "Doe") -``` - -Next, you can create member values to expose the constructor parameters as public immutable values. - -```fsharp -type Person(firstName: string, lastName: string) = - member val FirstName = firstName with get - member val LastName = lastName with get - -let person = Person("John", "Doe") -person.FirstName // "John" -person.LastName // "Doe" -``` - -Members can also be functions that operate on the data enclosed within an object. Unlike the let-bound functions, they exist within an object and usually have a tuple as a single argument. - -```fsharp -type Person(firstName: string, lastName: string) = - member val FirstName = firstName with get - member val LastName = lastName with get - member this.Greet(greeting: string) = $"{greeting} {this.FirstName} {this.LastName}!" - -let person = Person("John", "Doe") -let greeting = person.Greet("Hello") // "Hello John Doe!" -``` - -Note the usage of `this` in the above example. `this` is an arbitrary identifier that refers to the current instance of the object. The name `this` is arbitrary and can be anything, or even discarded with `_` although the `this` identifier is common as it's used in many other languages. - -One thing to look out for when invoking members is type inference. The F# compiler cannot infer the types from member invocations alone and may need additional type annotations. - -```fsharp -let test person = - person.Greet("Hello") // <- compiler error -// ^^^^^^^^^^^^^^ -// The compiler can't determine the type of `person` -// because this member invocation is entirely arbitrary. - -let test2 (person: Person) = -// ^^^^^^^^^^^^^^ -// type annotation is required - person.Greet("Hello") // <- this works -``` - -Members, unlike let-bound functions, have a unique ability to contain optional parameters with or without default values. - -```fsharp -type Person(firstName: string, lastName: string) = - member val FirstName = firstName with get - member val LastName = lastName with get - - member this.Greet(?greeting: string) = -// ^ -// notice that the parameter is prefixed with a ? -// which indicates that it's an optional parameter. - let greeting = Option.defaultValue "Hello" greeting - $"{greeting} {this.FirstName} {this.LastName}!" - -let person = Person("John", "Doe") -person.Greet() // "Hello John Doe!" -person.Greet("Greetings") // "Greetings John Doe!" -``` - -One caveat to this is that if a member has multiple optional parameters, you may need to supply parameter names along with their values. This is because the parameter order by default will be sequential, but optional parameters can be skipped, so parameter names must be provided. - -```fsharp -type Person(firstName: string, lastName: string) = - member val FirstName = firstName with get - member val LastName = lastName with get - - member this.Greet(?greeting: string, ?punctuation: string) = - let greeting = Option.defaultValue "Hello" greeting - let punctuation = Option.defaultValue "!" punctuation - $"{greeting} {this.FirstName} {this.LastName}{punctuation}" - -let person = Person("John", "Doe") -person.Greet() // "Hello John Doe!" -person.Greet("Greetings") // "Greetings John Doe!" -person.Greet(punctuation = ".") // "Hello John Doe." -// ^^^^^^^^^^^^^^^^^ -// punctuation is the second parameter. -// without specifying the parameter name, the compiler will -// infer that the "." value would be the "greeting" parameter -// as its the first parameter. -``` - -To specify an optional parameters default value, you need to use the `[]` and `[]` annotations instead of the `?parameter` syntax. - -```fsharp -open System.Runtime.InteropServices -// this import is required to use the annotation - -type Person(firstName: string, lastName: string) = - member val FirstName = firstName with get - member val LastName = lastName with get - - member this.Greet([] greeting: string) = - $"{greeting} {this.FirstName} {this.LastName}!" - -let person = Person("John", "Doe") -person.Greet() // "Hello John Doe!" -person.Greet("Greetings") // "Greetings John Doe!" -``` \ No newline at end of file diff --git a/public/documentation/object-programming/type-extensions.fsx b/public/documentation/object-programming/type-extensions.fsx deleted file mode 100644 index 768fdcd..0000000 --- a/public/documentation/object-programming/type-extensions.fsx +++ /dev/null @@ -1,11 +0,0 @@ -open System - -type String with - - member string.IsUpperCase() = string = string.ToUpper() - -let uppercaseString = "HELLO WORLD" -let lowercaseStringValue = "hello world" - -printfn "uppercaseString.IsUpperCase() = %b" (uppercaseString.IsUpperCase()) -printfn "lowercaseStringValue.IsUpperCase() = %b" (lowercaseStringValue.IsUpperCase()) diff --git a/public/documentation/object-programming/type-extensions.md b/public/documentation/object-programming/type-extensions.md deleted file mode 100644 index 9a212df..0000000 --- a/public/documentation/object-programming/type-extensions.md +++ /dev/null @@ -1,37 +0,0 @@ -# Type Extensions - -Type extensions allow us to extend the behavior of previously defined types by creating members for them. - -```fsharp -module Extensions - -type String with -// ^^^^^^ -// the type we will be extending - member string.IsUpperCase() = -// ^^^^^^ -// this will be the instance of the string on which -// this member will be called - string = string.ToUpper() - -let uppercaseStringValue = "HELLO WORLD" -let lowercaseStringValue = "hello world" - -uppercaseStringValue.IsUpperCase() // true -lowercaseStringValue.IsUpperCase() // false -``` - -This is called an optional type extension as we are exposing a type extension to a non-user defined type through a module/namespace that must be imported. This is in contrast to an instrinsic type extension that exists for a user-defined type and must be present in the same file. - -```fsharp -module Domain - -type Person = { FirstName: string; LastName: string } - -type Person with - member this.FullName = - $"{person.FirstName} {person.LastName}" - -let person = { FirstName = "John"; LastName = "Doe"; } -person.FullName // "John Doe" -``` \ No newline at end of file diff --git a/public/documentation/putting-it-together/58-signatures-and-design.fsx b/public/documentation/putting-it-together/58-signatures-and-design.fsx new file mode 100644 index 0000000..7c696be --- /dev/null +++ b/public/documentation/putting-it-together/58-signatures-and-design.fsx @@ -0,0 +1,51 @@ +type BookId = BookId of int +type Money = Money of int + +type Book = { + Id: BookId + Title: string + Price: Money +} + +type CartLine = { BookId: BookId; Quantity: int } + +type PricedLine = { + BookId: BookId + Quantity: int + UnitPrice: Money + Total: Money +} + +type CheckoutError = InvalidQuantity of BookId + +let validateQuantity (line: CartLine) = + if line.Quantity > 0 then + Ok line + else + Error(InvalidQuantity line.BookId) + +let calculateLineTotal (Money price) quantity = Money(price * quantity) + +let createPricedLine (book: Book) (line: CartLine) : PricedLine = { + BookId = book.Id + Quantity = line.Quantity + UnitPrice = book.Price + Total = calculateLineTotal book.Price line.Quantity +} + +let priceRequestedLine (book: Book) (line: CartLine) = + line |> validateQuantity |> Result.map (createPricedLine book) + +let book = { + Id = BookId 1 + Title = "Kindred" + Price = Money 1299 +} + +let validLine: CartLine = { BookId = book.Id; Quantity = 2 } +let invalidLine: CartLine = { BookId = book.Id; Quantity = 0 } + +printfn "Valid: %A" (priceRequestedLine book validLine) +printfn "Invalid: %A" (priceRequestedLine book invalidLine) + +// Add a maximum-quantity rule without changing createPricedLine. diff --git a/public/documentation/putting-it-together/58-signatures-and-design.md b/public/documentation/putting-it-together/58-signatures-and-design.md new file mode 100644 index 0000000..6ab233e --- /dev/null +++ b/public/documentation/putting-it-together/58-signatures-and-design.md @@ -0,0 +1,134 @@ +# Function signatures and small pure functions + +## What you will learn + +Function signatures expose design choices, and small pure functions make a larger order workflow easier to assemble and reason about. + +## Types describe a design before an implementation + +A signature now tells us far more than syntax. It names the information a function requires and the outcomes it can produce: + +```text +findBook : BookId -> Catalog -> Book option +priceLine : Book -> CartLine -> PricedLine +validateCart : Catalog -> Inventory -> Cart -> Result +``` + +The algorithms remain hidden, but callers already know what to supply and what to handle. The first lookup may return nothing; the last operation can explain why checkout failed. + +Write annotations at meaningful boundaries: + +```fsharp +let findBook (bookId: BookId) (catalog: Catalog) : Book option = + catalog |> Map.tryFind bookId +``` + +Inside small helpers, inference keeps implementations uncluttered. At a domain boundary, annotations document the contract and resolve ambiguous record fields or overloaded members. + +## Narrow inputs expose real dependencies + +This rule receives an entire shop state: + +```fsharp +let hasEnoughStock shop line = + match shop.Inventory |> Map.tryFind line.BookId with + | None -> false + | Some stock -> line.Quantity <= available stock +``` + +The arithmetic itself needs only two numbers: + +```fsharp +let hasEnough availableQuantity requestedQuantity = + requestedQuantity > 0 && requestedQuantity <= availableQuantity +``` + +The second function is easier to understand and reuse because unrelated catalog, customer, and order values cannot influence it. The lookup belongs in a coordinating function; the stock rule does not. + +Narrow dependencies should not erase meaning. A validated `PricedLine` is more cohesive than four unrelated primitive arguments. Accept a record when the whole record is the concept the function needs. + +## Let outputs tell the necessary truth + +```text +CartLine -> bool +CartLine -> Result +CartLine -> Result +``` + +The boolean answers only yes or no. The unit result can explain refusal. The final signature also constructs the successful value needed by the next step. Choose the smallest output that preserves information the caller genuinely needs. + +## Extract the real steps + +A large checkout block may contain these smaller operations: + +```fsharp +let validateQuantity line = + if line.Quantity > 0 then Ok line + else Error (InvalidQuantity line.BookId) + +let calculateLineTotal (Money price) quantity = + Money (price * quantity) + +let createPricedLine book line = + { + BookId = book.Id + Quantity = line.Quantity + UnitPrice = book.Price + Total = calculateLineTotal book.Price line.Quantity + } +``` + +Extract a binding when it names a meaningful rule, has a coherent signature, or appears in more than one workflow. A wrapper that only renames obvious syntax adds little. + +## Pure functions as the default domain tool + +A pure function depends only on its inputs, returns a value, and produces no observable side effect. F# is not purely functional: it also supports printing, mutation, exceptions, and objects. + +```fsharp +let shippingCost method subtotal = + match method with + | Collection -> Money 0 + | StandardPost -> Money 500 + | ExpressPost when subtotal >= Money 5000 -> Money 0 + | ExpressPost -> Money 900 +``` + +The function receives policy facts and produces a value. It reads no clock, updates no order, and prints nothing. + +Keep effects at the edge: + +```fsharp +let report = buildOrderReport orders +printfn "%s" report +``` + +## Composition should clarify, not conceal + +Composition can make a reusable transformation: + +```fsharp +let normalizeQuery = trim >> lowercase +``` + +But checkout depends on several named values and branching errors. Explicit arguments, matches, and local bindings are clearer there. Point-free code is an option, not an achievement level. + +## Review signatures before bodies + +Ask: + +1. Does every input affect the result? +2. Is required context explicit rather than global? +3. Does absence need `Option`, or refusal need `Result`? +4. Does success carry the trustworthy value needed next? +5. Is one record a cohesive concept or an oversized bag of context? + +## Experiment + +- Write signatures for catalog lookup, pricing one line, and placing an order before implementing them. +- Narrow a stock rule from `Shop -> CartLine -> bool` to its actual facts. +- Move `printfn` out of a calculation. +- Compare a short composition with a version using named intermediate values. + +## Summary + +Signatures are executable design constraints. Small pure functions work best when each type describes one meaningful responsibility and larger workflows coordinate their returned values explicitly. diff --git a/public/documentation/putting-it-together/59-placing-orders.fsx b/public/documentation/putting-it-together/59-placing-orders.fsx new file mode 100644 index 0000000..6051716 --- /dev/null +++ b/public/documentation/putting-it-together/59-placing-orders.fsx @@ -0,0 +1,203 @@ +module Shop = + type BookId = BookId of int + type CustomerId = CustomerId of int + type OrderId = OrderId of int + type Isbn = Isbn of string + type EmailAddress = EmailAddress of string + type Money = Money of int + + type Author = { Name: string } + + type Book = { + Id: BookId + Isbn: Isbn + Title: string + Authors: Author list + Price: Money + } + + type Membership = + | Standard + | Member of discountPercent: int + + type Customer = { + Id: CustomerId + Name: string + Email: EmailAddress option + Membership: Membership + } + + type Stock = { OnHand: int; Reserved: int } + + type CartLine = { BookId: BookId; Quantity: int } + + type Cart = { Lines: CartLine list } + + type ShippingMethod = + | Collection + | StandardPost + | ExpressPost + + type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money + } + + type OrderStatus = | AwaitingPayment + + type Order = { + Id: OrderId + CustomerId: CustomerId + Lines: PricedLine list + Subtotal: Money + Discount: Money + ShippingCost: Money + Total: Money + ShippingMethod: ShippingMethod + Status: OrderStatus + } + + type State = { + Catalog: Map + Customers: Map + Inventory: Map + Orders: Map + NextOrderId: int + } + + type CheckoutError = + | CustomerNotFound + | EmptyCart + | BookNotFound of BookId + | InvalidQuantity of BookId + | NotEnoughStock of BookId * available: int + + let add (Money left) (Money right) = Money(left + right) + let subtract (Money reduction) (Money amount) = Money(amount - reduction) + let multiply quantity (Money amount) = Money(quantity * amount) + let available stock = stock.OnHand - stock.Reserved + + let priceLine (book: Book) (line: CartLine) : PricedLine = { + BookId = book.Id + Title = book.Title + Quantity = line.Quantity + UnitPrice = book.Price + LineTotal = multiply line.Quantity book.Price + } + + let prepareLine catalog inventory (line: CartLine) = + match Map.tryFind line.BookId catalog, Map.tryFind line.BookId inventory with + | None, _ -> Error(BookNotFound line.BookId) + | _, None -> Error(NotEnoughStock(line.BookId, 0)) + | Some _, Some _ when line.Quantity <= 0 -> Error(InvalidQuantity line.BookId) + | Some book, Some stock when line.Quantity <= available stock -> + let priced = priceLine book line + + let updatedStock = { + stock with + Reserved = stock.Reserved + line.Quantity + } + + let updatedInventory = inventory |> Map.add line.BookId updatedStock + Ok(priced, updatedInventory) + | Some _, Some stock -> Error(NotEnoughStock(line.BookId, available stock)) + + let prepareLines catalog initialInventory (lines: CartLine list) = + lines + |> List.fold + (fun state line -> + state + |> Result.bind (fun (pricedLines, inventory) -> + prepareLine catalog inventory line + |> Result.map (fun (priced, nextInventory) -> priced :: pricedLines, nextInventory))) + (Ok([], initialInventory)) + |> Result.map (fun (reversed, inventory) -> List.rev reversed, inventory) + + let subtotal lines = + lines |> List.fold (fun total line -> add total line.LineTotal) (Money 0) + + let discountFor customer (Money amount) = + match customer.Membership with + | Standard -> Money 0 + | Member percent -> Money(amount * percent / 100) + + let shippingCost method (Money amount) = + match method with + | Collection -> Money 0 + | StandardPost -> Money 500 + | ExpressPost when amount >= 5000 -> Money 0 + | ExpressPost -> Money 900 + + let placeOrder customerId (cart: Cart) shippingMethod (state: State) = + match state.Customers |> Map.tryFind customerId with + | None -> Error CustomerNotFound + | Some _ when cart.Lines = [] -> Error EmptyCart + | Some customer -> + prepareLines state.Catalog state.Inventory cart.Lines + |> Result.map (fun (lines, inventory) -> + let beforeDiscount = subtotal lines + let discount = discountFor customer beforeDiscount + let delivery = shippingCost shippingMethod beforeDiscount + let total = beforeDiscount |> subtract discount |> add delivery + let orderId = OrderId state.NextOrderId + + let order = { + Id = orderId + CustomerId = customer.Id + Lines = lines + Subtotal = beforeDiscount + Discount = discount + ShippingCost = delivery + Total = total + ShippingMethod = shippingMethod + Status = AwaitingPayment + } + + let nextState = { + state with + Inventory = inventory + Orders = state.Orders |> Map.add orderId order + NextOrderId = state.NextOrderId + 1 + } + + nextState, order) + +open Shop + +let book = { + Id = BookId 1 + Isbn = Isbn "9780807083697" + Title = "Kindred" + Authors = [ { Name = "Octavia E. Butler" } ] + Price = Money 1299 +} + +let customer = { + Id = CustomerId 1 + Name = "Ada" + Email = Some(EmailAddress "ada@example.org") + Membership = Member 10 +} + +let initial = { + Catalog = Map.ofList [ (book.Id, book) ] + Customers = Map.ofList [ (customer.Id, customer) ] + Inventory = Map.ofList [ (book.Id, { OnHand = 5; Reserved = 0 }) ] + Orders = Map.empty + NextOrderId = 1 +} + +let cart = { + Lines = [ { BookId = book.Id; Quantity = 2 } ] +} + +match placeOrder customer.Id cart StandardPost initial with +| Error error -> printfn "Order failed: %A" error +| Ok(updated, order) -> + printfn "Order: %A" order + printfn "Inventory: %A" updated.Inventory + +// Try an unavailable quantity, a standard customer, and a second cart line. diff --git a/public/documentation/putting-it-together/59-placing-orders.md b/public/documentation/putting-it-together/59-placing-orders.md new file mode 100644 index 0000000..dddcd27 --- /dev/null +++ b/public/documentation/putting-it-together/59-placing-orders.md @@ -0,0 +1,110 @@ +# Placing orders + +## What you will learn + +Catalog, customer, cart, inventory, pricing, and order values can cooperate in one immutable order-placement transformation that returns either an error or a complete successor state. + +## Familiar pieces now cooperate + +The shop has grown one concern at a time: catalog books, validated customers, inventory, carts, money, discounts, shipping, and order states. We will connect them through one workflow—placing an order—before adding payment, shipping, and reports in the next lessons. + +```fsharp +type Shop = + { + Catalog: Map + Customers: Map + Inventory: Map + Orders: Map + NextOrderId: int + } +``` + +The state stores authoritative facts. Search results, available quantities, discount amounts, and report groups are calculated when needed. + +## Place an order in explicit stages + +```text +customer ID + cart + shipping + shop +→ find customer +→ reject an empty cart +→ validate and price every line +→ reserve inventory +→ calculate subtotal, discount, and shipping +→ construct an awaiting-payment order +→ return the successor shop and order +``` + +Each stage either returns a value needed by the next stage or an explicit error. + +## Validate one line and reserve its stock + +```fsharp +let prepareLine catalog inventory line = + match Map.tryFind line.BookId catalog, Map.tryFind line.BookId inventory with + | None, _ -> Error (BookNotFound line.BookId) + | _, None -> Error (NotEnoughStock (line.BookId, 0)) + | Some _, Some _ when line.Quantity <= 0 -> + Error (InvalidQuantity line.BookId) + | Some book, Some stock when line.Quantity <= available stock -> + let priced = priceLine book line + let updatedStock = { stock with Reserved = stock.Reserved + line.Quantity } + let updatedInventory = inventory |> Map.add line.BookId updatedStock + Ok (priced, updatedInventory) + | Some _, Some stock -> + Error (NotEnoughStock (line.BookId, available stock)) +``` + +The result contains both consequences that must remain together: the accepted price snapshot and successor inventory. + +## Fold while carrying Result and inventory + +```fsharp +let prepareLines catalog initialInventory lines = + lines + |> List.fold + (fun state line -> + state + |> Result.bind (fun (pricedLines, inventory) -> + prepareLine catalog inventory line + |> Result.map (fun (priced, nextInventory) -> + priced :: pricedLines, nextInventory))) + (Ok ([], initialInventory)) + |> Result.map (fun (reversed, inventory) -> + List.rev reversed, inventory) +``` + +Each line sees inventory already reserved by earlier lines. This matters if a manually constructed cart contains the same book twice. After the first failure, the fold still visits the remaining list cells, but `Result.bind` skips their preparation. No partially updated state escapes because every update exists only inside the Result accumulator. + +## Calculate policy after validation + +```fsharp +let subtotal = pricedLines |> List.fold addLineTotal (Money 0) +let discount = calculateDiscount customer subtotal +let shipping = shippingCost shippingMethod subtotal +let total = subtotal |> subtract discount |> add shipping +``` + +These are pure calculations over validated values. The order snapshots all three amounts so later catalog or membership changes cannot rewrite history. + +## Return the successor and the created value + +```text +placeOrder : CustomerId -> Cart -> ShippingMethod -> Shop + -> Result +``` + +The caller needs the successor shop for later commands and the created order for display or payment. Returning a tuple keeps both related outcomes together. + +On `Error`, no successor `Shop` is returned and the original value remains unchanged. On `Ok`, the returned shop contains both the inventory reservation and the new order. This is an all-or-error property of the pure value transformation, not a database transaction or concurrency guarantee. + +## Experiment + +- Place the sample order and inspect reserved inventory. +- Request more copies than are available. +- Use a missing customer or book ID. +- Add a second cart line and trace the fold accumulator. +- Change membership and compare discount and total. + +## Summary + +A substantial workflow can still be built from small transformations. Here, immutable state and Result ensure that callers receive either a complete successor value or an error, without hidden mutation. diff --git a/public/documentation/putting-it-together/59-result-expressions.fsx b/public/documentation/putting-it-together/59-result-expressions.fsx new file mode 100644 index 0000000..ee159b1 --- /dev/null +++ b/public/documentation/putting-it-together/59-result-expressions.fsx @@ -0,0 +1,33 @@ +type ResultBuilder() = + member _.Bind(result, next) = Result.bind next result + member _.Return(value) = Ok value + member _.ReturnFrom(result) = result + +let result = ResultBuilder() + +type CheckoutError = + | CustomerMissing + | EmptyCart + | InvalidShipping + +let findCustomer exists = + if exists then Ok "Ada" else Error CustomerMissing + +let validateCart lineCount = + if lineCount > 0 then Ok lineCount else Error EmptyCart + +let validateShipping supplied = + if supplied then Ok() else Error InvalidShipping + +let prepareOrder customerExists lineCount shippingSupplied = + result { + let! customer = findCustomer customerExists + let! validatedCount = validateCart lineCount + do! validateShipping shippingSupplied + return $"%s{customer}: %d{validatedCount} line(s) ready" + } + +printfn "%A" (prepareOrder true 2 true) +printfn "%A" (prepareOrder true 0 true) + +// Make each input invalid separately and confirm later steps are skipped. diff --git a/public/documentation/putting-it-together/59-result-expressions.md b/public/documentation/putting-it-together/59-result-expressions.md new file mode 100644 index 0000000..3a86941 --- /dev/null +++ b/public/documentation/putting-it-together/59-result-expressions.md @@ -0,0 +1,195 @@ +# Result computation expressions + +## What you will learn + +Use a computation expression to write a sequence of dependent `Result` +operations without nesting `Result.bind` and `Result.map` callbacks. + +## Result pipelines eventually bend inward + +A short Result pipeline is easy to read: + +```fsharp +validateQuantity line +|> Result.map priceLine +``` + +One transformation follows one validation, so the pipeline already states the +flow clearly. Keep code like this as a pipeline. + +The shape changes when several successful values are needed later: + +```fsharp +let prepareOrder request = + findCustomer request.CustomerId + |> Result.bind (fun customer -> + validateCart request.Cart + |> Result.bind (fun lines -> + validateShipping request.Shipping + |> Result.map (fun shipping -> + createDraft customer lines shipping))) +``` + +Each operation depends on an earlier success. The lambdas nest because +`customer`, `lines`, and `shipping` must remain in scope until the draft is +created. The mixture of several binds and a final map is correct, but its +indentation emphasizes plumbing instead of the workflow. + +Use a Result computation expression when a pipeline contains multiple dependent +`bind` and `map` operations and begins to nest. Keep a direct `Result.map` or +`Result.bind` when it remains flatter and clearer. + +## A computation expression gives the nesting a sequential form + +The same workflow can be written as: + +```fsharp +let prepareOrder request = + result { + let! customer = findCustomer request.CustomerId + let! lines = validateCart request.Cart + let! shipping = validateShipping request.Shipping + return createDraft customer lines shipping + } +``` + +Read it from top to bottom: + +1. obtain a customer or stop with that error; +2. obtain validated lines or stop with that error; +3. obtain validated shipping or stop with that error; +4. construct the successful draft. + +The computation expression has not made failure implicit. The function still +returns `Result`, and the first `Error` still becomes +the result of the whole block. + +## The builder defines what the syntax means + +FSharp.Core provides the `Result` type and functions such as `Result.map` and +`Result.bind`, but it does not provide a built-in `result { ... }` builder. This +small builder supplies the operations needed by the playground: + +```fsharp +type ResultBuilder() = + member _.Bind(result, next) = + Result.bind next result + + member _.Return(value) = + Ok value + + member _.ReturnFrom(result) = + result + +let result = ResultBuilder() +``` + +The name before the braces is a value. Its members interpret special syntax +inside the block. Another builder may give the same syntax different behavior. + +## `let!` is bind; `return` supplies the final map + +This block: + +```fsharp +result { + let! customer = findCustomer customerId + return customer.Name +} +``` + +corresponds to: + +```fsharp +findCustomer customerId +|> Result.map (fun customer -> customer.Name) +``` + +Conceptually, `let!` uses `Bind`. For `Ok customer`, it binds the inner value +and continues; for `Error error`, it skips the remaining body and preserves the +error. When the remaining body only returns an ordinary value, `return` wraps +that value with `Ok`, giving the whole expression the effect of a final map. + +The exact translation is expressed in terms of builder members, but this +bind/map correspondence is the useful way to read an ordinary Result workflow. + +## `do!` and `return!` + +Use `do!` when a step returns `Result` and success carries no value +that needs a name: + +```fsharp +result { + let! customer = findCustomer customerId + do! ensureCustomerMayOrder customer + return customer +} +``` + +Use `return!` when the final expression already is a `Result`: + +```fsharp +result { + let! customer = findCustomer customerId + return! createOrder customer +} +``` + +- `let!` unwraps a successful value and continues. +- `do!` performs the same bind when the success value is `unit`. +- `return` wraps an ordinary successful value. +- `return!` returns an already wrapped Result. + +Here, `return` and `return!` belong to computation-expression syntax. A normal +F# function still produces the value of its final expression without a return +keyword. + +## Use established builders in real code + +Defining a tiny builder exposes the mechanics, but production code does not +usually need to maintain its own. The widely used +[`FsToolkit.ErrorHandling`](https://github.com/demystifyfp/FsToolkit.ErrorHandling) +library provides builders and supporting functions for common error-handling +shapes. These include `result`, `option`, `validation`, and combinations with +asynchronous workflows such as `taskResult`. + +The builders support more syntax and conversions than this teaching version. +Use the builder whose returned type matches the workflow, and consult its +documentation rather than assuming all computation expressions handle failure +or accumulation identically. In particular, a Result workflow normally stops +at the first error, while validation-oriented abstractions may accumulate +independent errors. + +The playground defines its builder locally so it remains a complete standalone +program. It is demonstrating the same core `Bind`, `Return`, and `ReturnFrom` +relationship that library builders package more thoroughly. + +## Choose between a pipeline, match, and computation expression + +- Use a pipeline for one or two flat transformations where `Result.map` or + `Result.bind` makes the operation obvious. +- Use a Result computation expression when several dependent binds and maps + introduce nested lambdas or when several successful values must remain in + scope. +- Use pattern matching when different errors lead to substantially different + domain decisions rather than ordinary short-circuit propagation. + +A computation expression is not automatically clearer. It earns its place when +it removes structural nesting and makes the workflow's dependent steps visible. + +## Experiment + +- Trace which steps run when customer lookup fails. +- Rewrite the playground with nested `Result.bind` and compare indentation. +- Add one final transformation first with `Result.map`, then with `return`. +- Add a `Result` check using `do!`. +- Replace the final `return` with a function that already returns `Result`, read + the nested type or error, and repair it with `return!`. + +## Summary + +A Result computation expression presents nested, dependent binds and maps as a +linear workflow. Use it once an ordinary Result pipeline bends into nested +callbacks; keep simple Result transformations as pipelines. In application +code, established libraries such as `FsToolkit.ErrorHandling` provide complete +builders for Result and related error-handling contexts. diff --git a/public/documentation/putting-it-together/60-payment-and-cancellation.fsx b/public/documentation/putting-it-together/60-payment-and-cancellation.fsx new file mode 100644 index 0000000..cc594a3 --- /dev/null +++ b/public/documentation/putting-it-together/60-payment-and-cancellation.fsx @@ -0,0 +1,111 @@ +type OrderId = OrderId of int +type CustomerId = CustomerId of int +type BookId = BookId of int +type Money = Money of int + +type ShippingMethod = + | Collection + | StandardPost + | ExpressPost + +type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money +} + +type PaymentMethod = + | Card of lastFourDigits: string + | GiftCard of code: string + +type Payment = { + Method: PaymentMethod + PaidOnDay: int +} + +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Cancelled of reason: string + +// This is the same order shape produced by the placement lesson. +// Only Status has grown to represent later lifecycle facts. +type Order = { + Id: OrderId + CustomerId: CustomerId + Lines: PricedLine list + Subtotal: Money + Discount: Money + ShippingCost: Money + Total: Money + ShippingMethod: ShippingMethod + Status: OrderStatus +} + +type State = { Orders: Map } + +type TransitionError = + | OrderNotFound + | AlreadyPaid + | OrderAlreadyCancelled + +let pay payment order = + match order.Status with + | AwaitingPayment -> Ok { order with Status = Paid payment } + | Paid _ -> Error AlreadyPaid + | Cancelled _ -> Error OrderAlreadyCancelled + +let cancel reason order = + match order.Status with + | AwaitingPayment -> Ok { order with Status = Cancelled reason } + | Paid _ -> Error AlreadyPaid + | Cancelled _ -> Error OrderAlreadyCancelled + +let updateOrder orderId transition state = + match state.Orders |> Map.tryFind orderId with + | None -> Error OrderNotFound + | Some order -> + transition order + |> Result.map (fun updatedOrder -> { + state with + Orders = state.Orders |> Map.add orderId updatedOrder + }) + +let orderId = OrderId 1 + +let order = { + Id = orderId + CustomerId = CustomerId 1 + Lines = [ + { + BookId = BookId 1 + Title = "Kindred" + Quantity = 2 + UnitPrice = Money 1299 + LineTotal = Money 2598 + } + ] + Subtotal = Money 2598 + Discount = Money 259 + ShippingCost = Money 500 + Total = Money 2839 + ShippingMethod = StandardPost + Status = AwaitingPayment +} + +let initial = { + Orders = Map.ofList [ (orderId, order) ] +} + +let payment = { Method = Card "4242"; PaidOnDay = 20 } + +let paid = initial |> updateOrder orderId (pay payment) +let cancelled = initial |> updateOrder orderId (cancel "customer request") + +printfn "Paid history: %A" paid +printfn "Cancelled history: %A" cancelled +printfn "Pay twice: %A" (paid |> Result.bind (updateOrder orderId (pay payment))) + +// Predict each Result, run it, then try cancelling the paid history. diff --git a/public/documentation/putting-it-together/60-payment-and-cancellation.md b/public/documentation/putting-it-together/60-payment-and-cancellation.md new file mode 100644 index 0000000..a2a9520 --- /dev/null +++ b/public/documentation/putting-it-together/60-payment-and-cancellation.md @@ -0,0 +1,133 @@ +# Payment and cancellation + +## What you will learn + +Represent order commands as checked state transitions that preserve the facts +needed by later transitions. + +## Placement is only the beginning + +The previous lesson created an order in `AwaitingPayment`. A Boolean such as +`IsPaid` cannot explain when payment happened or prevent contradictory flag +combinations. The order status should describe its current lifecycle state: + +```fsharp +type PaymentMethod = + | Card of lastFourDigits: string + | GiftCard of code: string + +type Payment = + { + Method: PaymentMethod + PaidOnDay: int + } + +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Cancelled of reason: string +``` + +The `Paid` case carries the payment information because that information exists +only after payment. `Cancelled` carries the reason for the same reason. + +## A transition receives the current value + +```fsharp +type TransitionError = + | AlreadyPaid + | OrderAlreadyCancelled + +let pay payment order = + match order.Status with + | AwaitingPayment -> + Ok { order with Status = Paid payment } + | Paid _ -> + Error AlreadyPaid + | Cancelled _ -> + Error OrderAlreadyCancelled +``` + +Every branch explains one current state. Success returns a complete successor +order. Failure returns no successor and leaves the original value untouched. + +Notice what the type does and does not guarantee. `OrderStatus` prevents one +status value from being both paid and cancelled. It does not prevent arbitrary +code from constructing `Paid` directly while the union cases remain public. +Keeping transitions in a module and exposing a smaller public surface can make +the intended route clearer. + +## Cancellation has different rules + +```fsharp +let cancel reason order = + match order.Status with + | AwaitingPayment -> + Ok { order with Status = Cancelled reason } + | Paid _ -> + Error AlreadyPaid + | Cancelled _ -> + Error OrderAlreadyCancelled +``` + +This teaching domain refuses cancellation after payment because refunds have +not been modeled. A real domain might introduce `RefundPending`, `Refunded`, or +a separate refund workflow. The union should reflect the policy the program +actually implements, not an imagined universal order lifecycle. + +## Update an order inside shop state + +Orders are stored in a map, so the coordinating function first finds the order, +then applies the small transition, then stores the successor: + +```fsharp +let updateOrder orderId transition state = + match state.Orders |> Map.tryFind orderId with + | None -> Error OrderNotFound + | Some order -> + transition order + |> Result.map (fun updatedOrder -> + { + state with + Orders = state.Orders |> Map.add orderId updatedOrder + }) +``` + +`updateOrder` is higher-order: the caller supplies the transition. Payment can +partially apply `pay payment`; cancellation can partially apply +`cancel reason`. + +```fsharp +let payOrder orderId payment state = + updateOrder orderId (pay payment) state +``` + +The lookup concern and lifecycle rule remain separate, and both failures stay +explicit in `Result`. + +## Trace before running + +Starting from `AwaitingPayment`: + +1. `payOrder` returns a state containing `Paid payment`. +2. Paying that returned state again produces `AlreadyPaid`. +3. Cancelling the original state succeeds because immutable values let both + possible histories be explored independently. + +That third point does not imply that a deployed application should accept two +concurrent histories. It shows only that pure transition functions do not +destroy their inputs. + +## Experiment + +- Pay the awaiting order and inspect the stored payment. +- Attempt to pay the returned state twice. +- Cancel the original unpaid state. +- Add a `PaymentDeclined` error and decide which function should produce it. +- Introduce a refund state before allowing cancellation of a paid order. + +## Summary + +Lifecycle unions record meaningful states and their associated facts. Transition +functions accept a current value and return either a complete successor or a +domain error, while a coordinator updates the surrounding map. diff --git a/public/documentation/putting-it-together/60-placing-orders.fsx b/public/documentation/putting-it-together/60-placing-orders.fsx new file mode 100644 index 0000000..e453185 --- /dev/null +++ b/public/documentation/putting-it-together/60-placing-orders.fsx @@ -0,0 +1,211 @@ +module Shop = + type ResultBuilder() = + member _.Bind(result, next) = Result.bind next result + member _.Return(value) = Ok value + member _.ReturnFrom(result) = result + + let result = ResultBuilder() + + type BookId = BookId of int + type CustomerId = CustomerId of int + type OrderId = OrderId of int + type Isbn = Isbn of string + type EmailAddress = EmailAddress of string + type Money = Money of int + + type Author = { Name: string } + + type Book = { + Id: BookId + Isbn: Isbn + Title: string + Authors: Author list + Price: Money + } + + type Membership = + | Standard + | Member of discountPercent: int + + type Customer = { + Id: CustomerId + Name: string + Email: EmailAddress option + Membership: Membership + } + + type Stock = { OnHand: int; Reserved: int } + + type CartLine = { BookId: BookId; Quantity: int } + + type Cart = { Lines: CartLine list } + + type ShippingMethod = + | Collection + | StandardPost + | ExpressPost + + type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money + } + + type OrderStatus = | AwaitingPayment + + type Order = { + Id: OrderId + CustomerId: CustomerId + Lines: PricedLine list + Subtotal: Money + Discount: Money + ShippingCost: Money + Total: Money + ShippingMethod: ShippingMethod + Status: OrderStatus + } + + type State = { + Catalog: Map + Customers: Map + Inventory: Map + Orders: Map + NextOrderId: int + } + + type CheckoutError = + | CustomerNotFound + | EmptyCart + | BookNotFound of BookId + | InvalidQuantity of BookId + | NotEnoughStock of BookId * available: int + + let add (Money left) (Money right) = Money(left + right) + let subtract (Money reduction) (Money amount) = Money(amount - reduction) + let multiply quantity (Money amount) = Money(quantity * amount) + let available stock = stock.OnHand - stock.Reserved + + let priceLine (book: Book) (line: CartLine) : PricedLine = { + BookId = book.Id + Title = book.Title + Quantity = line.Quantity + UnitPrice = book.Price + LineTotal = multiply line.Quantity book.Price + } + + let prepareLine catalog inventory (line: CartLine) = + match Map.tryFind line.BookId catalog, Map.tryFind line.BookId inventory with + | None, _ -> Error(BookNotFound line.BookId) + | _, None -> Error(NotEnoughStock(line.BookId, 0)) + | Some _, Some _ when line.Quantity <= 0 -> Error(InvalidQuantity line.BookId) + | Some book, Some stock when line.Quantity <= available stock -> + let priced = priceLine book line + + let updatedStock = { + stock with + Reserved = stock.Reserved + line.Quantity + } + + let updatedInventory = inventory |> Map.add line.BookId updatedStock + Ok(priced, updatedInventory) + | Some _, Some stock -> Error(NotEnoughStock(line.BookId, available stock)) + + let prepareLines catalog initialInventory (lines: CartLine list) = + lines + |> List.fold + (fun state line -> + result { + let! pricedLines, inventory = state + let! priced, nextInventory = prepareLine catalog inventory line + return priced :: pricedLines, nextInventory + }) + (Ok([], initialInventory)) + |> Result.map (fun (reversed, inventory) -> List.rev reversed, inventory) + + let subtotal lines = + lines |> List.fold (fun total line -> add total line.LineTotal) (Money 0) + + let discountFor customer (Money amount) = + match customer.Membership with + | Standard -> Money 0 + | Member percent -> Money(amount * percent / 100) + + let shippingCost method (Money amount) = + match method with + | Collection -> Money 0 + | StandardPost -> Money 500 + | ExpressPost when amount >= 5000 -> Money 0 + | ExpressPost -> Money 900 + + let placeOrder customerId (cart: Cart) shippingMethod (state: State) = + match state.Customers |> Map.tryFind customerId with + | None -> Error CustomerNotFound + | Some _ when cart.Lines = [] -> Error EmptyCart + | Some customer -> + prepareLines state.Catalog state.Inventory cart.Lines + |> Result.map (fun (lines, inventory) -> + let beforeDiscount = subtotal lines + let discount = discountFor customer beforeDiscount + let delivery = shippingCost shippingMethod beforeDiscount + let total = beforeDiscount |> subtract discount |> add delivery + let orderId = OrderId state.NextOrderId + + let order = { + Id = orderId + CustomerId = customer.Id + Lines = lines + Subtotal = beforeDiscount + Discount = discount + ShippingCost = delivery + Total = total + ShippingMethod = shippingMethod + Status = AwaitingPayment + } + + let nextState = { + state with + Inventory = inventory + Orders = state.Orders |> Map.add orderId order + NextOrderId = state.NextOrderId + 1 + } + + nextState, order) + +open Shop + +let book = { + Id = BookId 1 + Isbn = Isbn "9780807083697" + Title = "Kindred" + Authors = [ { Name = "Octavia E. Butler" } ] + Price = Money 1299 +} + +let customer = { + Id = CustomerId 1 + Name = "Ada" + Email = Some(EmailAddress "ada@example.org") + Membership = Member 10 +} + +let initial = { + Catalog = Map.ofList [ (book.Id, book) ] + Customers = Map.ofList [ (customer.Id, customer) ] + Inventory = Map.ofList [ (book.Id, { OnHand = 5; Reserved = 0 }) ] + Orders = Map.empty + NextOrderId = 1 +} + +let cart = { + Lines = [ { BookId = book.Id; Quantity = 2 } ] +} + +match placeOrder customer.Id cart StandardPost initial with +| Error error -> printfn "Order failed: %A" error +| Ok(updated, order) -> + printfn "Order: %A" order + printfn "Inventory: %A" updated.Inventory + +// Try an unavailable quantity, a standard customer, and a second cart line. diff --git a/public/documentation/putting-it-together/60-placing-orders.md b/public/documentation/putting-it-together/60-placing-orders.md new file mode 100644 index 0000000..d6ece7c --- /dev/null +++ b/public/documentation/putting-it-together/60-placing-orders.md @@ -0,0 +1,116 @@ +# Placing orders + +## What you will learn + +Catalog, customer, cart, inventory, pricing, and order values can cooperate in one immutable order-placement transformation that returns either an error or a complete successor state. + +## Familiar pieces now cooperate + +The shop has grown one concern at a time: catalog books, validated customers, inventory, carts, money, discounts, shipping, and order states. We will connect them through one workflow—placing an order—before adding payment, shipping, and reports in the next lessons. + +```fsharp +type Shop = + { + Catalog: Map + Customers: Map + Inventory: Map + Orders: Map + NextOrderId: int + } +``` + +The state stores authoritative facts. Search results, available quantities, discount amounts, and report groups are calculated when needed. + +## Place an order in explicit stages + +```text +customer ID + cart + shipping + shop +→ find customer +→ reject an empty cart +→ validate and price every line +→ reserve inventory +→ calculate subtotal, discount, and shipping +→ construct an awaiting-payment order +→ return the successor shop and order +``` + +Each stage either returns a value needed by the next stage or an explicit error. + +## Validate one line and reserve its stock + +```fsharp +let prepareLine catalog inventory line = + match Map.tryFind line.BookId catalog, Map.tryFind line.BookId inventory with + | None, _ -> Error (BookNotFound line.BookId) + | _, None -> Error (NotEnoughStock (line.BookId, 0)) + | Some _, Some _ when line.Quantity <= 0 -> + Error (InvalidQuantity line.BookId) + | Some book, Some stock when line.Quantity <= available stock -> + let priced = priceLine book line + let updatedStock = { stock with Reserved = stock.Reserved + line.Quantity } + let updatedInventory = inventory |> Map.add line.BookId updatedStock + Ok (priced, updatedInventory) + | Some _, Some stock -> + Error (NotEnoughStock (line.BookId, available stock)) +``` + +The result contains both consequences that must remain together: the accepted price snapshot and successor inventory. + +## Fold while carrying Result and inventory + +```fsharp +let prepareLines catalog initialInventory lines = + lines + |> List.fold + (fun state line -> + result { + let! pricedLines, inventory = state + let! priced, nextInventory = + prepareLine catalog inventory line + return priced :: pricedLines, nextInventory + }) + (Ok ([], initialInventory)) + |> Result.map (fun (reversed, inventory) -> + List.rev reversed, inventory) +``` + +The computation expression replaces the nested bind and map callbacks inside +the fold. Each line sees inventory already reserved by earlier lines. This +matters if a manually constructed cart contains the same book twice. After the +first failure, the fold still visits the remaining list cells, but `let!` skips +their preparation. No partially updated state escapes because every update +exists only inside the Result accumulator. + +## Calculate policy after validation + +```fsharp +let subtotal = pricedLines |> List.fold addLineTotal (Money 0) +let discount = calculateDiscount customer subtotal +let shipping = shippingCost shippingMethod subtotal +let total = subtotal |> subtract discount |> add shipping +``` + +These are pure calculations over validated values. The order snapshots all three amounts so later catalog or membership changes cannot rewrite history. + +## Return the successor and the created value + +```text +placeOrder : CustomerId -> Cart -> ShippingMethod -> Shop + -> Result +``` + +The caller needs the successor shop for later commands and the created order for display or payment. Returning a tuple keeps both related outcomes together. + +On `Error`, no successor `Shop` is returned and the original value remains unchanged. On `Ok`, the returned shop contains both the inventory reservation and the new order. This is an all-or-error property of the pure value transformation, not a database transaction or concurrency guarantee. + +## Experiment + +- Place the sample order and inspect reserved inventory. +- Request more copies than are available. +- Use a missing customer or book ID. +- Add a second cart line and trace the fold accumulator. +- Change membership and compare discount and total. + +## Summary + +A substantial workflow can still be built from small transformations. Here, immutable state and Result ensure that callers receive either a complete successor value or an error, without hidden mutation. diff --git a/public/documentation/putting-it-together/61-payment-and-cancellation.fsx b/public/documentation/putting-it-together/61-payment-and-cancellation.fsx new file mode 100644 index 0000000..cc594a3 --- /dev/null +++ b/public/documentation/putting-it-together/61-payment-and-cancellation.fsx @@ -0,0 +1,111 @@ +type OrderId = OrderId of int +type CustomerId = CustomerId of int +type BookId = BookId of int +type Money = Money of int + +type ShippingMethod = + | Collection + | StandardPost + | ExpressPost + +type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money +} + +type PaymentMethod = + | Card of lastFourDigits: string + | GiftCard of code: string + +type Payment = { + Method: PaymentMethod + PaidOnDay: int +} + +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Cancelled of reason: string + +// This is the same order shape produced by the placement lesson. +// Only Status has grown to represent later lifecycle facts. +type Order = { + Id: OrderId + CustomerId: CustomerId + Lines: PricedLine list + Subtotal: Money + Discount: Money + ShippingCost: Money + Total: Money + ShippingMethod: ShippingMethod + Status: OrderStatus +} + +type State = { Orders: Map } + +type TransitionError = + | OrderNotFound + | AlreadyPaid + | OrderAlreadyCancelled + +let pay payment order = + match order.Status with + | AwaitingPayment -> Ok { order with Status = Paid payment } + | Paid _ -> Error AlreadyPaid + | Cancelled _ -> Error OrderAlreadyCancelled + +let cancel reason order = + match order.Status with + | AwaitingPayment -> Ok { order with Status = Cancelled reason } + | Paid _ -> Error AlreadyPaid + | Cancelled _ -> Error OrderAlreadyCancelled + +let updateOrder orderId transition state = + match state.Orders |> Map.tryFind orderId with + | None -> Error OrderNotFound + | Some order -> + transition order + |> Result.map (fun updatedOrder -> { + state with + Orders = state.Orders |> Map.add orderId updatedOrder + }) + +let orderId = OrderId 1 + +let order = { + Id = orderId + CustomerId = CustomerId 1 + Lines = [ + { + BookId = BookId 1 + Title = "Kindred" + Quantity = 2 + UnitPrice = Money 1299 + LineTotal = Money 2598 + } + ] + Subtotal = Money 2598 + Discount = Money 259 + ShippingCost = Money 500 + Total = Money 2839 + ShippingMethod = StandardPost + Status = AwaitingPayment +} + +let initial = { + Orders = Map.ofList [ (orderId, order) ] +} + +let payment = { Method = Card "4242"; PaidOnDay = 20 } + +let paid = initial |> updateOrder orderId (pay payment) +let cancelled = initial |> updateOrder orderId (cancel "customer request") + +printfn "Paid history: %A" paid +printfn "Cancelled history: %A" cancelled +printfn "Pay twice: %A" (paid |> Result.bind (updateOrder orderId (pay payment))) + +// Predict each Result, run it, then try cancelling the paid history. diff --git a/public/documentation/putting-it-together/61-payment-and-cancellation.md b/public/documentation/putting-it-together/61-payment-and-cancellation.md new file mode 100644 index 0000000..bcf5162 --- /dev/null +++ b/public/documentation/putting-it-together/61-payment-and-cancellation.md @@ -0,0 +1,133 @@ +# Payment and cancellation + +## What you will learn + +Represent order commands as checked state transitions that preserve the facts +needed by later transitions. + +## Placement is only the beginning + +The previous lesson created an order in `AwaitingPayment`. A boolean such as +`IsPaid` cannot explain when payment happened or prevent contradictory flag +combinations. The order status should describe its current lifecycle state: + +```fsharp +type PaymentMethod = + | Card of lastFourDigits: string + | GiftCard of code: string + +type Payment = + { + Method: PaymentMethod + PaidOnDay: int + } + +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Cancelled of reason: string +``` + +The `Paid` case carries the payment information because that information exists +only after payment. `Cancelled` carries the reason for the same reason. + +## A transition receives the current value + +```fsharp +type TransitionError = + | AlreadyPaid + | OrderAlreadyCancelled + +let pay payment order = + match order.Status with + | AwaitingPayment -> + Ok { order with Status = Paid payment } + | Paid _ -> + Error AlreadyPaid + | Cancelled _ -> + Error OrderAlreadyCancelled +``` + +Every branch explains one current state. Success returns a complete successor +order. Failure returns no successor and leaves the original value untouched. + +Notice what the type does and does not guarantee. `OrderStatus` prevents one +status value from being both paid and cancelled. It does not prevent arbitrary +code from constructing `Paid` directly while the union cases remain public. +Keeping transitions in a module and exposing a smaller public surface can make +the intended route clearer. + +## Cancellation has different rules + +```fsharp +let cancel reason order = + match order.Status with + | AwaitingPayment -> + Ok { order with Status = Cancelled reason } + | Paid _ -> + Error AlreadyPaid + | Cancelled _ -> + Error OrderAlreadyCancelled +``` + +This teaching domain refuses cancellation after payment because refunds have +not been modeled. A real domain might introduce `RefundPending`, `Refunded`, or +a separate refund workflow. The union should reflect the policy the program +actually implements, not an imagined universal order lifecycle. + +## Update an order inside shop state + +Orders are stored in a map, so the coordinating function first finds the order, +then applies the small transition, then stores the successor: + +```fsharp +let updateOrder orderId transition state = + match state.Orders |> Map.tryFind orderId with + | None -> Error OrderNotFound + | Some order -> + transition order + |> Result.map (fun updatedOrder -> + { + state with + Orders = state.Orders |> Map.add orderId updatedOrder + }) +``` + +`updateOrder` is higher-order: the caller supplies the transition. Payment can +partially apply `pay payment`; cancellation can partially apply +`cancel reason`. + +```fsharp +let payOrder orderId payment state = + updateOrder orderId (pay payment) state +``` + +The lookup concern and lifecycle rule remain separate, and both failures stay +explicit in `Result`. + +## Trace before running + +Starting from `AwaitingPayment`: + +1. `payOrder` returns a state containing `Paid payment`. +2. Paying that returned state again produces `AlreadyPaid`. +3. Cancelling the original state succeeds because immutable values let both + possible histories be explored independently. + +That third point does not imply that a deployed application should accept two +concurrent histories. It shows only that pure transition functions do not +destroy their inputs. + +## Experiment + +- Pay the awaiting order and inspect the stored payment. +- Attempt to pay the returned state twice. +- Cancel the original unpaid state. +- Add a `PaymentDeclined` error and decide which function should produce it. +- Introduce a refund state before allowing cancellation of a paid order. + +## Summary + +Lifecycle unions record meaningful states and their associated facts. Transition +functions accept a current value and return either a complete successor or a +domain error, while a coordinator updates the surrounding map. diff --git a/public/documentation/putting-it-together/61-shipping-and-reports.fsx b/public/documentation/putting-it-together/61-shipping-and-reports.fsx new file mode 100644 index 0000000..89da03b --- /dev/null +++ b/public/documentation/putting-it-together/61-shipping-and-reports.fsx @@ -0,0 +1,129 @@ +type OrderId = OrderId of int +type CustomerId = CustomerId of int +type BookId = BookId of int +type Money = Money of int + +module Money = + let zero = Money 0 + let add (Money left) (Money right) = Money(left + right) + +type ShippingMethod = + | Collection + | StandardPost + | ExpressPost + +type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money +} + +type PaymentMethod = + | Card of lastFourDigits: string + | GiftCard of code: string + +type Payment = { + Method: PaymentMethod + PaidOnDay: int +} + +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Shipped of payment: Payment * trackingNumber: string + | Cancelled of reason: string + +// The pricing and customer fields from placement remain available. +type Order = { + Id: OrderId + CustomerId: CustomerId + Lines: PricedLine list + Subtotal: Money + Discount: Money + ShippingCost: Money + Total: Money + ShippingMethod: ShippingMethod + Status: OrderStatus +} + +type State = { Orders: Map } + +type ShippingError = + | PaymentRequired + | OrderClosed + +let ship trackingNumber order = + match order.Status with + | Paid payment -> + Ok { + order with + Status = Shipped(payment, trackingNumber) + } + | AwaitingPayment -> Error PaymentRequired + | Shipped _ + | Cancelled _ -> Error OrderClosed + +type StatusCategory = + | AwaitingPaymentCategory + | PaidCategory + | ShippedCategory + | CancelledCategory + +let statusCategory status = + match status with + | AwaitingPayment -> AwaitingPaymentCategory + | Paid _ -> PaidCategory + | Shipped _ -> ShippedCategory + | Cancelled _ -> CancelledCategory + +let allOrders state = + state.Orders |> Map.toList |> List.map (fun (_, order) -> order) + +let orderCountsByStatus state = + state + |> allOrders + |> List.groupBy (fun order -> statusCategory order.Status) + |> List.map (fun (category, orders) -> category, List.length orders) + +let revenue state = + state + |> allOrders + |> List.filter (fun order -> + match order.Status with + | Paid _ + | Shipped _ -> true + | AwaitingPayment + | Cancelled _ -> false) + |> List.fold (fun total order -> Money.add total order.Total) Money.zero + +let makeOrder id status = { + Id = OrderId id + CustomerId = CustomerId 1 + Lines = [] + Subtotal = Money 3200 + Discount = Money 0 + ShippingCost = Money 500 + Total = Money 3700 + ShippingMethod = StandardPost + Status = status +} + +let payment = { Method = Card "4242"; PaidOnDay = 20 } +let awaiting = makeOrder 1 AwaitingPayment +let paid = makeOrder 2 (Paid payment) + +match ship "TRACK-100" paid with +| Error error -> printfn "Unexpected shipping failure: %A" error +| Ok shipped -> + let state = { + Orders = Map.ofList [ (awaiting.Id, awaiting); (shipped.Id, shipped) ] + } + + printfn "Counts: %A" (orderCountsByStatus state) + printfn "Revenue: %A" (revenue state) + +printfn "Ship unpaid: %A" (ship "TRACK-101" awaiting) + +// Add a cancelled order, predict the report, then run it. diff --git a/public/documentation/putting-it-together/61-shipping-and-reports.md b/public/documentation/putting-it-together/61-shipping-and-reports.md new file mode 100644 index 0000000..0c29ac0 --- /dev/null +++ b/public/documentation/putting-it-together/61-shipping-and-reports.md @@ -0,0 +1,128 @@ +# Shipping orders and deriving reports + +## What you will learn + +Complete the order lifecycle, then derive useful summaries from immutable shop +state without storing duplicate facts. + +## Shipping must preserve payment + +A shipped order still needs its payment history. Carry that value into the next +status instead of replacing it with a bare `Shipped` flag: + +The `PaymentMethod`, `Payment`, and pricing fields remain exactly as they were +in the previous lesson. The model grows by adding one status case; shipping +does not discard or redefine facts the order already contains. + +```fsharp +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Shipped of payment: Payment * trackingNumber: string + | Cancelled of reason: string +``` + +The shipping transition accepts only a paid order: + +```fsharp +type ShippingError = + | PaymentRequired + | OrderClosed + +let ship trackingNumber order = + match order.Status with + | Paid payment -> + Ok { order with Status = Shipped (payment, trackingNumber) } + | AwaitingPayment -> + Error PaymentRequired + | Shipped _ + | Cancelled _ -> + Error OrderClosed +``` + +Two patterns can share one result when their handling is identical. The line +beginning with `| Cancelled _` continues the pattern alternative; it is not a +new result branch. + +This version accepts blank tracking text because that validation is not yet +encoded. A validated `TrackingNumber` wrapper would move the rule to the input +boundary exactly as earlier wrappers did for IDs and email addresses. + +## Reports are queries, not stored state + +Suppose the shop stores orders in `Map`. A report begins by +deriving the current values: + +```fsharp +let allOrders state = + state.Orders + |> Map.toList + |> List.map (fun (_, order) -> order) +``` + +Do not also store `PaidOrderCount`, `CancelledOrderCount`, and +`ShippedOrderCount` in the state. Those fields could drift away from the order +map. Derive them when needed. + +## Group by a reporting category + +Payload values make complete statuses unsuitable as report keys: two `Paid` +values containing different payment days are different values. First classify +each status into a payload-free category: + +```fsharp +type StatusCategory = + | AwaitingPaymentCategory + | PaidCategory + | ShippedCategory + | CancelledCategory + +let statusCategory status = + match status with + | AwaitingPayment -> AwaitingPaymentCategory + | Paid _ -> PaidCategory + | Shipped _ -> ShippedCategory + | Cancelled _ -> CancelledCategory +``` + +Then group and count: + +```fsharp +let orderCountsByStatus state = + state + |> allOrders + |> List.groupBy (fun order -> statusCategory order.Status) + |> List.map (fun (category, orders) -> category, List.length orders) +``` + +The report is a derived list. Running it twice does not modify the state. + +## Fold a monetary summary + +```fsharp +let revenue orders = + orders + |> List.filter (fun order -> + match order.Status with + | Paid _ + | Shipped _ -> true + | AwaitingPayment + | Cancelled _ -> false) + |> List.fold (fun total order -> Money.add total order.Total) Money.zero +``` + +The business question determines which states count as revenue. If payment can +later be refunded, the model and report must change together. + +## Experiment + +- Try shipping an awaiting-payment order. +- Ship a paid order and confirm its payment remains available. +- Add orders in all four states and predict the grouped counts. +- Change the revenue rule to count only shipped orders. +- Introduce a validated tracking-number wrapper. + +## Summary + +Shipping is another explicit state transition. Reports classify, filter, group, +and fold authoritative order values; they do not create competing stored facts. diff --git a/public/documentation/putting-it-together/62-final-capstone.fsx b/public/documentation/putting-it-together/62-final-capstone.fsx new file mode 100644 index 0000000..a4cf81c --- /dev/null +++ b/public/documentation/putting-it-together/62-final-capstone.fsx @@ -0,0 +1,505 @@ +module Bookshop = + type InputError = InputError of string + + let private required name (text: string) = + let cleaned = text.Trim() + + if cleaned = "" then + Error(InputError($"%s{name} must not be blank")) + else + Ok cleaned + + type BookId = private BookId of int + + module BookId = + let create value = + if value > 0 then + Ok(BookId value) + else + Error(InputError "book ID must be positive") + + type CustomerId = private CustomerId of int + + module CustomerId = + let create value = + if value > 0 then + Ok(CustomerId value) + else + Error(InputError "customer ID must be positive") + + type OrderId = private OrderId of int + + module OrderId = + let create value = + if value > 0 then + Ok(OrderId value) + else + Error(InputError "order ID must be positive") + + type Isbn = private Isbn of string + + module Isbn = + let create (text: string) = + required "ISBN" text + |> Result.bind (fun value -> + let hasExpectedLength = value.Length = 10 || value.Length = 13 + + let containsOnlyDigits = + value |> Seq.forall (fun character -> character >= '0' && character <= '9') + + if hasExpectedLength && containsOnlyDigits then + Ok(Isbn value) + else + Error(InputError "ISBN must contain 10 or 13 digits")) + + type EmailAddress = private EmailAddress of string + + module EmailAddress = + let create text = + required "email address" text + |> Result.bind (fun value -> + if value.Contains("@") && not (value.Contains(" ")) then + Ok(EmailAddress value) + else + Error(InputError "email address must contain @ and no spaces")) + + type Money = private Money of int + + module Money = + let zero = Money 0 + + let create cents = + if cents >= 0 then + Ok(Money cents) + else + Error(InputError "money must not be negative") + + let add (Money left) (Money right) = Money(left + right) + let multiply quantity (Money amount) = Money(quantity * amount) + + type DiscountPercent = private DiscountPercent of int + + module DiscountPercent = + let create value = + if value >= 0 && value <= 100 then + Ok(DiscountPercent value) + else + Error(InputError "discount must be between 0 and 100") + + let discountAmount (DiscountPercent percent) (Money amount) = Money(amount * percent / 100) + + let apply (DiscountPercent percent) (Money amount) = Money(amount - amount * percent / 100) + + type LastFourDigits = private LastFourDigits of string + + module LastFourDigits = + let create (text: string) = + let containsOnlyDigits = + text |> Seq.forall (fun character -> character >= '0' && character <= '9') + + if text.Length = 4 && containsOnlyDigits then + Ok(LastFourDigits text) + else + Error(InputError "card detail must contain exactly four digits") + + type TrackingNumber = private TrackingNumber of string + + module TrackingNumber = + let create text = + required "tracking number" text |> Result.map TrackingNumber + + type Genre = + | Fiction + | History + | Science + + type Author = { Name: string } + + type Book = { + Id: BookId + Isbn: Isbn + Title: string + Authors: Author list + Genres: Set + Price: Money + } + + type Membership = + | Standard + | Member of DiscountPercent + + type Customer = { + Id: CustomerId + Name: string + Email: EmailAddress option + Membership: Membership + } + + type Stock = private { OnHand: int; Reserved: int } + + module Stock = + let create onHand = + if onHand >= 0 then + Ok { OnHand = onHand; Reserved = 0 } + else + Error(InputError "stock must not be negative") + + let available stock = stock.OnHand - stock.Reserved + + let reserve quantity stock = + if quantity > 0 && quantity <= available stock then + Some { + stock with + Reserved = stock.Reserved + quantity + } + else + None + + let release quantity stock = { + stock with + Reserved = stock.Reserved - quantity + } + + type CartLine = { BookId: BookId; Quantity: int } + + type Cart = { + CustomerId: CustomerId + Lines: CartLine list + } + + type ShippingMethod = + | Collection + | StandardPost + | ExpressPost + + type Payment = { Card: LastFourDigits } + + type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Shipped of payment: Payment * trackingNumber: TrackingNumber + | Cancelled of reason: string + + type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money + } + + type Order = { + Id: OrderId + CustomerId: CustomerId + Lines: PricedLine list + Subtotal: Money + Discount: Money + ShippingCost: Money + Total: Money + ShippingMethod: ShippingMethod + Status: OrderStatus + } + + type State = { + Catalog: Map + Customers: Map + Inventory: Map + Orders: Map + NextOrderId: int + } + + type ShopError = + | CustomerNotFound + | OrderNotFound + | EmptyCart + | BookNotFound of BookId + | InvalidQuantity of BookId + | NotEnoughStock of BookId * available: int + | AlreadyPaid + | PaymentRequired + | OrderClosed + | InvalidCancellationReason + | InvalidState of InputError + + let searchCatalog (query: string) (state: State) = + let normalized = query.Trim().ToLowerInvariant() + + state.Catalog + |> Map.toList + |> List.map (fun (_, book) -> book) + |> List.filter (fun book -> book.Title.ToLowerInvariant().Contains(normalized)) + + let booksInGenre genre state = + state.Catalog + |> Map.toList + |> List.map (fun (_, book) -> book) + |> List.filter (fun book -> book.Genres |> Set.contains genre) + + let lowStock threshold state = + state.Inventory + |> Map.toList + |> List.choose (fun (bookId, stock) -> + let quantity = Stock.available stock + + if quantity <= threshold then + Some(bookId, quantity) + else + None) + + let addToCart bookId quantity (cart: Cart) = + if quantity <= 0 then + Error(InvalidQuantity bookId) + else + let previousQuantity = + cart.Lines + |> List.tryFind (fun line -> line.BookId = bookId) + |> Option.map (fun line -> line.Quantity) + |> Option.defaultValue 0 + + let otherLines = cart.Lines |> List.filter (fun line -> line.BookId <> bookId) + + let line: CartLine = { + BookId = bookId + Quantity = previousQuantity + quantity + } + + Ok { cart with Lines = line :: otherLines } + + let private priceLine (book: Book) (line: CartLine) : PricedLine = { + BookId = book.Id + Title = book.Title + Quantity = line.Quantity + UnitPrice = book.Price + LineTotal = Money.multiply line.Quantity book.Price + } + + let private prepareLine (catalog: Map) (inventory: Map) (line: CartLine) = + match Map.tryFind line.BookId catalog, Map.tryFind line.BookId inventory with + | None, _ -> Error(BookNotFound line.BookId) + | _, None -> Error(NotEnoughStock(line.BookId, 0)) + | Some _, Some _ when line.Quantity <= 0 -> Error(InvalidQuantity line.BookId) + | Some book, Some stock -> + match Stock.reserve line.Quantity stock with + | None -> Error(NotEnoughStock(line.BookId, Stock.available stock)) + | Some reserved -> + let inventory = inventory |> Map.add line.BookId reserved + Ok(priceLine book line, inventory) + + let private prepareLines catalog inventory (lines: CartLine list) = + lines + |> List.fold + (fun result line -> + result + |> Result.bind (fun (priced, currentInventory) -> + prepareLine catalog currentInventory line + |> Result.map (fun (nextLine, nextInventory) -> nextLine :: priced, nextInventory))) + (Ok([], inventory)) + |> Result.map (fun (reversed, nextInventory) -> List.rev reversed, nextInventory) + + let private subtotal lines = + lines |> List.fold (fun total line -> Money.add total line.LineTotal) Money.zero + + let private discountFor customer amount = + match customer.Membership with + | Standard -> Money.zero, amount + | Member percent -> DiscountPercent.discountAmount percent amount, DiscountPercent.apply percent amount + + let private shippingCost method (Money subtotal) = + match method with + | Collection -> Money.zero + | StandardPost -> Money 500 + | ExpressPost when subtotal >= 5000 -> Money.zero + | ExpressPost -> Money 900 + + let placeOrder shippingMethod (cart: Cart) (state: State) = + match state.Customers |> Map.tryFind cart.CustomerId with + | None -> Error CustomerNotFound + | Some _ when cart.Lines = [] -> Error EmptyCart + | Some customer -> + prepareLines state.Catalog state.Inventory cart.Lines + |> Result.bind (fun (lines, inventory) -> + let beforeDiscount = subtotal lines + let discount, afterDiscount = discountFor customer beforeDiscount + let delivery = shippingCost shippingMethod beforeDiscount + + match OrderId.create state.NextOrderId with + | Error inputError -> Error(InvalidState inputError) + | Ok orderId -> + let order = { + Id = orderId + CustomerId = customer.Id + Lines = lines + Subtotal = beforeDiscount + Discount = discount + ShippingCost = delivery + Total = Money.add afterDiscount delivery + ShippingMethod = shippingMethod + Status = AwaitingPayment + } + + Ok( + { + state with + Inventory = inventory + Orders = state.Orders |> Map.add orderId order + NextOrderId = state.NextOrderId + 1 + }, + order + )) + + let private updateOrder orderId transition state = + match state.Orders |> Map.tryFind orderId with + | None -> Error OrderNotFound + | Some order -> + transition order + |> Result.map (fun updated -> { + state with + Orders = state.Orders |> Map.add orderId updated + }) + + let pay orderId payment state = + let transition order = + match order.Status with + | AwaitingPayment -> Ok { order with Status = Paid payment } + | Paid _ -> Error AlreadyPaid + | Shipped _ + | Cancelled _ -> Error OrderClosed + + updateOrder orderId transition state + + let ship orderId trackingNumber state = + let transition order = + match order.Status with + | Paid payment -> + Ok { + order with + Status = Shipped(payment, trackingNumber) + } + | AwaitingPayment -> Error PaymentRequired + | Shipped _ + | Cancelled _ -> Error OrderClosed + + updateOrder orderId transition state + + let cancel orderId (reason: string) (state: State) = + if reason.Trim() = "" then + Error InvalidCancellationReason + else + match state.Orders |> Map.tryFind orderId with + | None -> Error OrderNotFound + | Some order -> + match order.Status with + | AwaitingPayment -> + let inventory = + order.Lines + |> List.fold + (fun current line -> + current |> Map.change line.BookId (Option.map (Stock.release line.Quantity))) + state.Inventory + + let cancelled = { + order with + Status = Cancelled(reason.Trim()) + } + + Ok { + state with + Inventory = inventory + Orders = state.Orders |> Map.add orderId cancelled + } + | Paid _ + | Shipped _ + | Cancelled _ -> Error OrderClosed + + type StatusCategory = + | AwaitingCategory + | PaidCategory + | ShippedCategory + | CancelledCategory + + let private statusCategory order = + match order.Status with + | AwaitingPayment -> AwaitingCategory + | Paid _ -> PaidCategory + | Shipped _ -> ShippedCategory + | Cancelled _ -> CancelledCategory + + let private allOrders state = + state.Orders |> Map.toList |> List.map (fun (_, order) -> order) + + let orderCounts state = + state + |> allOrders + |> List.groupBy statusCategory + |> List.map (fun (status, orders) -> status, List.length orders) + + let revenue state = + state + |> allOrders + |> List.filter (fun order -> + match order.Status with + | Paid _ + | Shipped _ -> true + | AwaitingPayment + | Cancelled _ -> false) + |> List.fold (fun total order -> Money.add total order.Total) Money.zero + +open Bookshop + +let expect label result = + match result with + | Ok value -> value + | Error error -> failwith $"%s{label}: %A{error}" + +let bookId = BookId.create 1 |> expect "sample book ID" +let customerId = CustomerId.create 1 |> expect "sample customer ID" +let isbn = Isbn.create "9780807083697" |> expect "sample ISBN" +let email = EmailAddress.create "ada@example.org" |> expect "sample email" +let price = Money.create 1299 |> expect "sample price" +let discount = DiscountPercent.create 10 |> expect "sample discount" +let stock = Stock.create 5 |> expect "sample stock" +let card = LastFourDigits.create "4242" |> expect "sample card detail" +let tracking = TrackingNumber.create "TRACK-100" |> expect "sample tracking number" + +let book = { + Id = bookId + Isbn = isbn + Title = "Kindred" + Authors = [ { Name = "Octavia E. Butler" } ] + Genres = Set.ofList [ Fiction; History ] + Price = price +} + +let customer = { + Id = customerId + Name = "Ada" + Email = Some email + Membership = Member discount +} + +let initial = { + Catalog = Map.ofList [ (book.Id, book) ] + Customers = Map.ofList [ (customer.Id, customer) ] + Inventory = Map.ofList [ (book.Id, stock) ] + Orders = Map.empty + NextOrderId = 1 +} + +let emptyCart = { CustomerId = customerId; Lines = [] } +let payment = { Card = card } + +let completed = + addToCart bookId 2 emptyCart + |> Result.bind (fun cart -> placeOrder StandardPost cart initial) + |> Result.bind (fun (placed, order) -> pay order.Id payment placed |> Result.bind (ship order.Id tracking)) + +match completed with +| Error error -> printfn "Workflow failed: %A" error +| Ok finalState -> + printfn "Search: %A" (searchCatalog "kind" finalState) + printfn "History books: %A" (booksInGenre History finalState) + printfn "Low stock: %A" (lowStock 3 finalState) + printfn "Order counts: %A" (orderCounts finalState) + printfn "Revenue: %A" (revenue finalState) + +// Predict the result, run it, then try an invalid quantity and a cancelled unpaid order. diff --git a/public/documentation/putting-it-together/62-final-capstone.md b/public/documentation/putting-it-together/62-final-capstone.md new file mode 100644 index 0000000..12ac113 --- /dev/null +++ b/public/documentation/putting-it-together/62-final-capstone.md @@ -0,0 +1,187 @@ +# Final capstone: the functional bookshop + +## What you will learn + +Read, trace, and change one program that connects the domain types, validation, +collections, immutable state, and order transitions developed throughout the +course. + +## This program grew rather than appeared + +The playground is longer than earlier examples, but its ideas are familiar. It +combines the same pieces you have already used: + +1. constrained values at input boundaries; +2. records for books, customers, carts, stock, and orders; +3. unions for alternatives and lifecycle states; +4. maps and sets for the catalog, inventory, orders, and genres; +5. options for contact information and lookups; +6. results for validation and business refusal; +7. folds for pricing, reservation, release, and reporting; +8. pure state transitions for placement, payment, cancellation, and shipping. + +Read one section at a time. The function signatures are the seams between them. + +## Validate primitive input once + +An `int` can represent a quantity, a price, or three different kinds of ID. A +`string` can represent an ISBN, email address, card detail, or tracking number. +The capstone gives those values different types and puts construction rules next +to them: + +```fsharp +type DiscountPercent = private DiscountPercent of int + +module DiscountPercent = + let create value = + if value >= 0 && value <= 100 then + Ok (DiscountPercent value) + else + Error (InputError "discount must be between 0 and 100") +``` + +Code outside the defining module cannot construct the private case directly. It +must handle the `Result` from `create`. Once creation succeeds, pricing code can +rely on the range without checking it again. + +The validators are deliberately modest. The email rule checks a useful local +shape; it does not claim to decide whether an address exists. The ISBN rule +checks length and digits, not the ISBN checksum. A type should promise exactly +what its constructor establishes. + +The ISBN constructor uses `Seq.forall` to check every character. It has the same +all-elements meaning as `List.forall`, but accepts any sequence, including the +characters produced by a string. + +## Keep invalid stock out of the model + +`Stock.create` rejects a negative on-hand quantity. The record case is private, +so callers cannot bypass that entry point. Reservation returns `Some` only when +the requested quantity is positive and available: + +```text +Stock.reserve : int -> Stock -> Stock option +``` + +The stock operation does not know about checkout errors. `prepareLine` gives a +failed reservation its domain meaning by returning `NotEnoughStock`. This keeps +the small stock type reusable while the workflow still reports a precise error. + +## Query the catalog without changing it + +`searchCatalog`, `booksInGenre`, and `lowStock` derive answers from the current +state. They demonstrate three different collection questions: + +- filter books whose normalized titles contain a query; +- keep books whose genre set contains a value; +- choose only inventory entries whose available quantity is low. + +None of these answers is stored alongside the source maps. Storing both would +create two facts that could disagree. + +## Turn cart intent into an accepted order + +The cart contains requests. The order contains accepted facts. Placement crosses +that boundary: + +```text +cart + shipping method + current state +→ find the customer +→ reject an empty cart +→ find, validate, price, and reserve every line +→ calculate discount and shipping +→ construct an awaiting-payment order +→ return the successor state and new order +``` + +`prepareLines` carries a `Result` containing both the priced lines and the +successor inventory. Each reservation therefore sees reservations made for +earlier lines. If any line fails, no successor `State` escapes. + +The order snapshots title, unit price, discount, shipping cost, and total. A +later catalog price change should not rewrite an order already accepted. + +## Model money operations at the level of the rule + +The earlier version exposed a subtraction function that silently clamped a +negative answer to zero. That made an invalid calculation look valid. The final +model instead exposes the operations the policy needs: + +```fsharp +DiscountPercent.discountAmount percent subtotal +DiscountPercent.apply percent subtotal +``` + +Because a `DiscountPercent` is between 0 and 100, applying it cannot make a +non-negative `Money` value negative. The useful invariant follows from the input +types rather than a hidden correction inside arithmetic. + +This course models money as whole minor units, such as cents, in one implicit +currency. Multi-currency arithmetic and rounding policies would require more +domain information. + +## Preserve errors through the whole scenario + +The sample does not replace errors with an empty cart or an earlier order. Each +step feeds success into the next step with `Result.bind`: + +```fsharp +addToCart bookId 2 emptyCart +|> Result.bind (fun cart -> placeOrder StandardPost cart initial) +|> Result.bind (fun (placed, order) -> + pay order.Id payment placed + |> Result.bind (ship order.Id tracking)) +``` + +The final match prints either the first error or reports over the shipped state. +No branch pretends that failure was success. + +The `expect` helper used while creating fixed sample data is different. Invalid +hard-coded seed data is a programmer mistake in this playground, so the helper +raises an exception with context. Cart, placement, and lifecycle failures are +expected domain outcomes and remain `Result` values. + +## Cancellation restores reserved stock + +Cancellation is allowed only while payment is pending. It updates the order and +releases every reserved line in the same returned state. Returning only the +cancelled order would leave inventory inconsistent. + +This is still a teaching policy. A larger shop might allow cancellation after +payment through a refund workflow. The union and transition would then grow to +represent those additional states explicitly. + +## Trace before running + +Predict these values before pressing Run: + +1. the cart contains one line with quantity two; +2. placement changes available stock from five to three; +3. payment changes only the stored status; +4. shipping preserves payment and adds a tracking number; +5. low-stock reporting includes the book at threshold three; +6. revenue includes the shipped order; +7. status counts contain one shipped order. + +Then compare your prediction with the printed values. If one differs, locate the +smallest function responsible before changing the code. + +## Experiment + +- Predict the `ShopError`, then request zero copies and run the program. +- Request six copies. Confirm that no successor state is returned. +- Stop after placement, cancel the order, and inspect released inventory. +- Try a blank tracking number at the construction boundary. +- Add a second science book and check search, genre, and low-stock queries. +- Change the customer to `Standard` and calculate the expected total by hand. +- Deliberately pass a `CustomerId` where a `BookId` is required. Read the two + types named by the compiler, then repair the call. + +## Summary + +The finished bookshop is a composition of ordinary F# ideas. Constrained values +establish trustworthy inputs; records and unions describe the domain; options +and results keep uncertainty visible; collection functions derive answers; and +pure transitions return complete successor states. The program is substantial +because these small pieces cooperate, not because the language changes at the +end. diff --git a/public/documentation/putting-it-together/62-shipping-and-reports.fsx b/public/documentation/putting-it-together/62-shipping-and-reports.fsx new file mode 100644 index 0000000..89da03b --- /dev/null +++ b/public/documentation/putting-it-together/62-shipping-and-reports.fsx @@ -0,0 +1,129 @@ +type OrderId = OrderId of int +type CustomerId = CustomerId of int +type BookId = BookId of int +type Money = Money of int + +module Money = + let zero = Money 0 + let add (Money left) (Money right) = Money(left + right) + +type ShippingMethod = + | Collection + | StandardPost + | ExpressPost + +type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money +} + +type PaymentMethod = + | Card of lastFourDigits: string + | GiftCard of code: string + +type Payment = { + Method: PaymentMethod + PaidOnDay: int +} + +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Shipped of payment: Payment * trackingNumber: string + | Cancelled of reason: string + +// The pricing and customer fields from placement remain available. +type Order = { + Id: OrderId + CustomerId: CustomerId + Lines: PricedLine list + Subtotal: Money + Discount: Money + ShippingCost: Money + Total: Money + ShippingMethod: ShippingMethod + Status: OrderStatus +} + +type State = { Orders: Map } + +type ShippingError = + | PaymentRequired + | OrderClosed + +let ship trackingNumber order = + match order.Status with + | Paid payment -> + Ok { + order with + Status = Shipped(payment, trackingNumber) + } + | AwaitingPayment -> Error PaymentRequired + | Shipped _ + | Cancelled _ -> Error OrderClosed + +type StatusCategory = + | AwaitingPaymentCategory + | PaidCategory + | ShippedCategory + | CancelledCategory + +let statusCategory status = + match status with + | AwaitingPayment -> AwaitingPaymentCategory + | Paid _ -> PaidCategory + | Shipped _ -> ShippedCategory + | Cancelled _ -> CancelledCategory + +let allOrders state = + state.Orders |> Map.toList |> List.map (fun (_, order) -> order) + +let orderCountsByStatus state = + state + |> allOrders + |> List.groupBy (fun order -> statusCategory order.Status) + |> List.map (fun (category, orders) -> category, List.length orders) + +let revenue state = + state + |> allOrders + |> List.filter (fun order -> + match order.Status with + | Paid _ + | Shipped _ -> true + | AwaitingPayment + | Cancelled _ -> false) + |> List.fold (fun total order -> Money.add total order.Total) Money.zero + +let makeOrder id status = { + Id = OrderId id + CustomerId = CustomerId 1 + Lines = [] + Subtotal = Money 3200 + Discount = Money 0 + ShippingCost = Money 500 + Total = Money 3700 + ShippingMethod = StandardPost + Status = status +} + +let payment = { Method = Card "4242"; PaidOnDay = 20 } +let awaiting = makeOrder 1 AwaitingPayment +let paid = makeOrder 2 (Paid payment) + +match ship "TRACK-100" paid with +| Error error -> printfn "Unexpected shipping failure: %A" error +| Ok shipped -> + let state = { + Orders = Map.ofList [ (awaiting.Id, awaiting); (shipped.Id, shipped) ] + } + + printfn "Counts: %A" (orderCountsByStatus state) + printfn "Revenue: %A" (revenue state) + +printfn "Ship unpaid: %A" (ship "TRACK-101" awaiting) + +// Add a cancelled order, predict the report, then run it. diff --git a/public/documentation/putting-it-together/62-shipping-and-reports.md b/public/documentation/putting-it-together/62-shipping-and-reports.md new file mode 100644 index 0000000..a7e82a3 --- /dev/null +++ b/public/documentation/putting-it-together/62-shipping-and-reports.md @@ -0,0 +1,128 @@ +# Shipping orders and deriving reports + +## What you will learn + +Complete the order lifecycle, then derive useful summaries from immutable shop +state without storing duplicate facts. + +## Shipping must preserve payment + +A shipped order still needs its payment history. Carry that value into the next +status instead of replacing it with a bare `Shipped` flag: + +The `PaymentMethod`, `Payment`, and pricing fields remain exactly as they were +in the previous lesson. The model grows by adding one status case; shipping +does not discard or redefine facts the order already contains. + +```fsharp +type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Shipped of payment: Payment * trackingNumber: string + | Cancelled of reason: string +``` + +The shipping transition accepts only a paid order: + +```fsharp +type ShippingError = + | PaymentRequired + | OrderClosed + +let ship trackingNumber order = + match order.Status with + | Paid payment -> + Ok { order with Status = Shipped (payment, trackingNumber) } + | AwaitingPayment -> + Error PaymentRequired + | Shipped _ + | Cancelled _ -> + Error OrderClosed +``` + +Two patterns can share one result when their handling is identical. The line +beginning with `| Cancelled _` continues the pattern alternative; it is not a +new result branch. + +This version accepts blank tracking string because that validation is not yet +encoded. A validated `TrackingNumber` wrapper would move the rule to the input +boundary exactly as earlier wrappers did for IDs and email addresses. + +## Reports are queries, not stored state + +Suppose the shop stores orders in `Map`. A report begins by +deriving the current values: + +```fsharp +let allOrders state = + state.Orders + |> Map.toList + |> List.map (fun (_, order) -> order) +``` + +Do not also store `PaidOrderCount`, `CancelledOrderCount`, and +`ShippedOrderCount` in the state. Those fields could drift away from the order +map. Derive them when needed. + +## Group by a reporting category + +Payload values make complete statuses unsuitable as report keys: two `Paid` +values containing different payment days are different values. First classify +each status into a payload-free category: + +```fsharp +type StatusCategory = + | AwaitingPaymentCategory + | PaidCategory + | ShippedCategory + | CancelledCategory + +let statusCategory status = + match status with + | AwaitingPayment -> AwaitingPaymentCategory + | Paid _ -> PaidCategory + | Shipped _ -> ShippedCategory + | Cancelled _ -> CancelledCategory +``` + +Then group and count: + +```fsharp +let orderCountsByStatus state = + state + |> allOrders + |> List.groupBy (fun order -> statusCategory order.Status) + |> List.map (fun (category, orders) -> category, List.length orders) +``` + +The report is a derived list. Running it twice does not modify the state. + +## Fold a monetary summary + +```fsharp +let revenue orders = + orders + |> List.filter (fun order -> + match order.Status with + | Paid _ + | Shipped _ -> true + | AwaitingPayment + | Cancelled _ -> false) + |> List.fold (fun total order -> Money.add total order.Total) Money.zero +``` + +The business question determines which states count as revenue. If payment can +later be refunded, the model and report must change together. + +## Experiment + +- Try shipping an awaiting-payment order. +- Ship a paid order and confirm its payment remains available. +- Add orders in all four states and predict the grouped counts. +- Change the revenue rule to count only shipped orders. +- Introduce a validated tracking-number wrapper. + +## Summary + +Shipping is another explicit state transition. Reports classify, filter, group, +and fold authoritative order values; they do not create competing stored facts. diff --git a/public/documentation/putting-it-together/63-final-capstone.fsx b/public/documentation/putting-it-together/63-final-capstone.fsx new file mode 100644 index 0000000..c6bb8ae --- /dev/null +++ b/public/documentation/putting-it-together/63-final-capstone.fsx @@ -0,0 +1,518 @@ +module Bookshop = + type ResultBuilder() = + member _.Bind(result, next) = Result.bind next result + member _.Return(value) = Ok value + member _.ReturnFrom(result) = result + + let result = ResultBuilder() + + type InputError = InputError of string + + let private required name (text: string) = + let cleaned = text.Trim() + + if cleaned = "" then + Error(InputError($"%s{name} must not be blank")) + else + Ok cleaned + + type BookId = private BookId of int + + module BookId = + let create value = + if value > 0 then + Ok(BookId value) + else + Error(InputError "book ID must be positive") + + type CustomerId = private CustomerId of int + + module CustomerId = + let create value = + if value > 0 then + Ok(CustomerId value) + else + Error(InputError "customer ID must be positive") + + type OrderId = private OrderId of int + + module OrderId = + let create value = + if value > 0 then + Ok(OrderId value) + else + Error(InputError "order ID must be positive") + + type Isbn = private Isbn of string + + module Isbn = + let create (text: string) = + required "ISBN" text + |> Result.bind (fun value -> + let hasExpectedLength = value.Length = 10 || value.Length = 13 + + let containsOnlyDigits = + value |> Seq.forall (fun character -> character >= '0' && character <= '9') + + if hasExpectedLength && containsOnlyDigits then + Ok(Isbn value) + else + Error(InputError "ISBN must contain 10 or 13 digits")) + + type EmailAddress = private EmailAddress of string + + module EmailAddress = + let create text = + required "email address" text + |> Result.bind (fun value -> + if value.Contains("@") && not (value.Contains(" ")) then + Ok(EmailAddress value) + else + Error(InputError "email address must contain @ and no spaces")) + + type Money = private Money of int + + module Money = + let zero = Money 0 + + let create cents = + if cents >= 0 then + Ok(Money cents) + else + Error(InputError "money must not be negative") + + let add (Money left) (Money right) = Money(left + right) + let multiply quantity (Money amount) = Money(quantity * amount) + + type DiscountPercent = private DiscountPercent of int + + module DiscountPercent = + let create value = + if value >= 0 && value <= 100 then + Ok(DiscountPercent value) + else + Error(InputError "discount must be between 0 and 100") + + let discountAmount (DiscountPercent percent) (Money amount) = Money(amount * percent / 100) + + let apply (DiscountPercent percent) (Money amount) = Money(amount - amount * percent / 100) + + type LastFourDigits = private LastFourDigits of string + + module LastFourDigits = + let create (text: string) = + let containsOnlyDigits = + text |> Seq.forall (fun character -> character >= '0' && character <= '9') + + if text.Length = 4 && containsOnlyDigits then + Ok(LastFourDigits text) + else + Error(InputError "card detail must contain exactly four digits") + + type TrackingNumber = private TrackingNumber of string + + module TrackingNumber = + let create text = + required "tracking number" text |> Result.map TrackingNumber + + type Genre = + | Fiction + | History + | Science + + type Author = { Name: string } + + type Book = { + Id: BookId + Isbn: Isbn + Title: string + Authors: Author list + Genres: Set + Price: Money + } + + type Membership = + | Standard + | Member of DiscountPercent + + type Customer = { + Id: CustomerId + Name: string + Email: EmailAddress option + Membership: Membership + } + + type Stock = private { OnHand: int; Reserved: int } + + module Stock = + let create onHand = + if onHand >= 0 then + Ok { OnHand = onHand; Reserved = 0 } + else + Error(InputError "stock must not be negative") + + let available stock = stock.OnHand - stock.Reserved + + let reserve quantity stock = + if quantity > 0 && quantity <= available stock then + Some { + stock with + Reserved = stock.Reserved + quantity + } + else + None + + let release quantity stock = { + stock with + Reserved = stock.Reserved - quantity + } + + type CartLine = { BookId: BookId; Quantity: int } + + type Cart = { + CustomerId: CustomerId + Lines: CartLine list + } + + type ShippingMethod = + | Collection + | StandardPost + | ExpressPost + + type Payment = { Card: LastFourDigits } + + type OrderStatus = + | AwaitingPayment + | Paid of Payment + | Shipped of payment: Payment * trackingNumber: TrackingNumber + | Cancelled of reason: string + + type PricedLine = { + BookId: BookId + Title: string + Quantity: int + UnitPrice: Money + LineTotal: Money + } + + type Order = { + Id: OrderId + CustomerId: CustomerId + Lines: PricedLine list + Subtotal: Money + Discount: Money + ShippingCost: Money + Total: Money + ShippingMethod: ShippingMethod + Status: OrderStatus + } + + type State = { + Catalog: Map + Customers: Map + Inventory: Map + Orders: Map + NextOrderId: int + } + + type ShopError = + | CustomerNotFound + | OrderNotFound + | EmptyCart + | BookNotFound of BookId + | InvalidQuantity of BookId + | NotEnoughStock of BookId * available: int + | AlreadyPaid + | PaymentRequired + | OrderClosed + | InvalidCancellationReason + | InvalidState of InputError + + let searchCatalog (query: string) (state: State) = + let normalized = query.Trim().ToLowerInvariant() + + state.Catalog + |> Map.toList + |> List.map (fun (_, book) -> book) + |> List.filter (fun book -> book.Title.ToLowerInvariant().Contains(normalized)) + + let booksInGenre genre state = + state.Catalog + |> Map.toList + |> List.map (fun (_, book) -> book) + |> List.filter (fun book -> book.Genres |> Set.contains genre) + + let lowStock threshold state = + state.Inventory + |> Map.toList + |> List.choose (fun (bookId, stock) -> + let quantity = Stock.available stock + + if quantity <= threshold then + Some(bookId, quantity) + else + None) + + let addToCart bookId quantity (cart: Cart) = + if quantity <= 0 then + Error(InvalidQuantity bookId) + else + let previousQuantity = + cart.Lines + |> List.tryFind (fun line -> line.BookId = bookId) + |> Option.map (fun line -> line.Quantity) + |> Option.defaultValue 0 + + let otherLines = cart.Lines |> List.filter (fun line -> line.BookId <> bookId) + + let line: CartLine = { + BookId = bookId + Quantity = previousQuantity + quantity + } + + Ok { cart with Lines = line :: otherLines } + + let private priceLine (book: Book) (line: CartLine) : PricedLine = { + BookId = book.Id + Title = book.Title + Quantity = line.Quantity + UnitPrice = book.Price + LineTotal = Money.multiply line.Quantity book.Price + } + + let private prepareLine (catalog: Map) (inventory: Map) (line: CartLine) = + match Map.tryFind line.BookId catalog, Map.tryFind line.BookId inventory with + | None, _ -> Error(BookNotFound line.BookId) + | _, None -> Error(NotEnoughStock(line.BookId, 0)) + | Some _, Some _ when line.Quantity <= 0 -> Error(InvalidQuantity line.BookId) + | Some book, Some stock -> + match Stock.reserve line.Quantity stock with + | None -> Error(NotEnoughStock(line.BookId, Stock.available stock)) + | Some reserved -> + let inventory = inventory |> Map.add line.BookId reserved + Ok(priceLine book line, inventory) + + let private prepareLines catalog inventory (lines: CartLine list) = + lines + |> List.fold + (fun state line -> + result { + let! priced, currentInventory = state + let! nextLine, nextInventory = prepareLine catalog currentInventory line + return nextLine :: priced, nextInventory + }) + (Ok([], inventory)) + |> Result.map (fun (reversed, nextInventory) -> List.rev reversed, nextInventory) + + let private subtotal lines = + lines |> List.fold (fun total line -> Money.add total line.LineTotal) Money.zero + + let private discountFor customer amount = + match customer.Membership with + | Standard -> Money.zero, amount + | Member percent -> DiscountPercent.discountAmount percent amount, DiscountPercent.apply percent amount + + let private shippingCost method (Money subtotal) = + match method with + | Collection -> Money.zero + | StandardPost -> Money 500 + | ExpressPost when subtotal >= 5000 -> Money.zero + | ExpressPost -> Money 900 + + let placeOrder shippingMethod (cart: Cart) (state: State) = + match state.Customers |> Map.tryFind cart.CustomerId with + | None -> Error CustomerNotFound + | Some _ when cart.Lines = [] -> Error EmptyCart + | Some customer -> + result { + let! lines, inventory = prepareLines state.Catalog state.Inventory cart.Lines + let beforeDiscount = subtotal lines + let discount, afterDiscount = discountFor customer beforeDiscount + let delivery = shippingCost shippingMethod beforeDiscount + + let! orderId = + match OrderId.create state.NextOrderId with + | Ok value -> Ok value + | Error inputError -> Error(InvalidState inputError) + + let order = { + Id = orderId + CustomerId = customer.Id + Lines = lines + Subtotal = beforeDiscount + Discount = discount + ShippingCost = delivery + Total = Money.add afterDiscount delivery + ShippingMethod = shippingMethod + Status = AwaitingPayment + } + + return + { + state with + Inventory = inventory + Orders = state.Orders |> Map.add orderId order + NextOrderId = state.NextOrderId + 1 + }, + order + } + + let private updateOrder orderId transition state = + match state.Orders |> Map.tryFind orderId with + | None -> Error OrderNotFound + | Some order -> + transition order + |> Result.map (fun updated -> { + state with + Orders = state.Orders |> Map.add orderId updated + }) + + let pay orderId payment state = + let transition order = + match order.Status with + | AwaitingPayment -> Ok { order with Status = Paid payment } + | Paid _ -> Error AlreadyPaid + | Shipped _ + | Cancelled _ -> Error OrderClosed + + updateOrder orderId transition state + + let ship orderId trackingNumber state = + let transition order = + match order.Status with + | Paid payment -> + Ok { + order with + Status = Shipped(payment, trackingNumber) + } + | AwaitingPayment -> Error PaymentRequired + | Shipped _ + | Cancelled _ -> Error OrderClosed + + updateOrder orderId transition state + + let cancel orderId (reason: string) (state: State) = + if reason.Trim() = "" then + Error InvalidCancellationReason + else + match state.Orders |> Map.tryFind orderId with + | None -> Error OrderNotFound + | Some order -> + match order.Status with + | AwaitingPayment -> + let inventory = + order.Lines + |> List.fold + (fun current line -> + current |> Map.change line.BookId (Option.map (Stock.release line.Quantity))) + state.Inventory + + let cancelled = { + order with + Status = Cancelled(reason.Trim()) + } + + Ok { + state with + Inventory = inventory + Orders = state.Orders |> Map.add orderId cancelled + } + | Paid _ + | Shipped _ + | Cancelled _ -> Error OrderClosed + + type StatusCategory = + | AwaitingCategory + | PaidCategory + | ShippedCategory + | CancelledCategory + + let private statusCategory order = + match order.Status with + | AwaitingPayment -> AwaitingCategory + | Paid _ -> PaidCategory + | Shipped _ -> ShippedCategory + | Cancelled _ -> CancelledCategory + + let private allOrders state = + state.Orders |> Map.toList |> List.map (fun (_, order) -> order) + + let orderCounts state = + state + |> allOrders + |> List.groupBy statusCategory + |> List.map (fun (status, orders) -> status, List.length orders) + + let revenue state = + state + |> allOrders + |> List.filter (fun order -> + match order.Status with + | Paid _ + | Shipped _ -> true + | AwaitingPayment + | Cancelled _ -> false) + |> List.fold (fun total order -> Money.add total order.Total) Money.zero + +open Bookshop + +let expect label result = + match result with + | Ok value -> value + | Error error -> failwith $"%s{label}: %A{error}" + +let bookId = BookId.create 1 |> expect "sample book ID" +let customerId = CustomerId.create 1 |> expect "sample customer ID" +let isbn = Isbn.create "9780807083697" |> expect "sample ISBN" +let email = EmailAddress.create "ada@example.org" |> expect "sample email" +let price = Money.create 1299 |> expect "sample price" +let discount = DiscountPercent.create 10 |> expect "sample discount" +let stock = Stock.create 5 |> expect "sample stock" +let card = LastFourDigits.create "4242" |> expect "sample card detail" +let tracking = TrackingNumber.create "TRACK-100" |> expect "sample tracking number" + +let book = { + Id = bookId + Isbn = isbn + Title = "Kindred" + Authors = [ { Name = "Octavia E. Butler" } ] + Genres = Set.ofList [ Fiction; History ] + Price = price +} + +let customer = { + Id = customerId + Name = "Ada" + Email = Some email + Membership = Member discount +} + +let initial = { + Catalog = Map.ofList [ (book.Id, book) ] + Customers = Map.ofList [ (customer.Id, customer) ] + Inventory = Map.ofList [ (book.Id, stock) ] + Orders = Map.empty + NextOrderId = 1 +} + +let emptyCart = { CustomerId = customerId; Lines = [] } +let payment = { Card = card } + +let completed = + result { + let! cart = addToCart bookId 2 emptyCart + let! placed, order = placeOrder StandardPost cart initial + let! paid = pay order.Id payment placed + return! ship order.Id tracking paid + } + +match completed with +| Error error -> printfn "Workflow failed: %A" error +| Ok finalState -> + printfn "Search: %A" (searchCatalog "kind" finalState) + printfn "History books: %A" (booksInGenre History finalState) + printfn "Low stock: %A" (lowStock 3 finalState) + printfn "Order counts: %A" (orderCounts finalState) + printfn "Revenue: %A" (revenue finalState) + +// Predict the result, run it, then try an invalid quantity and a cancelled unpaid order. diff --git a/public/documentation/putting-it-together/63-final-capstone.md b/public/documentation/putting-it-together/63-final-capstone.md new file mode 100644 index 0000000..b0b04f2 --- /dev/null +++ b/public/documentation/putting-it-together/63-final-capstone.md @@ -0,0 +1,190 @@ +# Final capstone: the functional bookshop + +## What you will learn + +Read, trace, and change one program that connects the domain types, validation, +collections, immutable state, and order transitions developed throughout the +course. + +## This program grew rather than appeared + +The playground is longer than earlier examples, but its ideas are familiar. It +combines the same pieces you have already used: + +1. constrained values at input boundaries; +2. records for books, customers, carts, stock, and orders; +3. unions for alternatives and lifecycle states; +4. maps and sets for the catalog, inventory, orders, and genres; +5. options for contact information and lookups; +6. results for validation and business refusal; +7. folds for pricing, reservation, release, and reporting; +8. pure state transitions for placement, payment, cancellation, and shipping. + +Read one section at a time. The function signatures are the seams between them. + +## Validate primitive input once + +An `int` can represent a quantity, a price, or three different kinds of ID. A +`string` can represent an ISBN, email address, card detail, or tracking number. +The capstone gives those values different types and puts construction rules next +to them: + +```fsharp +type DiscountPercent = private DiscountPercent of int + +module DiscountPercent = + let create value = + if value >= 0 && value <= 100 then + Ok (DiscountPercent value) + else + Error (InputError "discount must be between 0 and 100") +``` + +Code outside the defining module cannot construct the private case directly. It +must handle the `Result` from `create`. Once creation succeeds, pricing code can +rely on the range without checking it again. + +The validators are deliberately modest. The email rule checks a useful local +shape; it does not claim to decide whether an address exists. The ISBN rule +checks length and digits, not the ISBN checksum. A type should promise exactly +what its constructor establishes. + +The ISBN constructor uses `Seq.forall` to check every character. It has the same +all-elements meaning as `List.forall`, but accepts any sequence, including the +characters produced by a string. + +## Keep invalid stock out of the model + +`Stock.create` rejects a negative on-hand quantity. The record case is private, +so callers cannot bypass that entry point. Reservation returns `Some` only when +the requested quantity is positive and available: + +```text +Stock.reserve : int -> Stock -> Stock option +``` + +The stock operation does not know about checkout errors. `prepareLine` gives a +failed reservation its domain meaning by returning `NotEnoughStock`. This keeps +the small stock type reusable while the workflow still reports a precise error. + +## Query the catalog without changing it + +`searchCatalog`, `booksInGenre`, and `lowStock` derive answers from the current +state. They demonstrate three different collection questions: + +- filter books whose normalized titles contain a query; +- keep books whose genre set contains a value; +- choose only inventory entries whose available quantity is low. + +None of these answers is stored alongside the source maps. Storing both would +create two facts that could disagree. + +## Turn cart intent into an accepted order + +The cart contains requests. The order contains accepted facts. Placement crosses +that boundary: + +```text +cart + shipping method + current state +→ find the customer +→ reject an empty cart +→ find, validate, price, and reserve every line +→ calculate discount and shipping +→ construct an awaiting-payment order +→ return the successor state and new order +``` + +`prepareLines` carries a `Result` containing both the priced lines and the +successor inventory. Each reservation therefore sees reservations made for +earlier lines. If any line fails, no successor `State` escapes. + +The order snapshots title, unit price, discount, shipping cost, and total. A +later catalog price change should not rewrite an order already accepted. + +## Model money operations at the level of the rule + +The earlier version exposed a subtraction function that silently clamped a +negative answer to zero. That made an invalid calculation look valid. The final +model instead exposes the operations the policy needs: + +```fsharp +DiscountPercent.discountAmount percent subtotal +DiscountPercent.apply percent subtotal +``` + +Because a `DiscountPercent` is between 0 and 100, applying it cannot make a +non-negative `Money` value negative. The useful invariant follows from the input +types rather than a hidden correction inside arithmetic. + +This course models money as whole minor units, such as cents, in one implicit +currency. Multi-currency arithmetic and rounding policies would require more +domain information. + +## Preserve errors through the whole scenario + +The sample does not replace errors with an empty cart or an earlier order. This +workflow has several dependent binds and would require nested callbacks, so it +uses the Result computation expression introduced in lesson 59: + +```fsharp +result { + let! cart = addToCart bookId 2 emptyCart + let! placed, order = placeOrder StandardPost cart initial + let! paid = pay order.Id payment placed + return! ship order.Id tracking paid +} +``` + +Each name remains available to later steps without indentation moving farther +right. The final match prints either the first error or reports over the shipped +state. No branch pretends that failure was success. + +The `expect` helper used while creating fixed sample data is different. Invalid +hard-coded seed data is a programmer mistake in this playground, so the helper +raises an exception with context. Cart, placement, and lifecycle failures are +expected domain outcomes and remain `Result` values. + +## Cancellation restores reserved stock + +Cancellation is allowed only while payment is pending. It updates the order and +releases every reserved line in the same returned state. Returning only the +cancelled order would leave inventory inconsistent. + +This is still a teaching policy. A larger shop might allow cancellation after +payment through a refund workflow. The union and transition would then grow to +represent those additional states explicitly. + +## Trace before running + +Predict these values before pressing Run: + +1. the cart contains one line with quantity two; +2. placement changes available stock from five to three; +3. payment changes only the stored status; +4. shipping preserves payment and adds a tracking number; +5. low-stock reporting includes the book at threshold three; +6. revenue includes the shipped order; +7. status counts contain one shipped order. + +Then compare your prediction with the printed values. If one differs, locate the +smallest function responsible before changing the code. + +## Experiment + +- Predict the `ShopError`, then request zero copies and run the program. +- Request six copies. Confirm that no successor state is returned. +- Stop after placement, cancel the order, and inspect released inventory. +- Try a blank tracking number at the construction boundary. +- Add a second science book and check search, genre, and low-stock queries. +- Change the customer to `Standard` and calculate the expected total by hand. +- Deliberately pass a `CustomerId` where a `BookId` is required. Read the two + types named by the compiler, then repair the call. + +## Summary + +The finished bookshop is a composition of ordinary F# ideas. Constrained values +establish trustworthy inputs; records and unions describe the domain; options +and results keep uncertainty visible; collection functions derive answers; and +pure transitions return complete successor states. The program is substantial +because these small pieces cooperate, not because the language changes at the +end. diff --git a/public/documentation/putting-it-together/63-result-expressions.fsx b/public/documentation/putting-it-together/63-result-expressions.fsx new file mode 100644 index 0000000..ee159b1 --- /dev/null +++ b/public/documentation/putting-it-together/63-result-expressions.fsx @@ -0,0 +1,33 @@ +type ResultBuilder() = + member _.Bind(result, next) = Result.bind next result + member _.Return(value) = Ok value + member _.ReturnFrom(result) = result + +let result = ResultBuilder() + +type CheckoutError = + | CustomerMissing + | EmptyCart + | InvalidShipping + +let findCustomer exists = + if exists then Ok "Ada" else Error CustomerMissing + +let validateCart lineCount = + if lineCount > 0 then Ok lineCount else Error EmptyCart + +let validateShipping supplied = + if supplied then Ok() else Error InvalidShipping + +let prepareOrder customerExists lineCount shippingSupplied = + result { + let! customer = findCustomer customerExists + let! validatedCount = validateCart lineCount + do! validateShipping shippingSupplied + return $"%s{customer}: %d{validatedCount} line(s) ready" + } + +printfn "%A" (prepareOrder true 2 true) +printfn "%A" (prepareOrder true 0 true) + +// Make each input invalid separately and confirm later steps are skipped. diff --git a/public/documentation/putting-it-together/63-result-expressions.md b/public/documentation/putting-it-together/63-result-expressions.md new file mode 100644 index 0000000..8ab8bb8 --- /dev/null +++ b/public/documentation/putting-it-together/63-result-expressions.md @@ -0,0 +1,100 @@ +# Optional enrichment: computation expressions for Result + +## What you will learn + +A computation expression can present already-understood Result binding as sequential syntax interpreted by a small builder. + +## Why this lesson is optional + +Matches, `Result.map`, and `Result.bind` are enough to write clear F#. Computation-expression syntax builds on those operations and can make a longer chain of dependent validations read sequentially. + +Nothing later depends on this syntax. If explicit Result pipelines are still new, feel free to return to this chapter later. + +## The workflow before new syntax + +```fsharp +let prepareOrder request = + findCustomer request.CustomerId + |> Result.bind (fun customer -> + validateCart request.Cart + |> Result.bind (fun lines -> + validateShipping request.Shipping + |> Result.map (fun shipping -> createDraft customer lines shipping))) +``` + +Every operation is visible, but dependent names create nested lambdas. + +## A minimal Result builder + +FSharp.Core provides Result functions but no single universal Result computation-expression builder. The small builder below defines only the behavior we need: + +```fsharp +type ResultBuilder() = + member _.Bind(result, next) = + Result.bind next result + + member _.Return(value) = + Ok value + + member _.ReturnFrom(result) = + result + +let result = ResultBuilder() +``` + +These class members give meaning to the syntax inside `result { ... }`. + +## `let!`, `do!`, `return`, and `return!` + +```fsharp +let prepareOrder request = + result { + let! customer = findCustomer request.CustomerId + let! lines = validateCart request.Cart + do! validateShipping request.Shipping + return createDraft customer lines request.Shipping + } +``` + +- `let!` calls `Bind`; `Ok` supplies its inner value to the remaining block and `Error` skips it. +- `do!` binds a successful unit result when no name is needed. +- `return value` calls `Return`, wrapping a value in `Ok`. +- `return! existingResult` calls `ReturnFrom` for a Result already produced elsewhere. + +Here, `return` belongs to computation-expression syntax. A regular F# function still produces its final expression without a return keyword. + +## Desugar one step + +```fsharp +result { + let! customer = findCustomer customerId + return customer.Name +} +``` + +corresponds conceptually to: + +```fsharp +Result.bind + (fun customer -> Ok customer.Name) + (findCustomer customerId) +``` + +The builder adds syntax for its members' bind behavior. It adds no exceptions, mutation, background work, or second error type. + +## Use the abstraction only when it helps + +A Result computation expression is useful when several fallible steps depend on earlier successes and the team recognizes the builder. Prefer a normal pipeline when two transformations already read clearly. Prefer explicit matching when different errors cause different domain behavior instead of simple propagation. + +A builder is an API, and its members determine exactly what the syntax means. Builders from libraries may support more operations, so read their contract instead of assuming that every `result {}` block behaves alike. + +## Experiment + +- Rewrite the playground with nested `Result.bind`. +- Make customer lookup and cart validation fail separately. +- Add `return!` around an already validated result. +- Remove `ReturnFrom` and observe which syntax stops compiling. + +## Summary + +A computation expression is syntax interpreted by builder members. For Result, it can present short-circuiting binds in sequence while keeping success and failure explicit in the type. diff --git a/public/documentation/table-of-contents.json b/public/documentation/table-of-contents.json index e57ed21..fe9e019 100644 --- a/public/documentation/table-of-contents.json +++ b/public/documentation/table-of-contents.json @@ -1,197 +1,110 @@ { - "root": "cover.md", - "categories": [ - { - "title": "Basics", - "route_segment": "basics", - "pages": [ - { - "title": "Hello, World!", - "route_segment": "hello-world", - "fsharp_file": "basics/hello-world.fsx", - "markdown_file": "basics/hello-world.md" - }, - { - "title": "Expressions", - "route_segment": "expressions", - "fsharp_file": "basics/expressions.fsx", - "markdown_file": "basics/expressions.md" - }, - { - "title": "Primitive Types", - "route_segment": "primitive-types", - "fsharp_file": "basics/primitive-types.fsx", - "markdown_file": "basics/primitive-types.md" - }, - { - "title": "String Formatting", - "route_segment": "string-formatting", - "fsharp_file": "basics/string-formatting.fsx", - "markdown_file": "basics/string-formatting.md" - }, - { - "title": "Operators", - "route_segment": "operators", - "fsharp_file": "basics/operators.fsx", - "markdown_file": "basics/operators.md" - }, - { - "title": "Conditional Expressions", - "route_segment": "conditional-expressions", - "fsharp_file": "basics/conditional-expressions.fsx", - "markdown_file": "basics/conditional-expressions.md" - }, - { - "title": "Patterns and Match Expressions", - "route_segment": "pattern-matching", - "fsharp_file": "basics/patterns-and-match-expressions.fsx", - "markdown_file": "basics/patterns-and-match-expressions.md" - } - ] - }, - { - "title": "Functions", - "route_segment": "functions", - "pages": [ - { - "title": "Defining Functions", - "route_segment": "definition", - "fsharp_file": "functions/defining-functions.fsx", - "markdown_file": "functions/defining-functions.md" - }, - { - "title": "Functions as Values", - "route_segment": "values", - "fsharp_file": "functions/functions-as-values.fsx", - "markdown_file": "functions/functions-as-values.md" - }, - { - "title": "Recursive Functions", - "route_segment": "recursive-functions", - "fsharp_file": "functions/recursive-functions.fsx", - "markdown_file": "functions/recursive-functions.md" - }, - { - "title": "Currying and Partial Application", - "route_segment": "currying-and-partial-application", - "fsharp_file": "functions/currying-and-partial-application.fsx", - "markdown_file": "functions/currying-and-partial-application.md" - }, - { - "title": "Pipelines and Composition", - "route_segment": "pipelines-and-composition", - "fsharp_file": "functions/pipelines-and-composition.fsx", - "markdown_file": "functions/pipelines-and-composition.md" - } - ] - }, - { - "title": "Data and Types", - "route_segment": "data-and-types", - "pages": [ - { - "title": "Tuples", - "route_segment": "tuples", - "fsharp_file": "data-and-types/tuples.fsx", - "markdown_file": "data-and-types/tuples.md" - }, - { - "title": "Lists", - "route_segment": "lists", - "fsharp_file": "data-and-types/lists.fsx", - "markdown_file": "data-and-types/lists.md" - }, - { - "title": "Sequences", - "route_segment": "sequences", - "fsharp_file": "data-and-types/sequences.fsx", - "markdown_file": "data-and-types/sequences.md" - }, - { - "title": "Sequence Expressions", - "route_segment": "sequence-expressions", - "fsharp_file": "data-and-types/sequence-expressions.fsx", - "markdown_file": "data-and-types/sequence-expressions.md" - }, - { - "title": "Type Abbreviations", - "route_segment": "abbreviations", - "fsharp_file": "data-and-types/abbreviations.fsx", - "markdown_file": "data-and-types/abbreviations.md" - }, - { - "title": "Records", - "route_segment": "records", - "fsharp_file": "data-and-types/records.fsx", - "markdown_file": "data-and-types/records.md" - }, - { - "title": "Discriminated Unions", - "route_segment": "discriminated-unions", - "fsharp_file": "data-and-types/discriminated-unions.fsx", - "markdown_file": "data-and-types/discriminated-unions.md" - }, - { - "title": "Generic Types", - "route_segment": "generic-types", - "fsharp_file": "data-and-types/generic-types.fsx", - "markdown_file": "data-and-types/generic-types.md" - }, - { - "title": "The Option Type", - "route_segment": "options", - "fsharp_file": "data-and-types/the-option-type.fsx", - "markdown_file": "data-and-types/the-option-type.md" - }, - { - "title": "The Result Type", - "route_segment": "results", - "fsharp_file": "data-and-types/the-result-type.fsx", - "markdown_file": "data-and-types/the-result-type.md" - }, - { - "title": "Units of Measure", - "route_segment": "units-of-measure", - "fsharp_file": "data-and-types/units-of-measure.fsx", - "markdown_file": "data-and-types/units-of-measure.md" - } - ] - }, - { - "title": "Object Programming", - "route_segment": "object-programming", - "pages": [ - { - "title": "Objects and Members", - "route_segment": "objects-and-members", - "fsharp_file": "object-programming/objects-and-members.fsx", - "markdown_file": "object-programming/objects-and-members.md" - }, - { - "title": "Interfaces", - "route_segment": "interfaces", - "fsharp_file": "object-programming/interfaces.fsx", - "markdown_file": "object-programming/interfaces.md" - }, - { - "title": "Abstract Classes and Inheritance", - "route_segment": "abstract-classes-and-inheritance", - "fsharp_file": "object-programming/abstract-classes-and-inheritance.fsx", - "markdown_file": "object-programming/abstract-classes-and-inheritance.md" - }, - { - "title": "Object Expressions", - "route_segment": "object-expressions", - "fsharp_file": "object-programming/object-expressions.fsx", - "markdown_file": "object-programming/object-expressions.md" - }, - { - "title": "Type Extensions", - "route_segment": "type-extensions", - "fsharp_file": "object-programming/type-extensions.fsx", - "markdown_file": "object-programming/type-extensions.md" - } - ] - } - ] -} \ No newline at end of file + "root": "cover.md", + "categories": [ + { + "title": "Foundations", + "route_segment": "foundations", + "pages": [ + { "title": "Everything Starts with Expressions", "route_segment": "01-expressions", "fsharp_file": "foundations/01-expressions.fsx", "markdown_file": "foundations/01-expressions.md" }, + { "title": "Values and let Bindings", "route_segment": "02-bindings", "fsharp_file": "foundations/02-bindings.fsx", "markdown_file": "foundations/02-bindings.md" }, + { "title": "Numbers and Arithmetic", "route_segment": "03-numbers", "fsharp_file": "foundations/03-numbers.fsx", "markdown_file": "foundations/03-numbers.md" }, + { "title": "Strings, Characters, and Text", "route_segment": "04-text", "fsharp_file": "foundations/04-text.fsx", "markdown_file": "foundations/04-text.md" }, + { "title": "Booleans and Comparisons", "route_segment": "05-booleans", "fsharp_file": "foundations/05-booleans.fsx", "markdown_file": "foundations/05-booleans.md" }, + { "title": "Conditional Expressions", "route_segment": "06-conditionals", "fsharp_file": "foundations/06-conditionals.fsx", "markdown_file": "foundations/06-conditionals.md" }, + { "title": "Types and Type Inference", "route_segment": "07-types", "fsharp_file": "foundations/07-types.fsx", "markdown_file": "foundations/07-types.md" } + ] + }, + { + "title": "Functions", + "route_segment": "functions", + "pages": [ + { "title": "Your First Functions", "route_segment": "08-functions", "fsharp_file": "functions/08-functions.fsx", "markdown_file": "functions/08-functions.md" }, + { "title": "Unit, Effects, and Functions That Do Things", "route_segment": "09-unit-and-effects", "fsharp_file": "functions/09-unit-and-effects.fsx", "markdown_file": "functions/09-unit-and-effects.md" }, + { "title": "Functions with Multiple Inputs", "route_segment": "10-multiple-inputs", "fsharp_file": "functions/10-multiple-inputs.fsx", "markdown_file": "functions/10-multiple-inputs.md" }, + { "title": "Local Bindings", "route_segment": "11-local-bindings", "fsharp_file": "functions/11-local-bindings.fsx", "markdown_file": "functions/11-local-bindings.md" }, + { "title": "Functions Are Values", "route_segment": "12-function-values", "fsharp_file": "functions/12-function-values.fsx", "markdown_file": "functions/12-function-values.md" }, + { "title": "Anonymous Functions", "route_segment": "13-lambdas", "fsharp_file": "functions/13-lambdas.fsx", "markdown_file": "functions/13-lambdas.md" }, + { "title": "Higher-Order Functions", "route_segment": "14-higher-order", "fsharp_file": "functions/14-higher-order.fsx", "markdown_file": "functions/14-higher-order.md" }, + { "title": "Partial Application and Currying", "route_segment": "15-partial-application", "fsharp_file": "functions/15-partial-application.fsx", "markdown_file": "functions/15-partial-application.md" }, + { "title": "The Pipeline Operator", "route_segment": "16-pipelines", "fsharp_file": "functions/16-pipelines.fsx", "markdown_file": "functions/16-pipelines.md" }, + { "title": "Function Composition", "route_segment": "17-composition", "fsharp_file": "functions/17-composition.fsx", "markdown_file": "functions/17-composition.md" } + ] + }, + { + "title": "Modeling Data", + "route_segment": "modeling-data", + "pages": [ + { "title": "Tuples", "route_segment": "18-tuples", "fsharp_file": "modeling-data/18-tuples.fsx", "markdown_file": "modeling-data/18-tuples.md" }, + { "title": "Records", "route_segment": "19-records", "fsharp_file": "modeling-data/19-records.fsx", "markdown_file": "modeling-data/19-records.md" }, + { "title": "Modeling with Records", "route_segment": "20-record-modeling", "fsharp_file": "modeling-data/20-record-modeling.fsx", "markdown_file": "modeling-data/20-record-modeling.md" }, + { "title": "Discriminated Unions", "route_segment": "21-unions", "fsharp_file": "modeling-data/21-unions.fsx", "markdown_file": "modeling-data/21-unions.md" }, + { "title": "Pattern Matching", "route_segment": "22-pattern-matching", "fsharp_file": "modeling-data/22-pattern-matching.fsx", "markdown_file": "modeling-data/22-pattern-matching.md" }, + { "title": "Records and Unions", "route_segment": "23-records-and-unions", "fsharp_file": "modeling-data/23-records-and-unions.fsx", "markdown_file": "modeling-data/23-records-and-unions.md" }, + { "title": "Making Invalid States Hard", "route_segment": "24-valid-states", "fsharp_file": "modeling-data/24-valid-states.fsx", "markdown_file": "modeling-data/24-valid-states.md" }, + { "title": "Optional Values", "route_segment": "25-options", "fsharp_file": "modeling-data/25-options.fsx", "markdown_file": "modeling-data/25-options.md" }, + { "title": "Working with Options", "route_segment": "26-option-functions", "fsharp_file": "modeling-data/26-option-functions.fsx", "markdown_file": "modeling-data/26-option-functions.md" }, + { "title": "Success and Failure with Result", "route_segment": "27-results", "fsharp_file": "modeling-data/27-results.fsx", "markdown_file": "modeling-data/27-results.md" }, + { "title": "Validation", "route_segment": "28-validation", "fsharp_file": "modeling-data/28-validation.fsx", "markdown_file": "modeling-data/28-validation.md" }, + { "title": "Chaining Fallible Operations", "route_segment": "29-result-chains", "fsharp_file": "modeling-data/29-result-chains.fsx", "markdown_file": "modeling-data/29-result-chains.md" } + ] + }, + { + "title": "Collections", + "route_segment": "collections", + "pages": [ + { "title": "Lists and Their Recursive Shape", "route_segment": "30-lists", "fsharp_file": "collections/30-lists.fsx", "markdown_file": "collections/30-lists.md" }, + { "title": "Transforming Lists with map", "route_segment": "31-list-map", "fsharp_file": "collections/31-list-map.fsx", "markdown_file": "collections/31-list-map.md" }, + { "title": "Selecting Values with filter", "route_segment": "32-list-filter", "fsharp_file": "collections/32-list-filter.fsx", "markdown_file": "collections/32-list-filter.md" }, + { "title": "Finding Values", "route_segment": "33-finding", "fsharp_file": "collections/33-finding.fsx", "markdown_file": "collections/33-finding.md" }, + { "title": "Choosing and Partitioning Values", "route_segment": "34-choosing-and-partitioning", "fsharp_file": "collections/34-choosing-and-partitioning.fsx", "markdown_file": "collections/34-choosing-and-partitioning.md" }, + { "title": "Folding Collections", "route_segment": "35-folds", "fsharp_file": "collections/35-folds.fsx", "markdown_file": "collections/35-folds.md" }, + { "title": "Recursive Domain Data", "route_segment": "36-recursion", "fsharp_file": "collections/36-recursion.fsx", "markdown_file": "collections/36-recursion.md" }, + { "title": "Arrays", "route_segment": "37-arrays", "fsharp_file": "collections/37-arrays.fsx", "markdown_file": "collections/37-arrays.md" }, + { "title": "Sequences", "route_segment": "38-sequences", "fsharp_file": "collections/38-sequences.fsx", "markdown_file": "collections/38-sequences.md" }, + { "title": "Maps and Keyed Lookup", "route_segment": "39-maps", "fsharp_file": "collections/39-maps.fsx", "markdown_file": "collections/39-maps.md" }, + { "title": "Sets and Unique Values", "route_segment": "40-sets", "fsharp_file": "collections/40-sets.fsx", "markdown_file": "collections/40-sets.md" } + ] + }, + { + "title": "The Bookshop Domain", + "route_segment": "bookshop-domain", + "pages": [ + { "title": "Domain-Specific IDs", "route_segment": "41-domain-ids", "fsharp_file": "bookshop-domain/41-domain-ids.fsx", "markdown_file": "bookshop-domain/41-domain-ids.md" }, + { "title": "Modeling the Catalog", "route_segment": "42-catalog", "fsharp_file": "bookshop-domain/42-catalog.fsx", "markdown_file": "bookshop-domain/42-catalog.md" }, + { "title": "Customers and Contact", "route_segment": "43-customers", "fsharp_file": "bookshop-domain/43-customers.fsx", "markdown_file": "bookshop-domain/43-customers.md" }, + { "title": "Inventory and Stock", "route_segment": "44-inventory", "fsharp_file": "bookshop-domain/44-inventory.fsx", "markdown_file": "bookshop-domain/44-inventory.md" }, + { "title": "Shopping Carts", "route_segment": "45-shopping-carts", "fsharp_file": "bookshop-domain/45-shopping-carts.fsx", "markdown_file": "bookshop-domain/45-shopping-carts.md" }, + { "title": "Pricing and Discounts", "route_segment": "46-pricing-and-discounts", "fsharp_file": "bookshop-domain/46-pricing-and-discounts.fsx", "markdown_file": "bookshop-domain/46-pricing-and-discounts.md" }, + { "title": "Validating Orders", "route_segment": "47-validating-orders", "fsharp_file": "bookshop-domain/47-validating-orders.fsx", "markdown_file": "bookshop-domain/47-validating-orders.md" }, + { "title": "Order States", "route_segment": "48-order-states", "fsharp_file": "bookshop-domain/48-order-states.fsx", "markdown_file": "bookshop-domain/48-order-states.md" } + ] + }, + { + "title": "The Wider F# Language", + "route_segment": "wider-fsharp", + "pages": [ + { "title": "Modules", "route_segment": "49-modules", "fsharp_file": "wider-fsharp/49-modules.fsx", "markdown_file": "wider-fsharp/49-modules.md" }, + { "title": "Generic Functions and Types", "route_segment": "50-generics", "fsharp_file": "wider-fsharp/50-generics.fsx", "markdown_file": "wider-fsharp/50-generics.md" }, + { "title": "Equality, Sorting, and Grouping", "route_segment": "51-equality-sorting", "fsharp_file": "wider-fsharp/51-equality-sorting.fsx", "markdown_file": "wider-fsharp/51-equality-sorting.md" }, + { "title": "The .NET Type World", "route_segment": "52-dotnet-types", "fsharp_file": "wider-fsharp/52-dotnet-types.fsx", "markdown_file": "wider-fsharp/52-dotnet-types.md" }, + { "title": "Objects and Classes", "route_segment": "53-objects-and-classes", "fsharp_file": "wider-fsharp/53-objects-and-classes.fsx", "markdown_file": "wider-fsharp/53-objects-and-classes.md" }, + { "title": "Interfaces and Object Abstraction", "route_segment": "54-interfaces", "fsharp_file": "wider-fsharp/54-interfaces.fsx", "markdown_file": "wider-fsharp/54-interfaces.md" }, + { "title": "Controlled Mutation and Mutable Arrays", "route_segment": "55-controlled-mutation", "fsharp_file": "wider-fsharp/55-controlled-mutation.fsx", "markdown_file": "wider-fsharp/55-controlled-mutation.md" }, + { "title": "Loops and Imperative Iteration", "route_segment": "56-loops-and-iteration", "fsharp_file": "wider-fsharp/56-loops-and-iteration.fsx", "markdown_file": "wider-fsharp/56-loops-and-iteration.md" }, + { "title": "Exceptions and Explicit Errors", "route_segment": "57-exceptions", "fsharp_file": "wider-fsharp/57-exceptions.fsx", "markdown_file": "wider-fsharp/57-exceptions.md" } + ] + }, + { + "title": "Putting It Together", + "route_segment": "putting-it-together", + "pages": [ + { "title": "Signatures and Small Pure Functions", "route_segment": "58-signatures-and-design", "fsharp_file": "putting-it-together/58-signatures-and-design.fsx", "markdown_file": "putting-it-together/58-signatures-and-design.md" }, + { "title": "Placing Orders", "route_segment": "59-placing-orders", "fsharp_file": "putting-it-together/59-placing-orders.fsx", "markdown_file": "putting-it-together/59-placing-orders.md" }, + { "title": "Payment and Cancellation", "route_segment": "60-payment-and-cancellation", "fsharp_file": "putting-it-together/60-payment-and-cancellation.fsx", "markdown_file": "putting-it-together/60-payment-and-cancellation.md" }, + { "title": "Shipping and Reports", "route_segment": "61-shipping-and-reports", "fsharp_file": "putting-it-together/61-shipping-and-reports.fsx", "markdown_file": "putting-it-together/61-shipping-and-reports.md" }, + { "title": "Final Functional Bookshop", "route_segment": "62-final-capstone", "fsharp_file": "putting-it-together/62-final-capstone.fsx", "markdown_file": "putting-it-together/62-final-capstone.md" }, + { "title": "Optional: Result Computation Expressions", "route_segment": "63-result-expressions", "fsharp_file": "putting-it-together/63-result-expressions.fsx", "markdown_file": "putting-it-together/63-result-expressions.md" } + ] + } + ] +} diff --git a/public/documentation/wider-fsharp/49-modules.fsx b/public/documentation/wider-fsharp/49-modules.fsx new file mode 100644 index 0000000..e486ae5 --- /dev/null +++ b/public/documentation/wider-fsharp/49-modules.fsx @@ -0,0 +1,35 @@ +module Catalog = + type BookId = BookId of int + type Entry = { Id: BookId; Title: string } + type Catalog = Map + + let private normalize (text: string) = text.Trim().ToLowerInvariant() + + let create entries = + entries |> List.map (fun entry -> entry.Id, entry) |> Map.ofList + + let search query catalog = + let wanted = normalize query + + catalog + |> Map.toList + |> List.map (fun (_, entry) -> entry) + |> List.filter (fun entry -> + let title = normalize entry.Title + title.Contains(wanted)) + +let catalog = + Catalog.create [ + { + Id = Catalog.BookId 1 + Title = "Kindred" + } + { + Id = Catalog.BookId 2 + Title = "Dune" + } + ] + +printfn "%A" (Catalog.search "kind" catalog) + +// Make normalize public, call it, then make it private again. diff --git a/public/documentation/wider-fsharp/49-modules.md b/public/documentation/wider-fsharp/49-modules.md new file mode 100644 index 0000000..f5cab47 --- /dev/null +++ b/public/documentation/wider-fsharp/49-modules.md @@ -0,0 +1,73 @@ +# Modules: organizing a growing program + +## What you will learn + +Modules group related types and functions under a qualified name. + +```fsharp +module Catalog = + type BookId = BookId of int + let find id catalog = Map.tryFind id catalog +``` + +Use `Catalog.find` from outside. `open Catalog` brings names into scope, but qualification often makes domain vocabulary clearer and avoids collisions. + +```fsharp +let found = Catalog.find (Catalog.BookId 1) catalog +``` + +Qualification tells the reader where vocabulary comes from. Once `Customers.find` and `Orders.find` exist, an unqualified `find` would be ambiguous even if the compiler could resolve it. + +Definitions inside a module are indented beneath it. Module-level bindings are initialized when the module loads. A module is not a class and needs no construction; its main job is to organize names. + +Accessibility can hide helpers: + +```fsharp +let private normalize (text: string) = text.Trim().ToLowerInvariant() +``` + +Public functions can use the private implementation while callers see a smaller surface. In a single script, modules also prevent the many `Id` and `Error` names of a larger domain from becoming ambiguous. + +## Hide construction to establish a guarantee + +The earlier `BookId` wrapper still allowed `BookId -1`. A module can make the union representation private: + +```fsharp +module BookId = + type BookId = private BookId of int + + let create value = + if value > 0 then + Ok (BookId value) + else + Error "Book ID must be positive" + + let value (BookId value) = + value +``` + +Outside the module, callers cannot write `BookId -1`; the public route is `create`. Values exposed by this module can therefore be guaranteed to have passed its constructor, provided the module's own implementation does not create invalid cases. Privacy turns a caller convention into an enforced public boundary. + +A namespace organizes names across a .NET codebase; it cannot directly contain values. This playground mostly needs modules, while larger F# codebases commonly combine namespaces with modules and types. + +## Shape the public vocabulary + +Callers should need `Catalog.create`, `Catalog.search`, and the catalog types, not the details of lowercase normalization. Making `normalize` private communicates that it may change without affecting callers. + +Avoid opening every module globally. `Catalog.find` is often clearer than an unqualified `find`, especially once `Customers.find` and `Orders.find` exist. `open` is most useful for a focused scope or a module whose vocabulary is unmistakable. + +## Modules versus namespaces + +A module can contain types, values, and functions. It is a named group of definitions, not an object that callers construct. A namespace organizes types and modules across source files but cannot directly contain `let` bindings. Scripts naturally use modules; multi-file libraries often place modules and types inside a namespace. + +Modules help when related names need qualification, privacy, or a clear public vocabulary. File length alone is a poor reason to add one. + +## Try it + +- Move catalog search into a module. +- Call it qualified, then with `open`. +- Mark a helper private and try accessing it outside. + +## Summary + +Modules give domain vocabulary a home and control which details callers can see. diff --git a/public/documentation/wider-fsharp/50-generics.fsx b/public/documentation/wider-fsharp/50-generics.fsx new file mode 100644 index 0000000..17c5790 --- /dev/null +++ b/public/documentation/wider-fsharp/50-generics.fsx @@ -0,0 +1,16 @@ +type Page<'item> = { Items: 'item list; Number: int } + +let transformPage transform page = { + Items = page.Items |> List.map transform + Number = page.Number +} + +let titles = { + Items = [ "Kindred"; "Dune" ] + Number = 1 +} + +let lengths = titles |> transformPage (fun title -> title.Length) +printfn "%A" lengths + +// Try transforming the same Page structure into uppercase titles. diff --git a/public/documentation/wider-fsharp/50-generics.md b/public/documentation/wider-fsharp/50-generics.md new file mode 100644 index 0000000..c197b05 --- /dev/null +++ b/public/documentation/wider-fsharp/50-generics.md @@ -0,0 +1,78 @@ +# Generic functions and types + +## What you will learn + +Generic code preserves relationships between types without fixing those types in advance. + +Higher-order functions introduced generic type variables. We can now move from reading generic signatures to designing generic functions and data types. + +The simplest generic function is inferred without an annotation: + +```fsharp +let keep value = value +// 'a -> 'a +``` + +The same input type must be the same output type, whether one call uses a book and another uses a number. + +```fsharp +keep 42 // int +keep "Dune" // string +``` + +These are separate uses of one generalized definition. Within each call, input and output still agree exactly. + +Generic domain containers name reusable structure: + +```fsharp +type Page<'item> = { Items: 'item list; Number: int } +type Attempt<'value, 'error> = Succeeded of 'value | Failed of 'error +``` + +`Page` and `Page` are distinct concrete types built from one definition. + +The definition says nothing about what an item means. It only says that a page contains a list of one item type and a page number—the part that every paged result genuinely shares. + +```fsharp +let transformPage transform page = + { Items = page.Items |> List.map transform; Number = page.Number } +``` + +Its type relates input item, output item, and the function between them. Let inference generalize naturally; explicit generic annotations are most helpful in public designs or explanations. + +Constraints appear when an operation needs a capability such as comparison. `Set<'a>` and `Map<'key,'value>` require comparable elements or keys. + +```fsharp +let contains value values = + values |> Set.contains value +``` + +The inferred type includes a comparison constraint because a set must order its values internally. Start with the operations the function needs and let inference reveal any required constraints. + +## Generality is inferred, not declared for sport + +`keep` uses no type-specific operation, so the compiler safely generalizes it. If the body used `value.Length`, the input would need a type with that member and would no longer be freely generic. The implementation determines the signature. + +Generic code should capture something genuinely shared. `Page<'item>` is useful because pagination works the same way for books and customers. Replacing meaningful domain types with `'a` only for the sake of abstraction would make the program harder to read. + +## Generic code rarely needs reflection + +The compiler checks each concrete use of a generic definition. Functions such as `map` rely on supplied functions and statically checked relationships; they do not need to inspect what `'a` happens to be at runtime. + +## Read a larger signature + +```text +transformPage : ('a -> 'b) -> Page<'a> -> Page<'b> +``` + +The first input converts one item. The second input is a page of source items. The result keeps the page structure but contains transformed items. Naming `'a` and `'b` differently communicates that the element type may change. + +## Try it + +- Use `keep` with two unrelated types. +- Create `Page` and map lengths. +- Inspect how the compiler relates the type variables. + +## Summary + +Generic parameters stand for types while preserving exact relationships. This is why functional building blocks remain reusable and type-safe. diff --git a/public/documentation/wider-fsharp/51-equality-sorting.fsx b/public/documentation/wider-fsharp/51-equality-sorting.fsx new file mode 100644 index 0000000..79f30b2 --- /dev/null +++ b/public/documentation/wider-fsharp/51-equality-sorting.fsx @@ -0,0 +1,50 @@ +type OrderId = OrderId of int + +type OrderStatus = + | AwaitingPayment + | Paid + | Shipped + +type Order = { + Id: OrderId + CustomerName: string + PlacedOnDay: int + TotalInCents: int + Status: OrderStatus +} + +let orders = [ + { + Id = OrderId 1 + CustomerName = "Ada" + PlacedOnDay = 12 + TotalInCents = 2400 + Status = Paid + } + { + Id = OrderId 2 + CustomerName = "Ben" + PlacedOnDay = 14 + TotalInCents = 1800 + Status = AwaitingPayment + } + { + Id = OrderId 3 + CustomerName = "Ada" + PlacedOnDay = 13 + TotalInCents = 3200 + Status = Paid + } +] + +let byTotal = orders |> List.sortBy (fun order -> order.TotalInCents, order.Id) + +let statusCounts = + orders + |> List.groupBy (fun order -> order.Status) + |> List.map (fun (status, matching) -> status, List.length matching) + +printfn "By total: %A" byTotal +printfn "Status counts: %A" statusCounts + +// Add a shipped order and predict the new groups. diff --git a/public/documentation/wider-fsharp/51-equality-sorting.md b/public/documentation/wider-fsharp/51-equality-sorting.md new file mode 100644 index 0000000..d36dc01 --- /dev/null +++ b/public/documentation/wider-fsharp/51-equality-sorting.md @@ -0,0 +1,76 @@ +# Equality, sorting, and grouping + +## What you will learn + +Learn what structural equality compares, then sort and group with keys that state the domain question clearly. + +By default, records and unions whose contents support equality receive structural equality: + +```fsharp +let same = firstOrder = secondOrder +``` + +Tuples and lists likewise compare their contents when their element types support equality. Function values do not support structural equality. Floating-point equality follows floating-point rules, including the fact that `nan = nan` is false. + +Equality is not domain identity. Two order values may contain equal fields but still represent different real orders if their `OrderId` values differ. Compare IDs when asking about identity; compare complete records only when complete value equality is the intended question. + +## Sort with a visible key + +```fsharp +let byTotal = + orders |> List.sortBy (fun order -> order.Total) + +let newestFirst = + orders |> List.sortByDescending (fun order -> order.PlacedOnDay) +``` + +For several keys, return a tuple: + +```fsharp +orders |> List.sortBy (fun order -> order.CustomerName, order.Id) +``` + +Tuple comparison uses the first part and then later parts to break ties. Prefer a named function such as `sortForDispatch` when ordering expresses business policy. + +When equal keys require deterministic output, include an explicit tie-breaker such as an ID rather than relying on incidental input order. + +## Group values for a report + +`List.groupBy` applies a key function and returns one pair per distinct key: + +```fsharp +let byStatus = + orders |> List.groupBy (fun order -> order.Status) +``` + +Its result has the conceptual shape: + +```text +(OrderStatus * Order list) list +``` + +Each tuple contains a status and all orders that produced that key. Grouping does not change the orders; it creates a report-oriented view. + +```fsharp +let statusCounts = + orders + |> List.groupBy (fun order -> order.Status) + |> List.map (fun (status, matching) -> status, List.length matching) +``` + +The grouping key must support equality. Sorting, maps, and sets require comparison instead, which is a stronger constraint. Unions whose payloads support the required operation work naturally unless equality or comparison generation has been disabled explicitly. + +## Representation can leak into comparison + +A wrapper union normally compares through its payload, so `OrderId 2 < OrderId 10`. That provides deterministic map and set behavior, but it does not automatically mean one order is more important than another. Use representation ordering only where the domain question supports it. + +## Experiment + +- Sort orders by total and then by ID. +- Group them by status and calculate counts. +- Add another order to an existing group. +- Compare two records differing only in ID. + +## Summary + +Structural equality compares complete data shapes. Sorting and grouping should use explicit keys that state the domain question being asked. diff --git a/public/documentation/wider-fsharp/52-dotnet-types.fsx b/public/documentation/wider-fsharp/52-dotnet-types.fsx new file mode 100644 index 0000000..5400e88 --- /dev/null +++ b/public/documentation/wider-fsharp/52-dotnet-types.fsx @@ -0,0 +1,13 @@ +open System + +let parseCount (text: string) = + match Int32.TryParse(text) with + | true, value -> Some value + | false, _ -> None + +let titles = [ "Kindred"; "Dune"; "Earthsea" ] +printfn "Catalog: %s" (String.concat ", " titles) +printfn "Parsed: %A" (parseCount "42") +printfn "Invalid: %A" (parseCount "many") + +// Try parsing a negative count and decide whether parsing or domain validation should reject it. diff --git a/public/documentation/wider-fsharp/52-dotnet-types.md b/public/documentation/wider-fsharp/52-dotnet-types.md new file mode 100644 index 0000000..bb52e8b --- /dev/null +++ b/public/documentation/wider-fsharp/52-dotnet-types.md @@ -0,0 +1,113 @@ +# F# in the .NET type world + +## What you will learn + +F# uses the .NET type system directly and works naturally with .NET types, members, namespaces, and generic APIs. + +## F# names are .NET types + +Several familiar F# type names are aliases for types from the .NET Base Class Library: + +```text +int = System.Int32 +float = System.Double +bool = System.Boolean +char = System.Char +string = System.String +``` + +This is not a conversion table between two unrelated type systems. An F# value of type `int` is a `System.Int32` value. F# supplies concise names and its own syntax while using the same underlying .NET type system. + +The F# language and FSharp.Core make types such as lists, options, results, tuples, and F# function values central to everyday code. They sit alongside Base Class Library types instead of replacing them. + +The shared type system is what matters in practice. F# can call a class written in another .NET language, and those languages can call suitably exposed F# types. Their source syntax differs, but they work with the same familiar categories of types and members. + +## Instance members + +A value can expose properties and methods defined by its .NET type: + +```fsharp +let title = " Kindred " +let cleaned = title.Trim() +let characterCount = cleaned.Length +let beginsWithK = cleaned.StartsWith("K") +``` + +`Length` is a property and is read without call parentheses. `Trim()` and `StartsWith(...)` are methods. The value before the dot is the object on which the member operates. + +Strings are immutable. `Trim()` returns another string; it does not alter `title`. + +## Static members + +A static member belongs to a type rather than one existing instance: + +```fsharp +let parsed = System.Int32.TryParse("42") +``` + +`TryParse` starts with a string and attempts to produce an integer, so it is exposed by `System.Int32` itself. In its convenient F# call form, the boolean success flag and parsed value are returned as a tuple. + +```fsharp +let parseCount (text: string) = + match System.Int32.TryParse(text) with + | true, value -> Some value + | false, _ -> None +``` + +The wrapper translates a conventional .NET “try” API into the `Option` vocabulary used by the surrounding F# code. Parsing and domain validation remain different: `"-3"` is a valid integer representation even if negative inventory is invalid business data. + +## Namespaces and `open` + +The fully qualified name `System.Int32` identifies the `Int32` type inside the `System` namespace. Opening that namespace shortens later references: + +```fsharp +open System + +let parsed = Int32.TryParse("42") +``` + +`open` changes how names are resolved in the current scope. It does not construct an object, load a package, or copy definitions. + +A namespace and an F# module have different jobs: + +- a namespace organizes types and modules across a .NET codebase; +- a module can contain types, values, and functions; +- `open` can bring names from either into scope; +- qualification remains useful when it makes ownership clearer. + +## Members and module functions side by side + +F# code commonly mixes .NET members with FSharp.Core module functions: + +```fsharp +text.Trim() // instance method +System.Int32.TryParse(text) // static method +String.length text // F# module function +String.concat ", " titles // F# module function +``` + +All four are normal F# expressions. Their call shapes reflect where the operation is defined, not a hierarchy of quality. Choose the form that states the operation clearly. + +This answers a common question about strings. `System.String` exposes members such as `.Trim()`, `.Contains(...)`, and `.Length`; the F# `String` module supplies helpers such as `String.length`, `String.concat`, and `String.replicate`. You will meet both styles throughout .NET and F# code. + +## Overloads sometimes need help + +.NET methods may be overloaded: one member name can have several parameter lists. F# usually infers the intended overload, but a small annotation can settle an ambiguous boundary: + +```fsharp +let parseCount (text: string) = + System.Int32.TryParse(text) +``` + +The annotation is not a retreat from type inference. It documents the external boundary and tells overload resolution which input type is intended. + +## Try it + +- Use both `title.Length` and `String.length title`. +- Parse valid, invalid, and negative integer text. +- Open `System` and shorten `System.Int32` to `Int32`. +- Identify the property, instance methods, static method, and module functions in the examples. + +## Summary + +F# is a .NET language with direct access to .NET types and members. F# aliases, FSharp.Core types, Base Class Library APIs, namespaces, modules, and object-oriented members form one practical programming model. diff --git a/public/documentation/wider-fsharp/53-objects-and-classes.fsx b/public/documentation/wider-fsharp/53-objects-and-classes.fsx new file mode 100644 index 0000000..8db8ff7 --- /dev/null +++ b/public/documentation/wider-fsharp/53-objects-and-classes.fsx @@ -0,0 +1,14 @@ +type Shelf(label: string, capacity: int) = + member _.Label = label + member _.Capacity = capacity + member _.HasSpace(bookCount: int) = bookCount < capacity + + member this.Describe() = + $"%s{this.Label}: space for %d{this.Capacity} books" + +let shelf = Shelf("Science fiction", 40) + +printfn "%s" (shelf.Describe()) +printfn "Has space after 37 books: %b" (shelf.HasSpace(37)) + +// Build a second shelf and compare its properties and method results. diff --git a/public/documentation/wider-fsharp/53-objects-and-classes.md b/public/documentation/wider-fsharp/53-objects-and-classes.md new file mode 100644 index 0000000..3d2c59f --- /dev/null +++ b/public/documentation/wider-fsharp/53-objects-and-classes.md @@ -0,0 +1,94 @@ +# Objects and classes + +## What you will learn + +F# can define and consume classes with constructors, properties, and methods +while records and functions remain available where they fit better. + +## Why F# includes object-oriented features + +Records, discriminated unions, and functions suit most of our bookshop domain. F# is also a multi-paradigm .NET language: it consumes object-oriented APIs and can define classes when they fit the boundary. + +The goal is to read and use object-oriented F# without abandoning the functional model you already know. + +## Classes and primary constructors + +```fsharp +type Shelf(label: string, capacity: int) = + member _.Label = label + member _.Capacity = capacity + member _.HasSpace(bookCount: int) = + bookCount < capacity +``` + +The parameters after `Shelf` form its primary constructor. Construct an object with: + +```fsharp +let shelf = Shelf("Science fiction", 40) +``` + +Class constructors use the parenthesized member-call style. Plain F# functions still use whitespace for application. + +`Label` and `Capacity` are read-only properties: + +```fsharp +shelf.Label +shelf.Capacity +``` + +`HasSpace` was defined with a parenthesized, .NET-style argument list, so its call uses parentheses: + +```fsharp +shelf.HasSpace(37) +// true +``` + +The underscore in `member _.Label` means the current object is not needed. Name it `this` when one member calls another: + +```fsharp +member this.Describe() = + $"%s{this.Label}: %d{this.Capacity} spaces" +``` + +Constructor parameters are in scope throughout the class body but are not automatically public properties. Expose only the members callers should use. + +## Properties are not guaranteed to be pure + +A property reads like data and a method reads like an operation, but that convention says nothing about purity. Either may hide mutation or other effects, so check the contract of an unfamiliar API. + +## Class or record? + +A record is usually the simpler representation for immutable domain facts. It +provides named fields, structural equality, pattern matching, and copy-and-update +without writing members. + +A class is useful when construction and an object-shaped API belong together, +when identity or encapsulated state matters, or when an API expects ordinary +.NET members. A class does not automatically make a model more realistic. + +```fsharp +type ShelfFacts = { Label: string; Capacity: int } + +let hasSpace bookCount shelf = + bookCount < shelf.Capacity +``` + +This record and function may express the shelf rule more directly than a class. +The class version may fit better when callers already work through member calls. +Compare actual use sites rather than choosing by habit. + +## Working with the wider .NET ecosystem + +Classes, methods, and properties belong to the .NET type system. F# can use types written in other .NET languages, and those languages can use suitably exposed F# types. Reflection, inheritance-heavy design, and application infrastructure are beyond this tour. The next lesson introduces interfaces as a separate idea. + +## Experiment + +- Add a `Describe()` method that uses two properties through `this`. +- Express the same immutable shelf facts as a record and compare construction and use. +- Try accessing a constructor parameter that was not exposed as a property. + +## Summary + +F# domain logic can remain function-and-data oriented while interoperating +comfortably with objects. Classes combine construction with an object-shaped +member API; records and functions often remain simpler for immutable facts. diff --git a/public/documentation/wider-fsharp/54-interfaces.fsx b/public/documentation/wider-fsharp/54-interfaces.fsx new file mode 100644 index 0000000..3d8c59b --- /dev/null +++ b/public/documentation/wider-fsharp/54-interfaces.fsx @@ -0,0 +1,24 @@ +type IDisplayable = + abstract member Display: unit -> string + +type Shelf(label: string, capacity: int) = + member _.Label = label + member _.Capacity = capacity + + interface IDisplayable with + member _.Display() = + $"%s{label}: space for %d{capacity} books" + +type CustomerLabel(name: string) = + interface IDisplayable with + member _.Display() = "Customer: " + name + +let display (item: IDisplayable) = item.Display() + +let shelf = Shelf("Science fiction", 40) +let customer = CustomerLabel("Ada") + +printfn "%s" (display shelf) +printfn "%s" (display customer) + +// Add an OrderLabel class that implements IDisplayable. diff --git a/public/documentation/wider-fsharp/54-interfaces.md b/public/documentation/wider-fsharp/54-interfaces.md new file mode 100644 index 0000000..ca1843b --- /dev/null +++ b/public/documentation/wider-fsharp/54-interfaces.md @@ -0,0 +1,100 @@ +# Interfaces and object abstraction + +## What you will learn + +An interface describes a set of object members without fixing the class that +implements them. + +## Different objects can promise the same behavior + +The previous lesson defined classes with members. A shelf and a customer label +are different classes, but both might know how to produce display text. An +interface names that shared object-oriented contract: + +```fsharp +type IDisplayable = + abstract member Display: unit -> string +``` + +`abstract member` declares the member without providing an implementation. +`unit -> string` means callers invoke `Display()` without meaningful input and +receive a string. + +A class implements the contract in an interface block: + +```fsharp +type Shelf(label: string, capacity: int) = + member _.Label = label + member _.Capacity = capacity + + interface IDisplayable with + member _.Display() = + $"%s{label}: space for %d{capacity} books" +``` + +Another class can make the same promise differently: + +```fsharp +type CustomerLabel(name: string) = + interface IDisplayable with + member _.Display() = "Customer: " + name +``` + +## Depend on the contract + +```fsharp +let display (item: IDisplayable) = + item.Display() +``` + +The annotation says that `display` accepts any object implementing +`IDisplayable`. Inside the function, only members promised by that interface are +available. Constructor parameters and unrelated class members remain outside +the contract. + +```fsharp +display (Shelf("Science fiction", 40)) +display (CustomerLabel("Ada")) +``` + +The concrete objects differ, but the consuming function needs only their shared +behavior. + +## Interface, union, or function parameter? + +These tools express different kinds of variation: + +- A discriminated union is closed: the type definition lists every case, and a + match without a wildcard can be checked for exhaustiveness. +- An interface is open: new classes can implement the contract without changing + its definition. +- A function parameter asks for one piece of behavior without requiring an + object to implement a named contract. + +Order status is a closed set of domain states, so a union fits. A .NET API may +ask for an interface implementation, so implement the interface. A pricing +operation that needs only `Money -> Money` can accept that function directly. + +Object-oriented and functional styles are not opposing teams. Choose the +smallest representation that accurately states what callers and implementations +need from one another. + +## Compiler clinic + +If a class claims to implement `IDisplayable` but omits `Display`, the compiler +reports that the interface has not been completely implemented. If `display` +tries to access `.Capacity`, it fails because `IDisplayable` does not promise +that property—even though one concrete shelf happens to have it. + +## Experiment + +- Implement `IDisplayable` for an order label. +- Add a second member to the interface and follow the compiler errors. +- Replace the interface-consuming function with one accepting `unit -> string`. +- Decide which version states the requirement more directly for that one call. + +## Summary + +An interface defines an open object contract. Classes provide implementations, +and consumers can depend on the promised members without depending on one +concrete class. diff --git a/public/documentation/wider-fsharp/55-controlled-mutation.fsx b/public/documentation/wider-fsharp/55-controlled-mutation.fsx new file mode 100644 index 0000000..ac54229 --- /dev/null +++ b/public/documentation/wider-fsharp/55-controlled-mutation.fsx @@ -0,0 +1,28 @@ +let stock = [| 2; 0; 4 |] +let sameStock = stock + +stock[1] <- 3 + +printfn "The shared array sees the update: %A" sameStock + +let restockedImmutably = + stock |> Array.mapi (fun index count -> if index = 0 then count + 2 else count) + +let receiveCopies shelfIndex delivered (counts: int array) = + if delivered > 0 then + counts[shelfIndex] <- counts[shelfIndex] + delivered + +let withReceivedCopies shelfIndex delivered counts = + counts + |> Array.mapi (fun index count -> if index = shelfIndex then count + delivered else count) + +printfn "Original after mutation: %A" stock +printfn "New immutable result: %A" restockedImmutably + +receiveCopies 2 1 stock +let anotherResult = withReceivedCopies 0 5 stock + +printfn "After receiveCopies: %A" stock +printfn "Returned successor: %A" anotherResult + +// Predict what sameStock sees after each mutation. diff --git a/public/documentation/wider-fsharp/55-controlled-mutation.md b/public/documentation/wider-fsharp/55-controlled-mutation.md new file mode 100644 index 0000000..4ed2ca3 --- /dev/null +++ b/public/documentation/wider-fsharp/55-controlled-mutation.md @@ -0,0 +1,104 @@ +# Controlled mutation and mutable arrays + +## What you will learn + +F# makes mutable bindings and mutable array elements explicit, so shared change +can be recognized and contained. + +## Change is available when you need it + +The course has emphasized immutable values because they make domain transformations easy to follow. F# is a multi-paradigm language, not a purely functional one. It also supports mutable bindings and array updates. + +The useful skill is knowing when mutation is local and helpful, then keeping it contained so the surrounding domain model remains easy to follow. + +## Mutable bindings + +A plain `let` creates an immutable binding: + +```fsharp +let count = 0 +``` + +Request mutation explicitly with `mutable` and assign with `<-`: + +```fsharp +let mutable count = 0 +count <- count + 1 +``` + +`<-` is assignment. Its distinct spelling separates mutation from `=`, which appears in bindings and equality expressions. + +Every reader can now see that `count` may change. That visibility is useful, but it also means the value at a line depends on the path taken through earlier assignments. + +## Mutating array elements + +Arrays permit indexed assignment: + +```fsharp +let stock = [| 2; 0; 4 |] +stock[1] <- 3 +// stock is now [| 2; 3; 4 |] +``` + +Unlike `Array.map`, this changes the existing array value observed by every reference to it. Keep such updates local when possible. + +If the domain operation is naturally expressed as a returned successor, prefer an immutable transformation: + +```fsharp +let restocked = + stock + |> Array.mapi (fun index value -> + if index = 1 then 3 else value) +``` + +Neither form is universally superior. The second makes old and new arrays distinct; the first can be appropriate inside a contained low-level algorithm or when using an imperative API. + +## References and shared change + +F# also has reference cells, but mutable bindings and arrays are enough for this tour. What matters is shared observable change: once several parts of a program can modify the same location, behavior depends on ordering and ownership. + +Keep mutation: + +- inside one small function; +- away from core domain values when immutable successors suffice; +- explicit in names and types where callers can observe it; +- out of higher-order callbacks unless the effect is the purpose. + +## Contain mutation behind a function + +```fsharp +let receiveCopies shelfIndex delivered (stock: int array) = + if delivered > 0 then + stock[shelfIndex] <- stock[shelfIndex] + delivered +``` + +The function's unit result signals that its purpose is the update. The type +`int array` does not reveal whether a function mutates the array, so its name +and documentation must make that effect clear. Every alias of the supplied +array observes the change. + +Compare it with an immutable alternative: + +```fsharp +let withReceivedCopies shelfIndex delivered stock = + stock + |> Array.mapi (fun index count -> + if index = shelfIndex then count + delivered else count) +``` + +This version returns another array and preserves the supplied one. The next +lesson introduces loops and shows how local mutable state can implement a +calculation without exposing that state to callers. + +## Experiment + +- Update one array element and observe another binding referring to the same array. +- Rewrite the update with `Array.mapi` to return a new value. +- Call both receiving functions and compare which arrays changed. +- Bind the result of `receiveCopies` and inspect its `unit` type. + +## Summary + +F# makes mutation opt-in at the binding or assignment site. Immutable +transformations remain the default for domain values; when shared mutation is +needed, keep its ownership and observable effects narrow and explicit. diff --git a/public/documentation/wider-fsharp/56-loops-and-iteration.fsx b/public/documentation/wider-fsharp/56-loops-and-iteration.fsx new file mode 100644 index 0000000..211f4f2 --- /dev/null +++ b/public/documentation/wider-fsharp/56-loops-and-iteration.fsx @@ -0,0 +1,24 @@ +let titles = [| "Kindred"; "Dune"; "Earthsea" |] + +printfn "Catalog with a for loop:" + +for index in 0 .. titles.Length - 1 do + printfn "%d. %s" (index + 1) titles[index] + +let totalCharactersImperative (values: string array) = + let mutable total = 0 + let mutable index = 0 + + while index < values.Length do + total <- total + values[index].Length + index <- index + 1 + + total + +let totalCharactersFunctional (values: string array) = + values |> Array.fold (fun total title -> total + title.Length) 0 + +printfn "Imperative total: %d" (totalCharactersImperative titles) +printfn "Functional total: %d" (totalCharactersFunctional titles) + +// Add another title and predict both totals before running. diff --git a/public/documentation/wider-fsharp/56-loops-and-iteration.md b/public/documentation/wider-fsharp/56-loops-and-iteration.md new file mode 100644 index 0000000..c350868 --- /dev/null +++ b/public/documentation/wider-fsharp/56-loops-and-iteration.md @@ -0,0 +1,112 @@ +# Loops and imperative iteration + +## What you will learn + +Use `for`, `while`, ranges, and iteration functions when repeated effects or a +contained imperative algorithm are the clearest tools. + +## Repeating an effect + +`List.map` and `Array.map` calculate new collections. When the purpose is an +effect such as printing every label, a `for` loop says so directly: + +```fsharp +for title in titles do + printfn "%s" title +``` + +The loop variable is a new immutable binding for each iteration. The loop as a +whole returns `unit` because its result is the repeated effect, not a collection +of transformed values. + +A `for` loop can traverse an inclusive range: + +```fsharp +for shelfNumber in 1 .. 5 do + printfn "Shelf %d" shelfNumber +``` + +`1 .. 5` produces the values one through five. A range can also use a step: + +```fsharp +for evenNumber in 2 .. 2 .. 10 do + printfn "%d" evenNumber +``` + +## Iteration functions + +Collections also provide higher-order functions for repeated effects: + +```fsharp +titles |> List.iter (fun title -> printfn "%s" title) +``` + +`List.iter` accepts a function returning `unit` and itself returns `unit`: + +```text +('a -> unit) -> 'a list -> unit +``` + +Use `map` when you need transformed values. Use `iter` or a loop when the effect +is the purpose. Calling `map` merely to print and then ignoring the returned +list communicates the wrong intention. + +## While a condition remains true + +A `while` loop checks a boolean condition before every iteration: + +```fsharp +let mutable index = 0 + +while index < titles.Length do + printfn "%s" titles[index] + index <- index + 1 +``` + +The programmer must update state so the condition eventually becomes false. +The type checker cannot prove termination or prevent an invalid index caused by +incorrect arithmetic. + +Use `while` when the stopping condition changes dynamically and does not fit a +simple collection traversal. For ordinary traversal, `for`, `iter`, or a +collection transformation is usually clearer. + +## A locally imperative calculation + +```fsharp +let totalStock counts = + let mutable total = 0 + + for count in counts do + total <- total + count + + total +``` + +The mutation is contained inside the function. Callers receive an ordinary +integer and cannot observe the accumulator. The same calculation is naturally +a fold: + +```fsharp +let totalStock counts = + counts |> Array.fold (fun total count -> total + count) 0 +``` + +The fold states the accumulation more directly. The loop may still be suitable +for an algorithm with several local indexes or when working with an imperative +API. Prefer the version whose state transitions are easiest to verify. + +## Experiment + +- Print the same titles with `for` and `List.iter`. +- Change an inclusive range and predict its final value. +- Trace the index in a `while` loop before running it. +- Remove the index update, reason about the result, and restore it without + running the non-terminating version. +- Rewrite the stock total using `Array.fold`. + +## Summary + +Loops and iteration functions repeat effects. Keep manual state and termination +conditions local, and prefer value-producing collection functions when a new +collection or summary is the actual result. diff --git a/public/documentation/wider-fsharp/57-exceptions.fsx b/public/documentation/wider-fsharp/57-exceptions.fsx new file mode 100644 index 0000000..0792b98 --- /dev/null +++ b/public/documentation/wider-fsharp/57-exceptions.fsx @@ -0,0 +1,27 @@ +type ImportError = InvalidImportedRecord of message: string + +let requireImportedTitle (title: string) = + let cleaned = title.Trim() + + if cleaned = "" then + failwith "Imported title was empty" + else + cleaned + +let importTitle title = + try + let validTitle = requireImportedTitle title + Ok validTitle + with ex -> + Error(InvalidImportedRecord ex.Message) + +let display result = + match result with + | Ok title -> $"Imported: %s{title}" + | Error(InvalidImportedRecord message) -> $"Import failed: %s{message}" + +printfn "%s" (importTitle " Kindred " |> display) +printfn "%s" (importTitle " " |> display) + +// Routine user validation should return Result directly rather than throw. +// Try both inputs, then rewrite blank-title handling as a direct Result. diff --git a/public/documentation/wider-fsharp/57-exceptions.md b/public/documentation/wider-fsharp/57-exceptions.md new file mode 100644 index 0000000..a7676f1 --- /dev/null +++ b/public/documentation/wider-fsharp/57-exceptions.md @@ -0,0 +1,116 @@ +# Exceptions and explicit errors + +## What you will learn + +Exceptions handle exceptional control flow, while Option and Result keep expected domain outcomes visible in types. + +## Two different kinds of failure + +The bookshop already represents expected outcomes with `Option` and `Result`: + +- a title may have no subtitle; +- a search may find nothing; +- checkout may be refused because stock is unavailable. + +Programs also encounter failures that are exceptional at the current boundary: a supposedly trusted imported record is corrupt, a supported external API throws, or an internal invariant is violated. F# supports exceptions for those cases. + +Choose the mechanism from the kind of failure. Ask whether callers are expected to branch on the outcome as part of normal domain behavior. + +## Raising an exception + +```fsharp +let requireImportedTitle (title: string) = + let cleaned = title.Trim() + + if cleaned = "" then + failwith "Imported title was empty" + else + cleaned +``` + +`failwith` raises an exception carrying the message. Unlike `Error`, it returns no union case. Evaluation stops and control searches outward for a matching handler. + +This function assumes imported data has already satisfied a contract. If blank titles are routine user input, `Result` validation is the better design. + +## Catching with `try/with` + +```fsharp +type ImportError = + | InvalidImportedRecord of message: string + +let importTitle title = + try + let validTitle = requireImportedTitle title + Ok validTitle + with + | ex -> Error (InvalidImportedRecord ex.Message) +``` + +`try ... with` is an expression. The successful body and every handler must produce compatible types; here both produce `Result`. + +The `ex` pattern binds the exception object, and `.Message` reads one of its properties. This boundary translates an exception-based API into the explicit error model used by the rest of the domain. + +## Specific exception patterns + +F# also supports type-test patterns: + +```fsharp +try + operation () +with +| :? System.ArgumentException as ex -> + Error ex.Message +| ex -> + Error ex.Message +``` + +Specific handlers must appear before the general `ex` pattern because the first matching pattern wins. Catch exception types documented by the .NET API at the boundary you are calling; avoid guessing from implementation details. + +## Keep the protected region narrow + +This is risky: + +```fsharp +let importSafely title = + try + importTitle title + with + | ex -> Error (InvalidImportedRecord ex.Message) +``` + +It can disguise a programming defect anywhere in the workflow as a friendly operational error. Wrap the smallest external or invariant-sensitive operation that is expected to throw, then translate or handle it deliberately. + +Avoid empty catch-all handlers. Silently continuing after an exception destroys information and can leave callers believing work succeeded. + +## Exception or Result? + +Use `Result` when: + +- failure is a normal modeled outcome; +- callers should inspect a typed reason; +- the operation naturally composes with other fallible domain functions. + +Exceptions are reasonable when: + +- a called API uses exceptions; +- a trusted invariant is unexpectedly broken; +- the current function cannot produce a meaningful local recovery value. + +The boundary is not absolute. What matters is whether failure should remain visible in normal control flow or interrupt it through exception handling. + +## Exceptions in .NET + +.NET exception types derive from `System.Exception`. A handler can match a specific exception type, bind the exception object, and inspect members such as `Message`. + +Catch the narrowest documented exception that the current boundary can handle meaningfully. Reserve a broad `ex` handler for an outer boundary that translates errors, and preserve useful diagnostic information there. Resource-owning .NET APIs introduce deterministic cleanup concerns, but filesystem and process infrastructure remain outside this tour. + +## Experiment + +- Pass a valid and blank imported title through `importTitle`. +- Move the `try` boundary outward and identify which unrelated mistakes it could hide. +- Rewrite routine blank-name validation with `Result` and compare the call sites. +- Add a specific handler before the general one, using only a supported operation. + +## Summary + +Results make expected failure explicit in a function's type. Exceptions provide non-local control flow for exceptional boundaries and invariant failures. Translate between them deliberately rather than treating either as a universal answer. diff --git a/src/App/App.fs b/src/App/App.fs index 9d92548..5f884b0 100644 --- a/src/App/App.fs +++ b/src/App/App.fs @@ -16,6 +16,12 @@ open System importSideEffects "./monaco-vite.js" importSideEffects "./styles.css" +let supressedWarningMessages = [| "https://aka.ms/fsharp-implicit-convs" |] + +let shouldBeSupressed (error: Error) = + supressedWarningMessages + |> Array.exists (fun supressedMessage -> error.Message.Contains supressedMessage) + module Helper = let inline mkProperty<'t> (key: string) (value: obj) : 't = (key, box value) |> unbox<'t> @@ -134,7 +140,12 @@ module WebWorker = |> Observable.add (function | Loaded _ -> () | LoadFailed -> dispatch (AddConsoleLog(LogLevel.Error, "The F# compiler could not load.")) - | ParsedCode errors -> errors |> Editor.mapErrorToMarker |> SetMarkers |> dispatch + | ParsedCode errors -> + errors + |> Array.filter (shouldBeSupressed >> not) + |> Editor.mapErrorToMarker + |> SetMarkers + |> dispatch | CompilationFinished(code, lang, errors, stats) -> dispatch (Compiled(code, lang, errors, stats)) | CompilationsFinished(code, lang, errors, stats) -> () | CompilerCrashed msg -> dispatch (AddConsoleLog(LogLevel.Error, "Compiler failed: " + msg)) @@ -268,6 +279,8 @@ let update msg model = { model with Logs = logs }, Cmd.none | Compiled(_, _, _, _) when model.CompilingRevision <> Some model.CodeRevision -> model, Cmd.none | Compiled(code, _, errors, _) -> + let errors = errors |> Array.filter (shouldBeSupressed >> not) + let logs = if errors.Length = 0 then model.Logs @@ -289,7 +302,7 @@ let update msg model = model, Cmd.batch [ errors |> Editor.mapErrorToMarker |> SetMarkers |> Cmd.ofMsg - if errors.Length = 0 then + if errors.Length = 0 || Array.forall _.IsWarning errors then Cmd.OfFunc.perform Iframe.generateHtmlBlobUrl code SetIFrameUrl else Cmd.none