├── .nvmrc ├── .prettierignore ├── .babelrc ├── docs ├── public │ ├── share.png │ ├── favicon.ico │ ├── share-large.jpg │ ├── favicon-16x16.png │ ├── favicon-180x180.png │ ├── favicon-32x32.png │ └── global.css ├── markdoc │ ├── nodes │ │ ├── index.ts │ │ ├── link.markdoc.ts │ │ ├── fence.markdoc.ts │ │ └── heading.markdoc.ts │ └── tags │ │ ├── index.ts │ │ ├── feature.markdoc.ts │ │ ├── hero.markdoc.ts │ │ └── callout.markdoc.ts ├── components │ ├── layouts │ │ ├── index.ts │ │ ├── ErrorLayout.tsx │ │ ├── types.ts │ │ ├── HomeLayout.tsx │ │ └── DocsLayout.tsx │ ├── shell │ │ ├── index.ts │ │ ├── Footer.tsx │ │ ├── SectionNav.tsx │ │ ├── SideNav.tsx │ │ ├── ThemeToggle.tsx │ │ └── Header.tsx │ ├── Hidden.tsx │ ├── Link.tsx │ ├── tags │ │ ├── Feature.tsx │ │ ├── Callout.tsx │ │ └── Hero.tsx │ ├── nodes │ │ ├── Heading.tsx │ │ └── Fence.tsx │ └── Icon.tsx ├── utils.ts ├── next-env.d.ts ├── next.config.mjs ├── tsconfig.json └── pages │ ├── 404.tsx │ ├── docs │ ├── getting-started.md │ ├── assertions.md │ ├── guards.md │ ├── utils.md │ ├── origin-story.md │ ├── opaques.md │ └── checks.md │ ├── markdoc-prose.md │ ├── index.md │ └── _app.tsx ├── utils └── package.json ├── checks └── package.json ├── guards └── package.json ├── opaques └── package.json ├── src ├── utils │ ├── index.ts │ ├── error.ts │ ├── object.test.ts │ ├── error.test.ts │ └── object.ts ├── checks │ ├── index.ts │ ├── utils.ts │ ├── number.ts │ ├── utils.test.ts │ └── number.test.ts ├── opaques.ts ├── index.ts ├── types.ts ├── testing.ts ├── opaques.test.ts ├── assertions.ts ├── guards.ts ├── assertions.test.ts └── guards.test.ts ├── assertions └── package.json ├── .changeset ├── config.json └── README.md ├── .prettierrc.js ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ └── bug_report.md ├── workflows │ ├── release.yml │ └── tests.yml ├── actions │ └── setup-deps │ │ └── action.yml ├── stale.yml └── assets │ └── banner.svg ├── .eslintrc.js ├── tsconfig.json ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/gallium 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript", "@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/public/share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thinkmill/emery/HEAD/docs/public/share.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thinkmill/emery/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/share-large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thinkmill/emery/HEAD/docs/public/share-large.jpg -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thinkmill/emery/HEAD/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thinkmill/emery/HEAD/docs/public/favicon-180x180.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Thinkmill/emery/HEAD/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/emery-utils.cjs.js", 3 | "module": "dist/emery-utils.esm.js" 4 | } 5 | -------------------------------------------------------------------------------- /checks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/emery-checks.cjs.js", 3 | "module": "dist/emery-checks.esm.js" 4 | } 5 | -------------------------------------------------------------------------------- /guards/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/emery-guards.cjs.js", 3 | "module": "dist/emery-guards.esm.js" 4 | } 5 | -------------------------------------------------------------------------------- /opaques/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/emery-opaques.cjs.js", 3 | "module": "dist/emery-opaques.esm.js" 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { getErrorMessage } from './error'; 2 | export { typedEntries, typedKeys } from './object'; 3 | -------------------------------------------------------------------------------- /assertions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "dist/emery-assertions.cjs.js", 3 | "module": "dist/emery-assertions.esm.js" 4 | } 5 | -------------------------------------------------------------------------------- /docs/markdoc/nodes/index.ts: -------------------------------------------------------------------------------- 1 | export { fence } from './fence.markdoc'; 2 | export { heading } from './heading.markdoc'; 3 | export { link } from './link.markdoc'; 4 | -------------------------------------------------------------------------------- /docs/components/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export { ErrorLayout } from './ErrorLayout'; 2 | export { DocsLayout } from './DocsLayout'; 3 | export { HomeLayout } from './HomeLayout'; 4 | -------------------------------------------------------------------------------- /docs/components/layouts/ErrorLayout.tsx: -------------------------------------------------------------------------------- 1 | import { LayoutProps } from './types'; 2 | 3 | export const ErrorLayout = ({ children }: LayoutProps) => { 4 | return <>{children}; 5 | }; 6 | -------------------------------------------------------------------------------- /docs/utils.ts: -------------------------------------------------------------------------------- 1 | type MaybeClassname = string | false | null | undefined; 2 | export function joinClasses(classes: MaybeClassname[]) { 3 | return classes.filter(Boolean).join(' '); 4 | } 5 | -------------------------------------------------------------------------------- /docs/components/layouts/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { MarkdocNextJsPageProps } from '@markdoc/next.js'; 3 | 4 | export type LayoutProps = { children: ReactNode; markdoc: MarkdocNextJsPageProps['markdoc'] }; 5 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "Thinkmill/emery" }], 4 | "access": "public", 5 | "baseBranch": "main" 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | arrowParens: 'avoid', 5 | printWidth: 100, 6 | tabWidth: 2, 7 | useTabs: false, 8 | semi: true, 9 | bracketSpacing: true, 10 | }; 11 | -------------------------------------------------------------------------------- /docs/markdoc/tags/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error markdocs doesn't yet expose this properly 2 | export { comment } from '@markdoc/next.js/tags'; 3 | 4 | export { callout } from './callout.markdoc'; 5 | export { feature } from './feature.markdoc'; 6 | export { hero } from './hero.markdoc'; 7 | -------------------------------------------------------------------------------- /src/checks/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | isEven, 3 | isFinite, 4 | isFloat, 5 | isInfinite, 6 | isInteger, 7 | isNegative, 8 | isNegativeZero, 9 | isNonNegative, 10 | isNonPositive, 11 | isOdd, 12 | isPositive, 13 | } from './number'; 14 | export { checkAll, checkAllWith, negate } from './utils'; 15 | -------------------------------------------------------------------------------- /docs/components/shell/index.ts: -------------------------------------------------------------------------------- 1 | export { Footer } from './Footer'; 2 | export { Header } from './Header'; 3 | export { SideNav, SideNavContext, useSidenavState } from './SideNav'; 4 | export { SectionNav } from './SectionNav'; 5 | export { ThemeToggle } from './ThemeToggle'; 6 | 7 | export type { Section } from './SectionNav'; 8 | -------------------------------------------------------------------------------- /docs/markdoc/nodes/link.markdoc.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error markdocs doesn't yet expose this properly 2 | import { link as nextLink } from '@markdoc/next.js/tags'; 3 | import { MarkdocNextJsSchema } from '@markdoc/next.js'; 4 | 5 | import { Link } from '../../components/Link'; 6 | 7 | export const link: MarkdocNextJsSchema = { 8 | ...nextLink, 9 | render: Link, 10 | }; 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Ask a Question 4 | url: https://github.com/thinkmill/emery/discussions/categories/questions 5 | about: Ask the community for help 6 | - name: Request a Feature 7 | url: https://github.com/thinkmill/emery/discussions/categories/ideas 8 | about: Share ideas for new features 9 | -------------------------------------------------------------------------------- /docs/components/layouts/HomeLayout.tsx: -------------------------------------------------------------------------------- 1 | import { LayoutProps } from './types'; 2 | 3 | export const HomeLayout = ({ children }: LayoutProps) => { 4 | return ( 5 |
6 | {children} 7 | 12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | sourceType: 'module', 11 | }, 12 | plugins: ['@typescript-eslint'], 13 | rules: {}, 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "jsx": "preserve" 13 | }, 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /docs/markdoc/nodes/fence.markdoc.ts: -------------------------------------------------------------------------------- 1 | import { MarkdocNextJsSchema } from '@markdoc/next.js'; 2 | import { Fence } from '../../components/nodes/Fence'; 3 | 4 | export const fence: MarkdocNextJsSchema = { 5 | render: Fence, 6 | attributes: { 7 | content: { type: String }, 8 | /** The programming language of the code block. Place it after the backticks. */ 9 | language: { type: String }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /docs/markdoc/tags/feature.markdoc.ts: -------------------------------------------------------------------------------- 1 | import { MarkdocNextJsSchema } from '@markdoc/next.js'; 2 | import { Feature } from '../../components//tags/Feature'; 3 | 4 | /** Display a feature box. */ 5 | export const feature: MarkdocNextJsSchema = { 6 | render: Feature, 7 | children: ['fence', 'heading', 'list', 'paragraph'], 8 | attributes: { 9 | /** Controls the horizontal layout direction on large devices. */ 10 | reverse: { 11 | type: Boolean, 12 | }, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /docs/next.config.mjs: -------------------------------------------------------------------------------- 1 | import withMarkdoc from '@markdoc/next.js'; 2 | 3 | const markdocConfig = { schemaPath: 'docs/markdoc' }; 4 | 5 | // @ts-check 6 | /** 7 | * @type {import('next').NextConfig} 8 | **/ 9 | const nextConfig = { 10 | pageExtensions: ['ts', 'tsx', 'md'], 11 | 12 | redirects() { 13 | return [ 14 | { 15 | source: '/docs', 16 | destination: '/docs/getting-started', 17 | permanent: false, 18 | }, 19 | ]; 20 | }, 21 | }; 22 | 23 | export default withMarkdoc(markdocConfig)(nextConfig); 24 | -------------------------------------------------------------------------------- /docs/markdoc/tags/hero.markdoc.ts: -------------------------------------------------------------------------------- 1 | import { MarkdocNextJsSchema } from '@markdoc/next.js'; 2 | import { Hero } from '../../components/tags/Hero'; 3 | 4 | /** Display a hero box. */ 5 | export const hero: MarkdocNextJsSchema = { 6 | render: Hero, 7 | children: ['fence', 'heading', 'paragraph'], 8 | attributes: { 9 | /** Influences the appearance of the hero box. */ 10 | prominence: { 11 | type: String, 12 | default: 'tertiary', 13 | matches: ['primary', 'secondary', 'tertiary'], 14 | errorLevel: 'critical', 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /docs/markdoc/tags/callout.markdoc.ts: -------------------------------------------------------------------------------- 1 | import { MarkdocNextJsSchema } from '@markdoc/next.js'; 2 | import { Callout, calloutTones } from '../../components/tags/Callout'; 3 | 4 | /** Display the enclosed content in a callout box. */ 5 | export const callout: MarkdocNextJsSchema = { 6 | render: Callout, 7 | children: ['paragraph', 'tag', 'list'], 8 | attributes: { 9 | /** Controls the color and icon of the callout. */ 10 | tone: { 11 | type: String, 12 | default: 'neutral', 13 | matches: calloutTones, 14 | errorLevel: 'critical', 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@v3 15 | 16 | - uses: ./.github/actions/setup-deps 17 | 18 | - name: 'Create Pull Request or Publish to npm' 19 | uses: changesets/action@v1 20 | with: 21 | publish: yarn release 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /docs/components/Hidden.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { joinClasses } from '../utils'; 3 | 4 | type Break = 'mobile' | 'tablet'; 5 | type HiddenProps = { above?: Break; below?: Break; children: React.ReactElement }; 6 | 7 | export function Hidden({ above, below, children }: HiddenProps) { 8 | const className = joinClasses([ 9 | children?.props?.className, 10 | above && `hidden-above-${above}`, 11 | below && `hidden-below-${below}`, 12 | ]); 13 | 14 | if (!React.isValidElement(children)) { 15 | throw TypeError('`Hidden` expects a React element child.'); 16 | } 17 | 18 | return React.cloneElement(children, { className }); 19 | } 20 | -------------------------------------------------------------------------------- /src/checks/utils.ts: -------------------------------------------------------------------------------- 1 | import { UnaryPredicate } from '../types'; 2 | 3 | /** 4 | * Returns a new function for checking *all* cases against a value, a bit 5 | * like `pipe` for predicates. 6 | */ 7 | export function checkAll(...predicates: UnaryPredicate[]) { 8 | return (value: T) => predicates.every(p => p(value)); 9 | } 10 | 11 | /** Apply *all* checks against a value. */ 12 | export function checkAllWith(value: T, ...predicates: UnaryPredicate[]) { 13 | return checkAll(...predicates)(value); 14 | } 15 | 16 | /** Returns a new negated version of the stated predicate function. */ 17 | export function negate(predicate: UnaryPredicate) { 18 | return (value: T) => !predicate(value); 19 | } 20 | -------------------------------------------------------------------------------- /docs/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import React, { AllHTMLAttributes } from 'react'; 2 | import NextLink from 'next/link'; 3 | 4 | type AnchorProps = AllHTMLAttributes; 5 | type LinkProps = { 6 | children: AnchorProps['children']; 7 | className?: AnchorProps['className']; 8 | href: string; 9 | target?: AnchorProps['target']; 10 | }; 11 | 12 | export function Link(props: LinkProps) { 13 | const target = props.target || (props.href.startsWith('http') ? '_blank' : undefined); 14 | 15 | return ( 16 | 17 | 22 | {props.children} 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/opaques.ts: -------------------------------------------------------------------------------- 1 | import { Opaque, Transparent, ValidOpaqueValues } from './types'; 2 | 3 | /** 4 | * A generic helper function that takes a primitive value, and returns the value 5 | * after casting it to the provided opaque type. 6 | */ 7 | // 1. extend `Opaque` to exclude transparent types e.g. `castToOpaque(1)` 8 | // 2. default `never` to prohibit unfulfilled type e.g. `castToOpaque(1)` 9 | // 3. explicit `Transparent` to prevent invalid values e.g. `castToOpaque(1)` 10 | // 4. cast `unknown` first to avoid invalid expression instantiation 11 | export function castToOpaque< 12 | OpaqueType extends Opaque /* 1. */ = never /* 2. */, 13 | >(value: Transparent /* 3. */) { 14 | /* 4. */ 15 | return value as unknown as OpaqueType; 16 | } 17 | -------------------------------------------------------------------------------- /.github/actions/setup-deps/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Setup Dependencies' 2 | runs: 3 | using: 'composite' 4 | steps: 5 | - name: Setup Node.js LTS 6 | uses: actions/setup-node@v3 7 | with: 8 | node-version: lts/* 9 | 10 | - name: Get yarn cache directory path 11 | id: yarn-cache-dir-path 12 | run: echo "::set-output name=dir::$(yarn cache dir)" 13 | shell: bash 14 | 15 | - uses: actions/cache@v3 16 | id: yarn-cache 17 | with: 18 | path: | 19 | ${{ steps.yarn-cache-dir-path.outputs.dir }} 20 | node_modules 21 | key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} 22 | restore-keys: | 23 | ${{ runner.os }}-yarn- 24 | 25 | - name: Install Dependencies 26 | run: yarn --frozen-lockfile 27 | shell: bash 28 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | tests: 11 | name: Tests 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | steps: 16 | - name: Checkout repo 17 | uses: actions/checkout@v3 18 | 19 | - uses: ./.github/actions/setup-deps 20 | 21 | - name: Unit tests 22 | run: yarn jest --ci --runInBand 23 | 24 | linting: 25 | name: Linting 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout repo 29 | uses: actions/checkout@v3 30 | 31 | - uses: ./.github/actions/setup-deps 32 | 33 | - name: Prettier 34 | run: yarn lint:prettier 35 | 36 | - name: ESLint 37 | run: yarn lint:eslint 38 | 39 | - name: TypeScript 40 | run: yarn lint:types 41 | -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | import { ErrorLike } from '../types'; 2 | 3 | /** 4 | * Simplifies `error` handling in `try...catch` statements. 5 | * 6 | * JavaScript is weird, you can `throw` anything. Since it's possible for 7 | * library authors to throw something unexpected, we have to take precautions. 8 | */ 9 | 10 | export function getErrorMessage(error: unknown, fallbackMessage = 'Unknown error') { 11 | if (isErrorLike(error)) { 12 | return error.message; 13 | } 14 | 15 | return error ? JSON.stringify(error) : fallbackMessage; 16 | } 17 | 18 | /** Handle situations where the error object isn't an _actual_ error. */ 19 | function isErrorLike(error: unknown): error is ErrorLike { 20 | return ( 21 | typeof error === 'object' && 22 | error !== null && 23 | 'message' in error && 24 | typeof (error as Record).message === 'string' 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/object.test.ts: -------------------------------------------------------------------------------- 1 | import { typedEntries, typedKeys, typedObjectFromEntries } from './object'; 2 | 3 | describe('utils/object', () => { 4 | describe('typedEntries', () => { 5 | it('should return the correct entries', () => { 6 | const result = typedEntries({ foo: 1, bar: 2 }); 7 | expect(result).toEqual([ 8 | ['foo', 1], 9 | ['bar', 2], 10 | ]); 11 | }); 12 | }); 13 | describe('typedKeys', () => { 14 | it('should return the correct keys', () => { 15 | const result = typedKeys({ foo: 1, bar: 2 }); 16 | expect(result).toEqual(['foo', 'bar']); 17 | }); 18 | }); 19 | describe('typedObjectFromEntries', () => { 20 | it('should return the correct object', () => { 21 | const result = typedObjectFromEntries([ 22 | ['foo', 1], 23 | ['bar', 2], 24 | ]); 25 | expect(result).toEqual({ foo: 1, bar: 2 }); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 120 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 360 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - verified 9 | - high-priority 10 | - security 11 | # Label to use when marking an issue as stale 12 | staleLabel: needs-review 13 | # Comment to post when marking an issue as stale. Set to `false` to disable 14 | markComment: > 15 | It looks like there hasn't been any activity here in over 6 months. 16 | Sorry about that! We've flagged this issue for special attention. 17 | It will be manually reviewed by maintainers, not automatically closed. 18 | If you have any additional information please leave us a comment. 19 | It really helps! Thank you for you contribution. :) 20 | # Comment to post when closing a stale issue. Set to `false` to disable 21 | closeComment: false 22 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Functions 2 | // ------------------------------ 3 | 4 | export { assert, assertNever, warning } from './assertions'; 5 | 6 | export { 7 | isEven, 8 | isFinite, 9 | isFloat, 10 | isInfinite, 11 | isInteger, 12 | isNegative, 13 | isNegativeZero, 14 | isNonNegative, 15 | isNonPositive, 16 | isOdd, 17 | isPositive, 18 | } from './checks/number'; 19 | export { checkAll, checkAllWith, negate } from './checks/utils'; 20 | 21 | export { 22 | isBoolean, 23 | isDefined, 24 | isNonEmptyArray, 25 | isNull, 26 | isNullish, 27 | isNumber, 28 | isString, 29 | isUndefined, 30 | isFulfilled, 31 | isRejected, 32 | } from './guards'; 33 | 34 | export { castToOpaque } from './opaques'; 35 | 36 | export { getErrorMessage } from './utils/error'; 37 | export { typedEntries, typedKeys, typedObjectFromEntries } from './utils/object'; 38 | 39 | // Types 40 | // ------------------------------ 41 | 42 | export type { Nullish, Opaque, UnaryPredicate } from './types'; 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Platform 2 | .DS_Store 3 | 4 | # Build output 5 | dist 6 | 7 | # Next.js build output 8 | .next 9 | 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # Diagnostic reports (https://nodejs.org/api/report.html) 19 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # Compiled binary addons (https://nodejs.org/api/addons.html) 32 | build/Release 33 | 34 | # Dependency directories 35 | node_modules/ 36 | jspm_packages/ 37 | 38 | # TypeScript cache 39 | *.tsbuildinfo 40 | 41 | # Optional npm cache directory 42 | .npm 43 | 44 | # Optional eslint cache 45 | .eslintcache 46 | 47 | # Optional REPL history 48 | .node_repl_history 49 | 50 | # Output of 'npm pack' 51 | *.tgz 52 | 53 | # Yarn Integrity file 54 | .yarn-integrity 55 | 56 | # dotenv environment variables file 57 | .env 58 | .env.test 59 | -------------------------------------------------------------------------------- /src/utils/error.test.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessage } from './error'; 2 | 3 | describe('utils/errors', () => { 4 | describe('getErrorMessage', () => { 5 | it('should validate real errors', () => { 6 | expect(getErrorMessage(new Error('Error text'))).toBe('Error text'); 7 | }); 8 | it('should validate error-like objects', () => { 9 | expect(getErrorMessage({ message: 'Object text' })).toBe('Object text'); 10 | }); 11 | it('should return the default fallback message', () => { 12 | expect(getErrorMessage(undefined)).toBe('Unknown error'); 13 | }); 14 | it("should return the consumer's fallback message", () => { 15 | expect(getErrorMessage(undefined, 'Custom message')).toBe('Custom message'); 16 | }); 17 | it('should return the stringified content for "truthy" error args', () => { 18 | const obj = { misshapen: 'Message text' }; 19 | expect(getErrorMessage(obj)).toBe(JSON.stringify(obj)); 20 | expect(getErrorMessage(123)).toBe('123'); 21 | expect(getErrorMessage('Text')).toBe('"Text"'); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | // Misc. types 4 | // ------------------------------ 5 | 6 | export type ErrorLike = { message: string }; 7 | 8 | export type ObjectEntry = { [K in keyof T]-?: [K, T[K]] }[keyof T]; 9 | 10 | export type Nullish = null | undefined; 11 | 12 | export type UnaryPredicate = (value: T) => boolean; 13 | 14 | // Opaque types 15 | // ------------------------------ 16 | 17 | declare const OPAQUE_TAG: unique symbol; 18 | declare type Tagged = { readonly [OPAQUE_TAG]: Token }; 19 | 20 | export type ValidOpaqueValues = bigint | number | string | symbol; 21 | 22 | /** Create an opaque type. */ 23 | export type Opaque = Type & Tagged; 24 | 25 | /** @private Extract the transparent type from an opaque type. */ 26 | export type Transparent = OpaqueType extends bigint 27 | ? bigint 28 | : OpaqueType extends number 29 | ? number 30 | : OpaqueType extends string 31 | ? string 32 | : OpaqueType extends symbol 33 | ? symbol 34 | : never; 35 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering contributing! 4 | 5 | Note that we have a [Code of Conduct](https://github.com/Thinkmill/emery/blob/main/CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. 6 | 7 | ## Documentation 8 | 9 | If you think an area of the docs could be improved—please raise a pull request! 10 | 11 | ## Bug 12 | 13 | If you spot a bug, please create [an issue](https://github.com/Thinkmill/emery/issues). Before raising a bug please search through our open and closed issues to see if there's something similar. When you create an issue you will be prompted with the details we would like you to provide. 14 | 15 | ## Feature request 16 | 17 | If you'd like to request a feature, create an ["idea" discussion](https://github.com/Thinkmill/emery/discussions/categories/ideas). Be sure to look through existing ideas to see if there's something similar. If you find a similar idea show that it's important to you by voting for it (↑). 18 | 19 | Please do not raise a pull request that adds features without creating a discussion, it will likely not be accepted. 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Thinkmill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/pages/404.tsx: -------------------------------------------------------------------------------- 1 | export default function PageNotFound() { 2 | return ( 3 |
4 |

404

5 |

This page could not be found.

6 | 31 |
32 | ); 33 | } 34 | 35 | export async function getStaticProps() { 36 | return { props: { isErrorPage: true } }; 37 | } 38 | -------------------------------------------------------------------------------- /src/testing.ts: -------------------------------------------------------------------------------- 1 | import { typedKeys } from './utils/object'; 2 | 3 | const obj = { a: 1, b: 2, c: 'three' }; 4 | const arr = Object.values(obj); 5 | const valuesByType = { 6 | boolean: [true, false], 7 | complex: [arr, obj, new Date()], 8 | null: [null], 9 | number: [1, -1, 0, -0, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER], 10 | string: ['', 'emery', JSON.stringify(obj)], 11 | undefined: [undefined], 12 | }; 13 | type ValueKey = keyof typeof valuesByType; 14 | 15 | // https://developer.mozilla.org/en-US/docs/Glossary/Falsy 16 | export const falsyValues = [false, 0, -0, '', null, undefined, NaN]; 17 | // https://developer.mozilla.org/en-US/docs/Glossary/Truthy 18 | export const truthyValues = [true, 1, -1, 'test', {}, []]; 19 | 20 | export function getValuesByType(keyOrKeys: ValueKey | ValueKey[]) { 21 | if (Array.isArray(keyOrKeys)) { 22 | return keyOrKeys.map(key => valuesByType[key]).flat(); 23 | } 24 | 25 | return valuesByType[keyOrKeys]; 26 | } 27 | 28 | export function getValuesByTypeWithout(excludedKeyOrKeys: ValueKey | ValueKey[]) { 29 | return typedKeys(valuesByType) 30 | .filter(key => { 31 | if (Array.isArray(excludedKeyOrKeys)) { 32 | return !excludedKeyOrKeys.includes(key); 33 | } 34 | 35 | return key !== excludedKeyOrKeys; 36 | }) 37 | .map(key => valuesByType[key]) 38 | .flat(); 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | import { ObjectEntry } from '../types'; 2 | 3 | /** 4 | * An alternative to `Object.entries()` that avoids type widening. 5 | * 6 | * @example 7 | * Object.entries({ foo: 1, bar: 2 }) // [string, number][] 8 | * typedEntries({ foo: 1, bar: 2 }) // ["foo" | "bar", number][] 9 | */ 10 | export function typedEntries(value: T) { 11 | return Object.entries(value) as ObjectEntry[]; 12 | } 13 | 14 | /** 15 | * An alternative to `Object.keys()` that avoids type widening. 16 | * 17 | * @example 18 | * Object.keys({ foo: 1, bar: 2 }) // string[] 19 | * typedKeys({ foo: 1, bar: 2 }) // ("foo" | "bar")[] 20 | */ 21 | export function typedKeys(value: T) { 22 | return Object.keys(value) as Array; 23 | } 24 | 25 | /** 26 | * An alternative to `Object.fromEntries()` that avoids type widening. Must be 27 | * used in conjunction with `typedEntries` or `typedKeys`. 28 | * 29 | * @example 30 | * const obj = { name: 'Alice', age: 30 }; 31 | * const rebuilt1 = Object.fromEntries(Object.entries(obj)); 32 | * // ^? { [k: string]: string | number } 33 | * const rebuilt2 = typedObjectFromEntries(typedEntries(obj)); 34 | * // ^? { name: string, age: number } 35 | */ 36 | export function typedObjectFromEntries(entries: ObjectEntry[]) { 37 | return Object.fromEntries(entries) as T; 38 | } 39 | -------------------------------------------------------------------------------- /src/checks/number.ts: -------------------------------------------------------------------------------- 1 | import { UnaryPredicate } from '../types'; 2 | 3 | import { negate } from './utils'; 4 | 5 | /** Checks whether a number is a finite */ 6 | export const isFinite: UnaryPredicate = Number.isFinite; 7 | 8 | /** Checks whether a number is a infinite */ 9 | export const isInfinite = negate(isFinite); 10 | 11 | /** Checks whether a number is an integer */ 12 | export const isInteger: UnaryPredicate = Number.isInteger; 13 | 14 | /** Checks whether a number is a float */ 15 | export const isFloat = negate(isInteger); 16 | 17 | /** Checks whether a number is even. */ 18 | export const isEven = (value: number) => value % 2 === 0; 19 | 20 | /** Checks whether a number is odd. */ 21 | export const isOdd = (value: number) => Math.abs(value % 2) === 1; 22 | 23 | /** Checks whether a number is negative zero */ 24 | export const isNegativeZero = (value: number) => 1 / value === Number.NEGATIVE_INFINITY; 25 | 26 | /** Checks whether a number is negative */ 27 | export const isNegative = (value: number) => value < 0; 28 | 29 | /** Checks whether a number is positive */ 30 | export const isPositive = (value: number) => value > 0; 31 | 32 | /** Checks whether a number is non-negative */ 33 | export const isNonNegative = (value: number) => value >= 0; 34 | 35 | /** Checks whether a number is non-positive */ 36 | export const isNonPositive = (value: number) => value <= 0; 37 | -------------------------------------------------------------------------------- /docs/markdoc/nodes/heading.markdoc.ts: -------------------------------------------------------------------------------- 1 | import { MarkdocNextJsSchema } from '@markdoc/next.js'; 2 | import { Config, Node, RenderableTreeNode, Tag } from '@markdoc/markdoc'; 3 | 4 | import { Heading } from '../../components/nodes/Heading'; 5 | 6 | export const heading: MarkdocNextJsSchema = { 7 | render: Heading, 8 | children: ['inline'], 9 | attributes: { 10 | id: { type: String }, 11 | level: { type: Number, required: true, default: 1 }, 12 | className: { type: String }, 13 | }, 14 | transform(node: Node, config: Config) { 15 | const attributes = node.transformAttributes(config); 16 | const children = node.transformChildren(config); 17 | const id = generateID(children, attributes); 18 | 19 | // NOTE: would prefer "ts-expect-error", but next.js fails to compile: 20 | // Type error: Unused '@ts-expect-error' directive. 21 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 22 | // @ts-ignore 23 | return new Tag(this.render, { ...attributes, id }, children); 24 | }, 25 | }; 26 | 27 | function generateID(children: RenderableTreeNode[], attributes: Record) { 28 | if (attributes.id && typeof attributes.id === 'string') { 29 | return attributes.id; 30 | } 31 | return children 32 | .filter(child => typeof child === 'string') 33 | .join(' ') 34 | .replace(/[?]/g, '') 35 | .replace(/[A-Z]/g, s => '-' + s) 36 | .replace(/\s+/g, '-') 37 | .replace(/^-/, '') 38 | .toLowerCase(); 39 | } 40 | -------------------------------------------------------------------------------- /docs/components/tags/Feature.tsx: -------------------------------------------------------------------------------- 1 | import React, { Children, isValidElement, ReactNode } from 'react'; 2 | 3 | type FeatureProps = { children: ReactNode; reverse?: boolean }; 4 | 5 | export const Feature = ({ children, reverse }: FeatureProps) => { 6 | const [fence, ...content] = flattenElements(children).reverse(); 7 | 8 | return ( 9 |
10 |
{content.reverse()}
11 |
{fence}
12 | 45 |
46 | ); 47 | }; 48 | 49 | function flattenElements(children: ReactNode) { 50 | return Children.toArray(children).filter(isValidElement); 51 | } 52 | -------------------------------------------------------------------------------- /src/opaques.test.ts: -------------------------------------------------------------------------------- 1 | import { castToOpaque } from './opaques'; 2 | import { Opaque } from './types'; 3 | 4 | describe('opaques', () => { 5 | describe('castToOpaque', () => { 6 | it('should be equivalent to an identity function at runtime', () => { 7 | type OpaqueString = Opaque; 8 | type OpaqueNumber = Opaque; 9 | type OpaqueBigInt = Opaque; 10 | type OpaqueSymbol = Opaque; 11 | 12 | const opaqueString = castToOpaque('string'); 13 | const opaqueNumber = castToOpaque(1); 14 | const opaqueBigInt = castToOpaque(BigInt(1)); 15 | const opaqueSymbol = castToOpaque(Symbol('symbol')); 16 | 17 | expect(opaqueString).toBe('string'); 18 | expect(opaqueNumber).toBe(1); 19 | expect(opaqueBigInt).toBe(BigInt(1)); 20 | expect(opaqueSymbol.toString()).toBe(Symbol('symbol').toString()); 21 | }); 22 | 23 | it('should expect TS error when Token parameter omitted', () => { 24 | // @ts-expect-error the second parameter `Token` is required 25 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 26 | type AccountNumber = Opaque; 27 | }); 28 | it('should expect TS error when variable assigned with type declaration', () => { 29 | // @ts-expect-error variable with type declaration not allowed 30 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 31 | const value: OpaqueNumber = 123; 32 | }); 33 | it('should expect TS error when called without explicit type', () => { 34 | // @ts-expect-error must be called with an explicit type 35 | castToOpaque('string'); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/assertions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Asserts that a condition is `true`, ensuring that whatever condition is being 3 | * checked must be true for the remainder of the containing scope. 4 | * 5 | * @throws when the condition is `false` 6 | */ 7 | // NOTE: The narrow type of `boolean` instead of something like `unknown` is an 8 | // intentional design decision. The goal is to promote consideration from 9 | // consumers when dealing with potentially ambiguous conditions like `0` or 10 | // `''`, which can introduce "subtle" bugs. 11 | export function assert(condition: boolean, message = 'Assert failed'): asserts condition { 12 | if (!condition) { 13 | throw new TypeError(message); 14 | } 15 | } 16 | 17 | /** 18 | * Asserts that allegedly unreachable code has been executed. 19 | * 20 | * @throws always 21 | */ 22 | export function assertNever(arg: never): never { 23 | throw new Error('Expected never to be called, but received: ' + JSON.stringify(arg)); 24 | } 25 | 26 | /** 27 | * Similar to `assert` but only logs a warning if the condition is not met. Only 28 | * logs in development. 29 | */ 30 | export function warning(condition: boolean, message: string) { 31 | if (!(process.env.NODE_ENV === 'production')) { 32 | if (condition) { 33 | return; 34 | } 35 | 36 | // follow message prefix convention 37 | const text = `Warning: ${message}`; 38 | 39 | // IE9 support, console only with open devtools 40 | if (typeof console !== 'undefined') { 41 | console.warn(text); 42 | } 43 | 44 | // NOTE: throw and catch immediately to provide a stack trace: 45 | // https://developer.chrome.com/blog/automatically-pause-on-any-exception/ 46 | try { 47 | throw Error(text); 48 | // eslint-disable-next-line no-empty 49 | } catch (x) {} 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/pages/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started 3 | description: How to install, import, and get the most out of Emery 4 | --- 5 | 6 | # {% $markdoc.frontmatter.title %} 7 | 8 | Follow the instructions below to use Emery in your application. 9 | 10 | ## Install 11 | 12 | Install the library: 13 | 14 | ```shell 15 | npm install emery 16 | ``` 17 | 18 | or 19 | 20 | ```shell 21 | yarn add emery 22 | ``` 23 | 24 | ## Import 25 | 26 | Import the library: 27 | 28 | ```js 29 | const emery = require('emery'); 30 | ``` 31 | 32 | If you're using ESM: 33 | 34 | ```js 35 | import * as emery from 'emery'; 36 | ``` 37 | 38 | ### Modules 39 | 40 | Emery's already tiny (~3kB minified) but if you prefer just grab the parts you need: 41 | 42 | #### [Assertions](/docs/assertions) 43 | 44 | An assertion declares that a condition be `true` before executing subsequent code, ensuring that whatever condition is checked must be true for the remainder of the containing scope. 45 | 46 | ```ts 47 | import assertions from 'emery/assertions'; 48 | ``` 49 | 50 | #### [Checks](/docs/checks) 51 | 52 | Checks are just predicates that can't easily be expressed as type guards. Handy for dealing with ambiguous types. 53 | 54 | ```ts 55 | import checks from 'emery/checks'; 56 | ``` 57 | 58 | #### [Guards](/docs/guards) 59 | 60 | A collection of common guards. Type guards allow you to narrow the type of a value. 61 | 62 | ```ts 63 | import guards from 'emery/guards'; 64 | ``` 65 | 66 | #### [Opaques](/docs/opaques) 67 | 68 | Opaque types encode primitive types with information about program semantics. 69 | 70 | ```ts 71 | import opaques from 'emery/opaques'; 72 | ``` 73 | 74 | #### [Utils](/docs/utils) 75 | 76 | Utilities for smoothing over areas of TS that are loosely typed. 77 | 78 | ```ts 79 | import utils from 'emery/utils'; 80 | ``` 81 | -------------------------------------------------------------------------------- /docs/components/tags/Callout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { Icon } from '../Icon'; 4 | 5 | const iconMap = { 6 | neutral: 'information-circle', 7 | warning: 'warning', 8 | positive: 'checkmark-circle', 9 | critical: 'warning', 10 | } as const; 11 | 12 | export const calloutTones = Object.keys(iconMap); 13 | 14 | type CalloutProps = { 15 | children: React.ReactNode; 16 | tone: keyof typeof iconMap; 17 | }; 18 | 19 | export function Callout({ children, tone = 'neutral' }: CalloutProps) { 20 | const icon = iconMap[tone]; 21 | 22 | return ( 23 |
24 |
25 | 26 |
27 |
{children}
28 | 62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Bugs, missing documentation, or unexpected behavior 4 | labels: 'unconfirmed bug' 5 | --- 6 | 7 | 22 | 23 | ### Expected behavior 24 | 25 | ### Actual behavior 26 | 27 | ### Steps to reproduce 28 | 29 | ### Suggested solution? 30 | 31 | 35 | 36 | ### What version of `emery` are you running? 37 | 38 | 41 | 42 | ### Demo 43 | 44 | 51 | 52 | 57 | -------------------------------------------------------------------------------- /docs/pages/markdoc-prose.md: -------------------------------------------------------------------------------- 1 | # Markdoc prose tests 2 | 3 | ## h2 Heading 4 | 5 | ### h3 Heading 6 | 7 | #### h4 Heading 8 | 9 | ##### h5 Heading 10 | 11 | ###### h6 Heading 12 | 13 | ## Inline 14 | 15 | This is a paragraph with _emphasised text_ and **strongly emphasised text**. It may also contain [links](https://markdoc.io/) and ~~strikethrough~~ text. 16 | 17 | Here's a new paragraph that contains inline `code`. 18 | 19 | ## Block 20 | 21 | ### Horizontal rule 22 | 23 | --- 24 | 25 | ### Blockquotes 26 | 27 | > Blockquotes example with _inline_ **tags** included. 28 | 29 | ### Lists 30 | 31 | #### Unordered 32 | 33 | Prefer dash `-` for unordered lists. 34 | 35 | - Create a list by starting a line with `-` 36 | - Sub-lists 37 | - are made by 38 | - indenting 2 spaces 39 | 40 | #### Ordered 41 | 42 | Prefer non-sequential indicators for ordered lists—it's easier to more items around. 43 | 44 | 1. Lorem ipsum dolor sit amet 45 | 1. Consectetur adipiscing elit 46 | 1. Integer molestie lorem at massa 47 | 48 | ### Code 49 | 50 | Only code "fences" e.g. ` ``` ` supported, not indentation syntax. 51 | 52 | ``` 53 | Sample text here... 54 | ``` 55 | 56 | Provide a language after the backticks e.g. ` ```js` for syntax highlighting. 57 | 58 | ```js 59 | var foo = function (bar) { 60 | return bar++; 61 | }; 62 | 63 | console.log(foo(5)); 64 | ``` 65 | 66 | ### Tables 67 | 68 | | Option | Description | 69 | | ------ | ------------------------------------------------------------------------- | 70 | | data | path to data files to supply the data that will be passed into templates. | 71 | | engine | engine to be used for processing templates. Handlebars is the default. | 72 | | ext | extension to be used for dest files. | 73 | 74 | ### Images 75 | 76 | ![Octocat](https://octodex.github.com/images/original.png) 77 | 78 | ## Tags 79 | 80 | ### Callout 81 | 82 | {% callout %} 83 | The "neutral" tone is the default 84 | {% /callout %} 85 | 86 | {% callout tone="positive" %} 87 | Indicate success with a "positive" callout 88 | {% /callout %} 89 | 90 | {% callout tone="warning" %} 91 | Give readers a "warning" when appropriate 92 | {% /callout %} 93 | 94 | {% callout tone="critical" %} 95 | Highlight "critical" areas with a callout 96 | {% /callout %} 97 | -------------------------------------------------------------------------------- /src/guards.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { Nullish } from './types'; 3 | 4 | // Primitives 5 | // ------------------------------ 6 | 7 | /** Checks whether a value is a boolean */ 8 | export function isBoolean(value: unknown): value is boolean { 9 | return typeof value === 'boolean'; 10 | } 11 | 12 | /** Checks whether a value is null */ 13 | export function isNull(value: unknown): value is null { 14 | return value === null; 15 | } 16 | 17 | /** Checks whether a value is a number */ 18 | export function isNumber(value: unknown): value is number { 19 | return typeof value === 'number' && !isNaN(value); 20 | } 21 | 22 | /** Checks whether a value is a string */ 23 | export function isString(value: unknown): value is string { 24 | return typeof value === 'string'; 25 | } 26 | 27 | /** Checks whether a value is undefined */ 28 | export function isUndefined(value: unknown): value is undefined { 29 | return value === undefined; 30 | } 31 | 32 | // Array 33 | // ------------------------------ 34 | 35 | /** Checks whether or not an array is empty. */ 36 | export function isNonEmptyArray(value: readonly T[]): value is [T, ...T[]] { 37 | return value.length > 0; 38 | } 39 | 40 | // Convenience 41 | // ------------------------------ 42 | 43 | /** Checks whether a value is null or undefined */ 44 | export function isNullish(value: unknown): value is Nullish { 45 | return value === null || value === undefined; 46 | } 47 | 48 | /** Checks whether a value is defined */ 49 | export function isDefined(value: T): value is NonNullable { 50 | return !isNullish(value); 51 | } 52 | 53 | // Promise 54 | // ------------------------------ 55 | 56 | /** 57 | * Checks whether a result from `Promise.allSettled` is fulfilled 58 | * 59 | * ```ts 60 | * const results = await Promise.allSettled(promises); 61 | * const fulfilledValues = results.filter(isFulfilled).map(result => result.value); 62 | * ``` 63 | */ 64 | export function isFulfilled( 65 | result: PromiseSettledResult, 66 | ): result is PromiseFulfilledResult { 67 | return result.status === 'fulfilled'; 68 | } 69 | 70 | /** 71 | * Checks whether a result from `Promise.allSettled` is rejected 72 | * 73 | * ```ts 74 | * const results = await Promise.allSettled(promises); 75 | * const rejectionReasons = results.filter(isRejected).map(result => result.reason); 76 | * ``` 77 | */ 78 | export function isRejected(result: PromiseSettledResult): result is PromiseRejectedResult { 79 | return result.status === 'rejected'; 80 | } 81 | -------------------------------------------------------------------------------- /src/checks/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { checkAll, checkAllWith, negate } from './utils'; 2 | 3 | describe('checks/utils', () => { 4 | const isEven = jest.fn(x => x % 2 === 0); 5 | const isNumberish = jest.fn(x => typeof x === 'number'); 6 | const lessThanTen = jest.fn(x => x < 10); 7 | 8 | beforeEach(() => { 9 | jest.clearAllMocks(); 10 | }); 11 | 12 | describe('negate', () => { 13 | it('should return a negated predicate', () => { 14 | const isOdd = negate(isEven); 15 | 16 | expect(isOdd(4)).toEqual(false); 17 | expect(isEven).toHaveBeenCalledTimes(1); 18 | expect(isEven).toHaveBeenCalledWith(4); 19 | }); 20 | }); 21 | 22 | describe('checkAll', () => { 23 | it('should create a new function', () => { 24 | expect(checkAll(isEven)).toBeDefined(); 25 | }); 26 | it('should call provided fns, and return correct result', () => { 27 | const checker = jest.fn(checkAll(isNumberish, lessThanTen, isEven)); 28 | const bool = checker(4); 29 | 30 | expect(isNumberish).toHaveBeenCalledTimes(1); 31 | expect(lessThanTen).toHaveBeenCalledTimes(1); 32 | expect(isEven).toHaveBeenCalledTimes(1); 33 | 34 | expect(checker).toHaveBeenCalled(); 35 | expect(checker).toHaveBeenCalledWith(4); 36 | expect(bool).toEqual(true); 37 | }); 38 | it('should call provided fns in sequence, and early exit when appropriate', () => { 39 | const checker = jest.fn(checkAll(isNumberish, lessThanTen, isEven)); 40 | const bool = checker(25); 41 | 42 | expect(isNumberish).toHaveBeenCalledTimes(1); 43 | expect(lessThanTen).toHaveBeenCalledTimes(1); 44 | expect(isEven).toHaveBeenCalledTimes(0); 45 | 46 | expect(checker).toHaveBeenCalledWith(25); 47 | expect(bool).toEqual(false); 48 | }); 49 | it('should support built-ins', () => { 50 | const checker = jest.fn(checkAll(Array.isArray)); 51 | const bool = checker([]); 52 | 53 | expect(checker).toHaveBeenCalledWith([]); 54 | expect(bool).toEqual(true); 55 | }); 56 | }); 57 | 58 | describe('checkAllWith', () => { 59 | it('should return the correct result', () => { 60 | expect(checkAllWith(5, isEven)).toBe(false); 61 | }); 62 | it('should call provided fns, and return correct result', () => { 63 | const bool = checkAllWith(4, isNumberish, lessThanTen, isEven); 64 | 65 | expect(isNumberish).toHaveBeenCalledTimes(1); 66 | expect(lessThanTen).toHaveBeenCalledTimes(1); 67 | expect(isEven).toHaveBeenCalledTimes(1); 68 | expect(bool).toEqual(true); 69 | }); 70 | it('should call provided fns in sequence, and early exit when appropriate', () => { 71 | const bool = checkAllWith(25, isNumberish, lessThanTen, isEven); 72 | 73 | expect(isNumberish).toHaveBeenCalledTimes(1); 74 | expect(lessThanTen).toHaveBeenCalledTimes(1); 75 | expect(isEven).toHaveBeenCalledTimes(0); 76 | expect(bool).toEqual(false); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /docs/pages/docs/assertions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Assertions 3 | description: Utilities for managing assertions in TypeScript 4 | --- 5 | 6 | # {% $markdoc.frontmatter.title %} 7 | 8 | An assertion declares that a condition be `true` before executing subsequent code. 9 | 10 | - If the condition resolves to `true` the code continues running. 11 | - If the condition resolves to `false` an error will be thrown. 12 | 13 | ## Errors 14 | 15 | ### assert 16 | 17 | Asserts that a condition is `true`, ensuring that whatever condition is being checked must be true for the remainder of the containing scope. 18 | 19 | {% callout %} 20 | The narrow type of `boolean` is an intentional design decision. 21 | {% /callout %} 22 | 23 | ```ts 24 | function assert(condition: boolean, message?: string): asserts condition; 25 | ``` 26 | 27 | Other assertion functions may accept ["truthy"](https://developer.mozilla.org/en-US/docs/Glossary/Truthy) and ["falsy"](https://developer.mozilla.org/en-US/docs/Glossary/Falsy) conditions, while `assert` only accepts conditions that resolve to `boolean`. 28 | 29 | The goal is to promote consideration from consumers when dealing with potentially ambiguous values like `0` or `''`, which can introduce subtle bugs. 30 | 31 | #### Messages 32 | 33 | The `assert` function has a default message: 34 | 35 | ```ts 36 | assert(false); 37 | // → TypeError: Assert failed 38 | ``` 39 | 40 | Or you can provide a custom message: 41 | 42 | ```ts 43 | let invalidValue = -1; 44 | assert(invalidValue >= 0, `Expected a non-negative number, but received: ${invalidValue}`); 45 | // → TypeError: Expected a non-negative number, but received: -1 46 | ``` 47 | 48 | ### assertNever 49 | 50 | Asserts that allegedly unreachable code has been executed. 51 | 52 | ```ts 53 | function assertNever(condition: never): never; 54 | ``` 55 | 56 | Use `assertNever` to catch logic forks that shouldn't be possible. 57 | 58 | ```ts 59 | function doThing(type: 'draft' | 'published') { 60 | switch (type) { 61 | case 'draft': 62 | return; /*...*/ 63 | case 'published': 64 | return; /*...*/ 65 | 66 | default: 67 | assertNever(type); 68 | } 69 | } 70 | ``` 71 | 72 | {% callout tone="warning" %} 73 | Regardless of the condition, this function **always** throws. 74 | {% /callout %} 75 | 76 | ```ts 77 | doThing('archived'); 78 | // → Error: Unexpected call to assertNever: 'archived' 79 | ``` 80 | 81 | ## Logs 82 | 83 | ### warning 84 | 85 | Similar to `assert` but only logs a warning if the condition is not met. 86 | 87 | ```ts 88 | function warning(condition: boolean, message: string): void; 89 | ``` 90 | 91 | Use `warning` to let consumers know about potentially hazardous circumstances. 92 | 93 | {% callout %} 94 | Never logs in production. 95 | {% /callout %} 96 | 97 | ```ts 98 | warning(options.scrollEventThrottle === 0, 'Unthrottled scroll handler may harm performance.'); 99 | // → console.warn('Warning: Unthrottled scroll handler may harm performance.'); 100 | ``` 101 | -------------------------------------------------------------------------------- /src/assertions.test.ts: -------------------------------------------------------------------------------- 1 | import { assert, assertNever, warning } from './assertions'; 2 | import { getErrorMessage } from './utils/error'; 3 | import { falsyValues, truthyValues } from './testing'; 4 | 5 | describe('assertions', () => { 6 | describe('assert', () => { 7 | it('should throw when the condition is `false`', () => { 8 | expect(() => assert(false)).toThrow(); 9 | }); 10 | it('should not throw when the condition is `true`', () => { 11 | expect(() => assert(true)).not.toThrow(); 12 | }); 13 | 14 | it('should expect TS error when called with non-boolean conditions', () => { 15 | falsyValues.forEach(val => { 16 | // @ts-expect-error should not accept non-boolean conditions 17 | expect(() => assert(val)).toThrow(); 18 | }); 19 | truthyValues.forEach(val => { 20 | // @ts-expect-error should not accept non-boolean conditions 21 | expect(() => assert(val)).not.toThrow(); 22 | }); 23 | }); 24 | 25 | it('should throw a TypeError with the "default" message, when none provided', () => { 26 | try { 27 | assert(false); 28 | } catch (error) { 29 | expect(getErrorMessage(error)).toBe('Assert failed'); 30 | } 31 | }); 32 | it('should throw a TypeError with the consumer message, when provided', () => { 33 | try { 34 | assert(false, 'Custom message'); 35 | } catch (error) { 36 | expect(getErrorMessage(error)).toBe('Custom message'); 37 | } 38 | }); 39 | }); 40 | 41 | describe('assertNever', () => { 42 | it('should always throw', () => { 43 | // @ts-expect-error: for testing 44 | expect(() => assertNever(0)).toThrow(); 45 | // @ts-expect-error: for testing 46 | expect(() => assertNever(null)).toThrow(); 47 | // @ts-expect-error: for testing 48 | expect(() => assertNever('test')).toThrow(); 49 | // @ts-expect-error: for testing 50 | expect(() => assertNever({})).toThrow(); 51 | }); 52 | it('should throw with error message', () => { 53 | const value = 1; 54 | 55 | try { 56 | // @ts-expect-error: for testing 57 | assertNever(value); 58 | } catch (error) { 59 | expect(getErrorMessage(error)).toBe(`Expected never to be called, but received: ${value}`); 60 | } 61 | }); 62 | }); 63 | 64 | describe('warning', () => { 65 | beforeEach(() => { 66 | // eslint-disable-next-line @typescript-eslint/no-empty-function 67 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 68 | }); 69 | afterEach(() => { 70 | // @ts-expect-error: mocked 71 | console.warn.mockRestore(); 72 | }); 73 | 74 | it('should not warn if the condition is true', () => { 75 | warning(true, 'test message'); 76 | expect(console.warn).not.toHaveBeenCalled(); 77 | }); 78 | it('should warn if the condition is false', () => { 79 | const message = 'test message'; 80 | warning(false, message); 81 | 82 | expect(console.warn).toHaveBeenCalledWith('Warning: ' + message); 83 | // @ts-expect-error: mocked 84 | console.warn.mockClear(); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # emery 2 | 3 | ## 1.4.4 4 | 5 | ### Patch Changes 6 | 7 | - [#53](https://github.com/Thinkmill/emery/pull/53) [`9d3a10b`](https://github.com/Thinkmill/emery/commit/9d3a10b562d3f815ed06085d558837ad81aff8c2) Thanks [@jossmac](https://github.com/jossmac)! - Add `typedObjectFromEntries` utility, and improve `ObjectEntry` type. 8 | 9 | ## 1.4.3 10 | 11 | ### Patch Changes 12 | 13 | - [#49](https://github.com/Thinkmill/emery/pull/49) [`32a35bd`](https://github.com/Thinkmill/emery/commit/32a35bdd0d839e9fbce8030e3e5566b5f41ce1a7) Thanks [@emmatown](https://github.com/emmatown)! - Add `exports` to `package.json` to allow importing in Node ESM 14 | 15 | ## 1.4.2 16 | 17 | ### Patch Changes 18 | 19 | - [#41](https://github.com/Thinkmill/emery/pull/41) [`3b73321`](https://github.com/Thinkmill/emery/commit/3b73321ecf8357a927d8a2a4eb23914a06761e1a) Thanks [@lukebennett88](https://github.com/lukebennett88)! - Handle readonly arrays for `isNonEmptyArray` guard. 20 | 21 | * [#44](https://github.com/Thinkmill/emery/pull/44) [`bbe5d72`](https://github.com/Thinkmill/emery/commit/bbe5d72e8badd98aa3a37698c78f47c565811c9e) Thanks [@jossmac](https://github.com/jossmac)! - Stringify arg provided to `assertNever` error message. 22 | 23 | ## 1.4.1 24 | 25 | ### Patch Changes 26 | 27 | - [#39](https://github.com/Thinkmill/emery/pull/39) [`c30581a`](https://github.com/Thinkmill/emery/commit/c30581a992bd4e8eebb7334a50f5792c7aef1d22) Thanks [@jossmac](https://github.com/jossmac)! - fix exports 28 | 29 | ## 1.4.0 30 | 31 | ### Minor Changes 32 | 33 | - [#37](https://github.com/Thinkmill/emery/pull/37) [`810b5bf`](https://github.com/Thinkmill/emery/commit/810b5bf771d970408ff5cb27a4906d64280de119) Thanks [@jossmac](https://github.com/jossmac)! - Add `warning` fn 34 | 35 | ## 1.3.0 36 | 37 | ### Minor Changes 38 | 39 | - [#34](https://github.com/Thinkmill/emery/pull/34) [`3d3888a`](https://github.com/Thinkmill/emery/commit/3d3888aba63b7638f4d71c3b952e5a1b7590b3b0) Thanks [@jossmac](https://github.com/jossmac)! - Assertions: support opt-out of debugger 40 | 41 | ## 1.2.2 42 | 43 | ### Patch Changes 44 | 45 | - [#30](https://github.com/Thinkmill/emery/pull/30) [`f3bed0d`](https://github.com/Thinkmill/emery/commit/f3bed0d894b3780ed95b29481259018fb33f21ff) Thanks [@jossmac](https://github.com/jossmac)! - Loosely type object util args. 46 | 47 | If folks had strongly typed objects, they wouldn't need these utils. Support any object to save some headaches. Affects: 48 | 49 | - `typedEntries()` 50 | - `typedKeys()` 51 | 52 | ## 1.2.1 53 | 54 | ### Patch Changes 55 | 56 | - [#28](https://github.com/Thinkmill/emery/pull/28) [`2b8620a`](https://github.com/Thinkmill/emery/commit/2b8620ac73cebe99543af26f7e1ce31978e7752c) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Fixed `isFulfilled` and `isRejected` guards not being exported from the main entry point. 57 | 58 | ## 1.2.0 59 | 60 | ### Minor Changes 61 | 62 | - [#25](https://github.com/Thinkmill/emery/pull/25) [`cec7317`](https://github.com/Thinkmill/emery/commit/cec7317185a9485709b134453063e0ec991e26ca) Thanks [@mitchellhamilton](https://github.com/mitchellhamilton)! - Added `isFulfilled` and `isRejected` guards for use with `Promise.allSettled` 63 | -------------------------------------------------------------------------------- /docs/pages/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Polish for the rough parts of TypeScript 3 | description: Emery is a collection of utilities that improve DX without compromising static types. 4 | --- 5 | 6 | {% hero prominence="primary" %} 7 | 8 | # Polish for the rough parts of TypeScript 9 | 10 | TypeScript is great but there's parts that are still rough around the edges, especially for developers who are new to the language. 11 | 12 | ```shell 13 | npm install emery 14 | ``` 15 | 16 | {% /hero %} 17 | 18 | {% hero %} 19 | 20 | ## Emery is a collection of utilities that improve DX without compromising static types 21 | 22 | {% /hero %} 23 | 24 | {% feature reverse=true %} 25 | 26 | ### Check for ambiguous types 27 | 28 | Emery exposes ["checks"](/docs/checks) for dealing with ambiguous types. 29 | 30 | Checks are just predicates that can't be expressed as [type guards](/docs/guards), without enforcing [opaque types](/docs/opaques). 31 | 32 | ```ts 33 | import { checkAll, isNonNegative, isInteger } from 'emery'; 34 | 35 | /** 36 | * Along with some default check functions, we provide helpers 37 | * for managing combinations. The `checkAll` helper is a bit 38 | * like `pipe` for predicates. 39 | */ 40 | export const isNonNegativeInteger = checkAll(isNonNegative, isInteger); 41 | ``` 42 | 43 | {% /feature %} 44 | 45 | {% feature %} 46 | 47 | ### Assert the validity of props 48 | 49 | An [assertion](/docs/assertions) declares that a condition be true before executing subsequent code, ensuring that whatever condition is checked must be true for the remainder of the containing scope. 50 | 51 | ```ts 52 | import { assert } from 'emery'; 53 | 54 | import { isNonNegativeInteger } from './path-to/check'; 55 | 56 | function getThingByIndex(index: number) { 57 | assert(isNonNegativeInteger(index)); 58 | 59 | return things[index]; // 🎉 Safely use the `index` argument! 60 | } 61 | ``` 62 | 63 | {% /feature %} 64 | 65 | {% feature reverse=true %} 66 | 67 | ### Smooth over loose types 68 | 69 | [Utility functions](/docs/utils) for smoothing over areas of TypeScript that are loosely typed. 70 | 71 | Because of JavaScript's dynamic implementation the default TS behaviour is correct, but can be frustrating in certain situations. 72 | 73 | ```ts 74 | import { typedKeys } from 'emery'; 75 | 76 | const obj = { foo: 1, bar: 2 }; 77 | 78 | const thing = Object.keys(obj).map(key => { 79 | return obj[key]; // 🚨 'string' can't be used to index... 80 | }); 81 | const thing2 = typedKeys(obj).map(key => { 82 | return obj[key]; // 🎉 No more TypeScript issues! 83 | }); 84 | ``` 85 | 86 | {% /feature %} 87 | 88 | {% hero prominence="secondary" %} 89 | 90 | ## Philosophy and motivation 91 | 92 | Like all good things, Emery started with curiosity. At [Thinkmill](https://thinkmill.com.au) we have an internal Slack channel for TypeScript where a question was raised about how to offer consumers error messages that convey intent, not just cascading type failures. 93 | 94 | While that's not currently possible, it became apparent that there was demand for a solution. We also discovered that many developers were carrying around miscellaneous utilities for working with TypeScript between projects. 95 | 96 | [Read the origin story](/docs/origin-story) for more information about how we arrived here. 97 | 98 | {% /hero %} 99 | -------------------------------------------------------------------------------- /docs/components/nodes/Heading.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | type HeadingLevel = 1 | 2 | 3 | 4; 5 | type HeadingTag = `h${HeadingLevel}`; 6 | export type HeadingProps = { 7 | id?: string; 8 | level: HeadingLevel; 9 | children: React.ReactNode; 10 | }; 11 | 12 | export function Heading({ children, id = '', level = 1 }: HeadingProps) { 13 | const router = useRouter(); 14 | const isDocs = router.pathname.startsWith('/docs'); 15 | const showLink = isDocs && level !== 1 && id; 16 | 17 | const Tag: HeadingTag = `h${level}`; 18 | const className = `heading ${isDocs && [2, 3, 4].includes(level) ? ' docs-heading' : ''}`; 19 | 20 | return ( 21 | 22 | {showLink ? : null} 23 | {children} 24 | 73 | 74 | ); 75 | } 76 | 77 | function HeadingLink({ href }: { href: string }) { 78 | return ( 79 | <> 80 | 81 | 103 | 104 | 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /docs/components/shell/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Link } from '../Link'; 4 | 5 | type FooterProps = { filePath?: string }; 6 | 7 | export function Footer({ filePath }: FooterProps) { 8 | const editable = filePath && filePath.startsWith('/docs'); 9 | 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 | Thinkmill Labs 17 | 18 | {editable && Edit this page} 19 | 45 |
46 | ); 47 | } 48 | 49 | function getEditLink(path: string) { 50 | return `https://github.com/thinkmill/emery/edit/main/docs/pages${path}`; 51 | } 52 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [joss.mackison@thinkmill.com.au](joss.mackison+emery@thinkmill.com.au). All 58 | complaints will be reviewed and investigated and will result in a response that 59 | is deemed necessary and appropriate to the circumstances. The project team is 60 | obligated to maintain confidentiality with regard to the reporter of an incident. 61 | Further details of specific enforcement policies may be posted separately. 62 | 63 | Project maintainers who do not follow or enforce the Code of Conduct in good 64 | faith may face temporary or permanent repercussions as determined by other 65 | members of the project's leadership. 66 | 67 | ## Attribution 68 | 69 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 70 | available at [http://contributor-covenant.org/version/1/4][version] 71 | 72 | [homepage]: http://contributor-covenant.org 73 | [version]: http://contributor-covenant.org/version/1/4/ 74 | -------------------------------------------------------------------------------- /docs/pages/docs/guards.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Type guards 3 | description: Common guards for narrowing the type of a value 4 | --- 5 | 6 | # {% $markdoc.frontmatter.title %} 7 | 8 | Type guards allow you to [narrow the type](https://www.typescriptlang.org/docs/handbook/2/narrowing.html) of a value: 9 | 10 | ```ts 11 | function doThing(x: number | string) { 12 | if (typeof x === 'string') { 13 | // `x` is string 14 | return x.substring(1); 15 | } 16 | 17 | // `x` is number 18 | return x * x; 19 | } 20 | ``` 21 | 22 | ## Primitive 23 | 24 | Guards for [primitive](https://developer.mozilla.org/en-US/docs/Glossary/Primitive) types. 25 | 26 | ### isBoolean 27 | 28 | Checks whether a value is `boolean`. 29 | 30 | ```ts 31 | function isBoolean(value: unknown): value is boolean; 32 | ``` 33 | 34 | ### isNull 35 | 36 | Checks whether a value is `null`. 37 | 38 | ```ts 39 | function isNull(value: unknown): value is null; 40 | ``` 41 | 42 | ### isNumber 43 | 44 | Checks whether a value is a `number`. 45 | 46 | {% callout tone="warning" %} 47 | Does not consider `NaN` a valid value 48 | {% /callout %} 49 | 50 | ```ts 51 | function isNumber(value: unknown): value is number; 52 | ``` 53 | 54 | ### isString 55 | 56 | Checks whether a value is a `string`. 57 | 58 | ```ts 59 | function isString(value: unknown): value is string; 60 | ``` 61 | 62 | ### isUndefined 63 | 64 | Checks whether a value is `undefined`. 65 | 66 | ```ts 67 | function isUndefined(value: unknown): value is undefined; 68 | ``` 69 | 70 | ## Nullish 71 | 72 | Guards for [nullish](https://developer.mozilla.org/en-US/docs/Glossary/Nullish) types. 73 | 74 | ### isNullish 75 | 76 | Checks whether a value is `null` or `undefined`. 77 | 78 | ```ts 79 | function isNullish(value: unknown): value is Nullish; 80 | ``` 81 | 82 | ### isDefined 83 | 84 | Checks whether a value is **not** `null` or `undefined`. 85 | 86 | ```ts 87 | function isDefined(value: T | Nullish): value is NonNullable; 88 | ``` 89 | 90 | ## Array 91 | 92 | Guards for [array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array) types. 93 | 94 | ### isNonEmptyArray 95 | 96 | Checks whether an array is **not** empty. 97 | 98 | ```ts 99 | function isNonEmptyArray(value: T[]): value is [T, ...T[]]; 100 | ``` 101 | 102 | ## Promise 103 | 104 | Guards for [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) related types. 105 | 106 | ### isFulfilled 107 | 108 | Checks whether a result from [`Promise.allSettled`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) is fulfilled 109 | 110 | ```ts 111 | function isFulfilled(result: PromiseSettledResult): result is PromiseFulfilledResult; 112 | ``` 113 | 114 | ```ts 115 | const results = await Promise.allSettled(promises); 116 | const fulfilledValues = results.filter(isFulfilled).map(result => result.value); 117 | ``` 118 | 119 | ### isRejected 120 | 121 | Checks whether a result from [`Promise.allSettled`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled) is rejected. 122 | 123 | ```ts 124 | function isRejected(result: PromiseSettledResult): result is PromiseRejectedResult; 125 | ``` 126 | 127 | ```ts 128 | const results = await Promise.allSettled(promises); 129 | const rejectionReasons = results.filter(isRejected).map(result => result.reason); 130 | ``` 131 | -------------------------------------------------------------------------------- /src/guards.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isBoolean, 3 | isDefined, 4 | isFulfilled, 5 | isNonEmptyArray, 6 | isNull, 7 | isNullish, 8 | isNumber, 9 | isRejected, 10 | isString, 11 | isUndefined, 12 | } from './guards'; 13 | import { getValuesByType, getValuesByTypeWithout } from './testing'; 14 | 15 | describe('guards', () => { 16 | describe('primitives', () => { 17 | it('isBoolean should validate assumed values', () => { 18 | expect(isBoolean(true)).toBe(true); 19 | expect(isBoolean(false)).toBe(true); 20 | 21 | getValuesByTypeWithout('boolean').forEach(val => { 22 | expect(isBoolean(val)).toBe(false); 23 | }); 24 | }); 25 | 26 | it('isNull should validate assumed values', () => { 27 | expect(isNull(null)).toBe(true); 28 | 29 | getValuesByTypeWithout('null').forEach(val => { 30 | expect(isNull(val)).toBe(false); 31 | }); 32 | }); 33 | 34 | it('isNumber should validate assumed values', () => { 35 | getValuesByType('number').forEach(val => { 36 | expect(isNumber(val)).toBe(true); 37 | }); 38 | 39 | getValuesByTypeWithout('number').forEach(val => { 40 | expect(isNumber(val)).toBe(false); 41 | }); 42 | 43 | expect(isNumber(NaN)).toBe(false); 44 | }); 45 | 46 | it('isString should validate assumed values', () => { 47 | getValuesByType('string').forEach(val => { 48 | expect(isString(val)).toBe(true); 49 | }); 50 | 51 | getValuesByTypeWithout('string').forEach(val => { 52 | expect(isString(val)).toBe(false); 53 | }); 54 | }); 55 | 56 | it('isUndefined should validate assumed values', () => { 57 | expect(isUndefined(undefined)).toBe(true); 58 | 59 | getValuesByTypeWithout('undefined').forEach(val => { 60 | expect(isUndefined(val)).toBe(false); 61 | }); 62 | }); 63 | }); 64 | 65 | describe('array', () => { 66 | it('isNonEmptyArray should validate assumed values', () => { 67 | expect(isNonEmptyArray([1, 2])).toBe(true); 68 | expect(isNonEmptyArray([1, 2] as const)).toBe(true); 69 | expect(isNonEmptyArray([])).toBe(false); 70 | expect(isNonEmptyArray([] as const)).toBe(false); 71 | }); 72 | }); 73 | 74 | describe('convenience', () => { 75 | it('isNullish should validate assumed values', () => { 76 | expect(isNullish(null)).toBe(true); 77 | expect(isNullish(undefined)).toBe(true); 78 | 79 | getValuesByTypeWithout(['null', 'undefined']).forEach(val => { 80 | expect(isNullish(val)).toBe(false); 81 | }); 82 | }); 83 | it('isDefined should validate assumed values', () => { 84 | expect(isDefined(null)).toBe(false); 85 | expect(isDefined(undefined)).toBe(false); 86 | 87 | getValuesByTypeWithout(['null', 'undefined']).forEach(val => { 88 | expect(isDefined(val)).toBe(true); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('promise', () => { 94 | it('isFulfilled should validate assumed values', async () => { 95 | expect( 96 | (await Promise.allSettled([Promise.resolve(), Promise.reject()])).map(x => isFulfilled(x)), 97 | ).toEqual([true, false]); 98 | }); 99 | it('isRejected should validate assumed values', async () => { 100 | expect( 101 | (await Promise.allSettled([Promise.resolve(), Promise.reject()])).map(x => isRejected(x)), 102 | ).toEqual([false, true]); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /docs/components/tags/Hero.tsx: -------------------------------------------------------------------------------- 1 | import React, { Children, ReactNode } from 'react'; 2 | 3 | type HeroProps = { children: ReactNode; prominence: 'primary' | 'secondary' | 'tertiary' }; 4 | 5 | export const Hero = ({ children, prominence = 'tertiary' }: HeroProps) => { 6 | return ( 7 |
8 |
{children}
9 | {prominence === 'primary' ? : null} 10 | 11 | 36 |
37 | ); 38 | }; 39 | 40 | const Fold = () => { 41 | return ( 42 |
43 | {gemSVG} 44 | 67 |
68 | ); 69 | }; 70 | 71 | const gemSVG = ( 72 | 73 | 77 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 95 | 96 | ); 97 | -------------------------------------------------------------------------------- /src/checks/number.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isEven, 3 | isFinite, 4 | isFloat, 5 | isInfinite, 6 | isInteger, 7 | isNegative, 8 | isNegativeZero, 9 | isNonNegative, 10 | isNonPositive, 11 | isOdd, 12 | isPositive, 13 | } from './number'; 14 | 15 | describe('checks/number', () => { 16 | it('`isFinite` should correctly evaluate values', () => { 17 | expect(isFinite(1)).toBe(true); 18 | expect(isFinite(123e4 / 12.3)).toBe(true); 19 | expect(isFinite(Number.MAX_SAFE_INTEGER)).toBe(true); 20 | expect(isFinite(Number.MIN_SAFE_INTEGER)).toBe(true); 21 | expect(isFinite(Number.POSITIVE_INFINITY)).toBe(false); 22 | expect(isFinite(Number.NEGATIVE_INFINITY)).toBe(false); 23 | }); 24 | it('`isInfinite` should correctly evaluate values', () => { 25 | expect(isInfinite(Number.POSITIVE_INFINITY)).toBe(true); 26 | expect(isInfinite(Number.NEGATIVE_INFINITY)).toBe(true); 27 | expect(isInfinite(1)).toBe(false); 28 | expect(isInfinite(123e4 / 12.3)).toBe(false); 29 | expect(isInfinite(Number.MAX_SAFE_INTEGER)).toBe(false); 30 | expect(isInfinite(Number.MIN_SAFE_INTEGER)).toBe(false); 31 | }); 32 | 33 | it('`isInteger` should correctly evaluate values', () => { 34 | expect(isInteger(1)).toBe(true); 35 | expect(isInteger(-1)).toBe(true); 36 | expect(isInteger(1.23)).toBe(false); 37 | expect(isInteger(-1.23)).toBe(false); 38 | }); 39 | it('`isFloat` should correctly evaluate values', () => { 40 | expect(isFloat(1.23)).toBe(true); 41 | expect(isFloat(-1.23)).toBe(true); 42 | expect(isFloat(1)).toBe(false); 43 | expect(isFloat(-1)).toBe(false); 44 | }); 45 | 46 | it('`isEven` should correctly evaluate values', () => { 47 | expect(isEven(2)).toBe(true); 48 | expect(isEven(-22)).toBe(true); 49 | expect(isEven(1)).toBe(false); 50 | expect(isEven(2.2)).toBe(false); 51 | }); 52 | it('`isOdd` should correctly evaluate values', () => { 53 | expect(isOdd(1)).toBe(true); 54 | expect(isOdd(-11)).toBe(true); 55 | expect(isOdd(2)).toBe(false); 56 | expect(isOdd(1.1)).toBe(false); 57 | }); 58 | 59 | it('`isNegativeZero` should correctly evaluate values', () => { 60 | expect(isNegativeZero(-0)).toBe(true); 61 | expect(isNegativeZero(1)).toBe(false); 62 | expect(isNegativeZero(0)).toBe(false); 63 | }); 64 | 65 | it('`isNegative` should correctly evaluate values', () => { 66 | expect(isNegative(-1)).toBe(true); 67 | expect(isNegative(-1.23)).toBe(true); 68 | expect(isNegative(1)).toBe(false); 69 | expect(isNegative(0)).toBe(false); 70 | expect(isNegative(-0)).toBe(false); 71 | }); 72 | it('`isPositive` should correctly evaluate values', () => { 73 | expect(isPositive(1)).toBe(true); 74 | expect(isPositive(1.23)).toBe(true); 75 | expect(isPositive(-1)).toBe(false); 76 | expect(isPositive(0)).toBe(false); 77 | expect(isPositive(-0)).toBe(false); 78 | }); 79 | 80 | it('`isNonNegative` should correctly evaluate values', () => { 81 | expect(isNonNegative(0)).toBe(true); 82 | expect(isNonNegative(-0)).toBe(true); 83 | expect(isNonNegative(1)).toBe(true); 84 | expect(isNonNegative(1.23)).toBe(true); 85 | expect(isNonNegative(-1)).toBe(false); 86 | expect(isNonNegative(-1.23)).toBe(false); 87 | }); 88 | it('`isNonPositive` should correctly evaluate values', () => { 89 | expect(isNonPositive(0)).toBe(true); 90 | expect(isNonPositive(-0)).toBe(true); 91 | expect(isNonPositive(-1)).toBe(true); 92 | expect(isNonPositive(1)).toBe(false); 93 | expect(isNonPositive(1.23)).toBe(false); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emery", 3 | "version": "1.4.4", 4 | "description": "Utilities to help polish the rough parts of TypeScript.", 5 | "main": "dist/emery.cjs.js", 6 | "module": "dist/emery.esm.js", 7 | "exports": { 8 | ".": { 9 | "module": "./dist/emery.esm.js", 10 | "default": "./dist/emery.cjs.js" 11 | }, 12 | "./guards": { 13 | "module": "./guards/dist/emery-guards.esm.js", 14 | "default": "./guards/dist/emery-guards.cjs.js" 15 | }, 16 | "./opaques": { 17 | "module": "./opaques/dist/emery-opaques.esm.js", 18 | "default": "./opaques/dist/emery-opaques.cjs.js" 19 | }, 20 | "./assertions": { 21 | "module": "./assertions/dist/emery-assertions.esm.js", 22 | "default": "./assertions/dist/emery-assertions.cjs.js" 23 | }, 24 | "./utils": { 25 | "module": "./utils/dist/emery-utils.esm.js", 26 | "default": "./utils/dist/emery-utils.cjs.js" 27 | }, 28 | "./checks": { 29 | "module": "./checks/dist/emery-checks.esm.js", 30 | "default": "./checks/dist/emery-checks.cjs.js" 31 | }, 32 | "./package.json": "./package.json" 33 | }, 34 | "repository": "https://github.com/thinkmill/emery.git", 35 | "homepage": "https://emery-ts.vercel.app", 36 | "author": "jossmac ", 37 | "license": "MIT", 38 | "private": false, 39 | "files": [ 40 | "dist", 41 | "src", 42 | "assertions", 43 | "checks", 44 | "guards", 45 | "opaques", 46 | "utils" 47 | ], 48 | "keywords": [ 49 | "ts", 50 | "typescript", 51 | "utils", 52 | "utilities" 53 | ], 54 | "scripts": { 55 | "format": "prettier --write ./src", 56 | "test": "jest", 57 | "test:watch": "jest --watch", 58 | "test:coverage": "jest --collect-coverage", 59 | "open:coverage": "open coverage/lcov-report/index.html", 60 | "lint:eslint": "eslint ./src --ext .js,.ts", 61 | "lint:prettier": "prettier --check ./src", 62 | "lint:types": "tsc", 63 | "lint": "yarn lint:prettier && yarn lint:eslint && yarn lint:types", 64 | "docs:dev": "next dev docs", 65 | "docs:build": "next build docs", 66 | "prepublishOnly": "preconstruct build", 67 | "release": "changeset publish", 68 | "changeset": "changeset" 69 | }, 70 | "jest": { 71 | "preset": "ts-jest", 72 | "testRegex": "/.*test\\.ts$", 73 | "moduleFileExtensions": [ 74 | "ts", 75 | "js" 76 | ] 77 | }, 78 | "preconstruct": { 79 | "exports": true, 80 | "entrypoints": [ 81 | "index.ts", 82 | "assertions.ts", 83 | "checks/index.ts", 84 | "guards.ts", 85 | "opaques.ts", 86 | "utils/index.ts" 87 | ] 88 | }, 89 | "devDependencies": { 90 | "@babel/core": "^7.17.12", 91 | "@babel/preset-env": "^7.17.12", 92 | "@babel/preset-typescript": "^7.17.12", 93 | "@markdoc/markdoc": "^0.1.2", 94 | "@markdoc/next.js": "^0.1.4", 95 | "@preconstruct/cli": "^2.8.3", 96 | "@types/jest": "^27.5.1", 97 | "@types/react": "^18.0.9", 98 | "@typescript-eslint/eslint-plugin": "^5.23.0", 99 | "@typescript-eslint/parser": "^5.23.0", 100 | "copy-to-clipboard": "^3.3.1", 101 | "eslint": "^8.15.0", 102 | "jest": "^28.1.0", 103 | "next": "^12.1.6", 104 | "prettier": "2.6.2", 105 | "prism-react-renderer": "^1.3.3", 106 | "react": "^18.1.0", 107 | "react-dom": "^18.1.0", 108 | "ts-jest": "^28.0.2", 109 | "typescript": "^4.6.4", 110 | "@changesets/changelog-github": "^0.4.5", 111 | "@changesets/cli": "^2.23.0" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /docs/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | const svgs = { 4 | copied: ( 5 | 6 | Copied 7 | 15 | 29 | 30 | ), 31 | 'checkmark-circle': ( 32 | 33 | Checkmark Circle 34 | 35 | 36 | ), 37 | copy: ( 38 | 39 | Copy 40 | 52 | 60 | 61 | ), 62 | 'information-circle': ( 63 | 64 | Information Circle 65 | 66 | 67 | ), 68 | warning: ( 69 | 70 | Warning 71 | 72 | 73 | ), 74 | }; 75 | 76 | type IconProps = { 77 | icon: keyof typeof svgs; 78 | }; 79 | 80 | export function Icon({ icon }: IconProps) { 81 | return ( 82 | 83 | {svgs[icon] || null} 84 | 108 | 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /docs/components/nodes/Fence.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, Ref, useEffect, useRef, useState } from 'react'; 2 | import copy from 'copy-to-clipboard'; 3 | import PrismHighlight, { Language, Prism } from 'prism-react-renderer'; 4 | 5 | import { Icon } from '../Icon'; 6 | 7 | type FenceProps = { content: string; language: Language }; 8 | export const Fence = ({ content, language }: FenceProps) => { 9 | return ( 10 | 11 | {preRef => ( 12 | 13 | {({ className, tokens, getLineProps, getTokenProps }) => ( 14 |
 15 |               
 16 |                 {tokens.map((line, key) => {
 17 |                   return (
 18 |                     
19 | {line.map((token, key) => ( 20 | 21 | ))} 22 |
23 | ); 24 | })} 25 |
26 |
27 | )} 28 |
29 | )} 30 |
31 | ); 32 | }; 33 | 34 | type WrapperProps = { 35 | children: (ref: Ref) => ReactElement; 36 | }; 37 | 38 | const Wrapper = ({ children }: WrapperProps) => { 39 | const [copied, setCopied] = useState(false); 40 | const ref = useRef(null); 41 | 42 | useEffect(() => { 43 | const preEl = ref.current; 44 | 45 | if (preEl && copied) { 46 | copy(preEl.innerText); 47 | const to = setTimeout(setCopied, 1000, false); 48 | return () => clearTimeout(to); 49 | } 50 | }, [copied]); 51 | 52 | return ( 53 |
54 | {children(ref)} 55 | 62 | 105 |
106 | ); 107 | }; 108 | 109 | // Prism or Markdoc is adding an empty line at the end of each snippet... 110 | function removeEmptyLine(str: string) { 111 | return str.replace(/\n$/, ''); 112 | } 113 | -------------------------------------------------------------------------------- /docs/pages/docs/utils.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Utils 3 | description: Utilities for smoothing over areas of TS that are loosely typed 4 | --- 5 | 6 | # {% $markdoc.frontmatter.title %} 7 | 8 | Utility functions for overriding TypeScript's default behaviour. 9 | 10 | ## Errors 11 | 12 | Utilities for managing [Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) objects. 13 | 14 | ### getErrorMessage 15 | 16 | Simplifies error handling in `try...catch` statements. 17 | 18 | ```ts 19 | function getErrorMessage(error: unknown, fallbackMessage? string): string 20 | ``` 21 | 22 | JavaScript is weird, you can `throw` anything—seriously, [anything of any type](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/throw). 23 | 24 | ```ts 25 | try { 26 | someFunctionThatMightThrow(); 27 | } catch (error) { 28 | Monitor.reportError(error.message); 29 | // ~~~~~ 30 | // Object is of type 'unknown'. 31 | } 32 | ``` 33 | 34 | #### Type casting 35 | 36 | Since it's possible for library authors to throw something unexpected, we have to take precautions. Using `getErrorMessage` takes care of type casting for you, and makes error handling safe and simple. 37 | 38 | ```ts 39 | Monitor.reportError(getErrorMessage(error)); 40 | // 🎉 No more TypeScript issues! 41 | ``` 42 | 43 | Handles cases where the value isn't an actual `Error` object. 44 | 45 | ```ts 46 | getErrorMessage({ message: 'Object text', other: () => 'Properties' }); 47 | // → 'Object text' 48 | ``` 49 | 50 | Supports a fallback message for "falsy" values. 51 | 52 | ```ts 53 | getErrorMessage(undefined); 54 | // → 'Unknown error' 55 | getErrorMessage(undefined, 'Custom message text'); 56 | // → 'Custom message text' 57 | ``` 58 | 59 | Fails gracefully by stringifying unexpected values. 60 | 61 | ```ts 62 | getErrorMessage({ msg: 'Something went wrong' }); 63 | // → '{ "msg": "Something went wrong" }' 64 | ``` 65 | 66 | ## Objects 67 | 68 | Utility functions for [objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object). 69 | 70 | ### typedEntries 71 | 72 | An alternative to `Object.entries()` that avoids type widening. 73 | 74 | {% callout tone="warning" %} 75 | Uses a [type assertion](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions) which could be considered dangerous. 76 | {% /callout %} 77 | 78 | ```ts 79 | function typedEntries>(value: T): ObjectEntry[]; 80 | ``` 81 | 82 | Differences: 83 | 84 | ```ts 85 | Object.entries({ foo: 1, bar: 2 }); 86 | // → [string, number][] 87 | typedEntries({ foo: 1, bar: 2 }); 88 | // → ['foo' | 'bar', number][] 89 | ``` 90 | 91 | ### typedKeys 92 | 93 | An alternative to `Object.keys()` that avoids type widening. 94 | 95 | ```ts 96 | function typedKeys>(value: T): Array; 97 | ``` 98 | 99 | Differences: 100 | 101 | ```ts 102 | Object.keys({ foo: 1, bar: 2 }); 103 | // → string[] 104 | typedKeys({ foo: 1, bar: 2 }); 105 | // → ("foo" | "bar")[] 106 | ``` 107 | 108 | Example use case: 109 | 110 | ```ts 111 | const obj = { foo: 1, bar: 2 }; 112 | const thing = Object.keys(obj).map(key => { 113 | return obj[key]; 114 | // ~~~~~~~~ 115 | // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type '{ foo: number; bar: number; }'. 116 | // No index signature with a parameter of type 'string' was found on type '{ foo: number; bar: number; }'. 117 | }); 118 | 119 | const thing2 = typedKeys(obj).map(key => { 120 | return obj[key]; 121 | // 🎉 No more TypeScript issues! 122 | }); 123 | ``` 124 | -------------------------------------------------------------------------------- /docs/pages/docs/origin-story.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Origin story 3 | description: The problems we were facing that lead to the creation of Emery 4 | --- 5 | 6 | # {% $markdoc.frontmatter.title %} 7 | 8 | The seed was planted by this simple Slack message: 9 | 10 | > Is there a way to “optimise” the errors returned from TS? 11 | 12 | The discussion that followed confirmed a desire for an approach to TypeScript development that improves DX without compromising static types. At [Thinkmill](https://www.thinkmill.com.au/) we collaborate on large applications and design systems. We've learned that removing any obstacle to quality engineering is a big win. 13 | 14 | ## The problem 15 | 16 | To express our intent we often write types that are technically accurate but leave consumers scratching their heads when something goes wrong. Consider this component definition: 17 | 18 | {% callout %} 19 | React is used in these examples, but Emery is framework agnostic 20 | {% /callout %} 21 | 22 | ```tsx 23 | type LabelProps = 24 | | { 'aria-label': string; 'aria-labelledby'?: never } 25 | | { 'aria-label'?: never; 'aria-labelledby': string }; 26 | type ThingProps = { other: string } & LabelProps; 27 | 28 | const Thing = (props: ThingProps) => { 29 | return; /* omitted for brevity */ 30 | }; 31 | ``` 32 | 33 | ### TypeScript errors (buildtime) 34 | 35 | When a consumer uses this component with missing props they're confronted with a confusing error message: 36 | 37 | ```tsx 38 | const ExampleOmission = () => { 39 | return ; 40 | // ~~~~~ 41 | // Type '{ other: string; }' is not assignable to type 'ThingProps'. 42 | // Property ''aria-labelledby'' is missing in type '{ other: string; }' but required in type '{ 'aria-label'?: never; 'aria-labelledby': string; }'. 43 | }; 44 | ``` 45 | 46 | Not only is the error message difficult to understand, especially for those new to TypeScript, it's misleading! Simply because of the union's declaration order the final line implies that providing `'aria-labelledby'` will rectify the problem, while `'aria-label'` would also be appropriate. 47 | 48 | If the consumer, for some reason, provides both label props the message becomes more cryptic. Nobody deserves this in their day: 49 | 50 | ```tsx 51 | const ExampleCombination = () => { 52 | return ; 53 | // ~~~~~ 54 | // Type '{ other: string; "aria-label": string; "aria-labelledby": string; }' is not assignable to type 'ThingProps'. 55 | // Type '{ other: string; "aria-label": string; "aria-labelledby": string; }' is not assignable to type '{ 'aria-label'?: never; 'aria-labelledby': string; }'. 56 | // Types of property ''aria-label'' are incompatible. 57 | // Type 'string' is not assignable to type 'never'. 58 | }; 59 | ``` 60 | 61 | When you encounter an error the last thing you need is more work, researching some mysterious texts. You should be presented with a message that tells you clearly what went wrong, and ideally how to fix it. 62 | 63 | ## A solution 64 | 65 | Without TypeScript support for influencing the messages surfaced to consumers at buildtime, runtime errors are our next best option. 66 | 67 | ### Custom errors (runtime) 68 | 69 | We can improve DX without compromising static types by including an [assertion](/docs/assertions) that yields a more _human friendly_ error message. 70 | 71 | ```ts 72 | const Thing = (props: ThingProps) => { 73 | validateProps(props); 74 | return; /* omitted for brevity */ 75 | }; 76 | 77 | function validateProps(props: ThingProps) { 78 | assert( 79 | 'aria-label' in props || 'aria-labelledby' in props, 80 | 'You must provide either `aria-label` or `aria-labelledby`.', 81 | ); 82 | } 83 | ``` 84 | -------------------------------------------------------------------------------- /docs/components/shell/SectionNav.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | 4 | export type Section = { id: string; level: 2 | 3; title: string }; 5 | type SectionNavProps = { sections: Section[] }; 6 | 7 | export function SectionNav({ sections }: SectionNavProps) { 8 | const items = sections.filter(item => item.id && (item.level === 2 || item.level === 3)); 9 | 10 | return ( 11 | 99 | ); 100 | } 101 | 102 | // function getClassNames({ current, prefix, level }) { 103 | // return [prefix, current ? `${prefix}--current` : null, level === 3 ? `${prefix}--inset` : null] 104 | // .filter(Boolean) 105 | // .join(' '); 106 | // } 107 | -------------------------------------------------------------------------------- /docs/components/layouts/DocsLayout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RenderableTreeNode, Tag } from '@markdoc/markdoc'; 3 | 4 | import { Footer, SideNav, SectionNav, Section } from '../shell'; 5 | import { LayoutProps } from './types'; 6 | 7 | export const DocsLayout = ({ children, markdoc }: LayoutProps) => { 8 | const sections = markdoc?.content ? collectSections(markdoc.content) : []; 9 | 10 | const skipNavID = 'skip-nav'; 11 | 12 | return ( 13 | <> 14 | 15 |
16 |
17 |
18 | 19 |
20 |
21 | {children} 22 |
23 |
24 | 25 |
26 |
27 |
28 | 60 | 61 | ); 62 | }; 63 | 64 | // Styled components 65 | // ------------------------------ 66 | 67 | /* https://webaim.org/techniques/skipnav/ */ 68 | function SkipNav({ id }: { id: string }) { 69 | return ( 70 | <> 71 | 72 | Skip to content 73 | 74 | 99 | 100 | ); 101 | } 102 | 103 | // Utils 104 | // ------------------------------ 105 | 106 | function isTag(node: RenderableTreeNode): node is Tag { 107 | return Boolean(node) && typeof node !== 'string'; 108 | } 109 | 110 | function collectSections(node: RenderableTreeNode, sections: Section[] = []) { 111 | if (isTag(node)) { 112 | if (node.name === 'Heading') { 113 | const title = node.children[0]; 114 | 115 | if (typeof title === 'string') { 116 | // @ts-expect-error not sure how to extract attributes from markdoc tags 117 | sections.push({ ...node.attributes, title }); 118 | } 119 | } 120 | 121 | if (node.children) { 122 | for (const child of node.children) { 123 | collectSections(child, sections); 124 | } 125 | } 126 | } 127 | 128 | return sections; 129 | } 130 | -------------------------------------------------------------------------------- /docs/pages/docs/opaques.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Opaque types 3 | description: Utilities for managing opaque types 4 | --- 5 | 6 | # {% $markdoc.frontmatter.title %} 7 | 8 | Utilities for managing [opaque types](https://codemix.com/opaque-types-in-javascript/). Opaque types are [included with Flow](https://flow.org/en/docs/types/opaque-types/), but there's a bit of extra ceremony required for TypeScript. 9 | 10 | ## Types 11 | 12 | In TypeScript, types are transparent by default — if two types are structurally identical they are deemed compatible. Transparent types can ensure type safety, but they don’t encode any information about program semantics. 13 | 14 | ### Opaque 15 | 16 | Create an opaque type, which hides its internal details from the public. 17 | 18 | The `Type` parameter is limited to primitive types. For more complex requirements consider an [interface](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#interfaces) or [record](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkeys-type). 19 | 20 | ```ts 21 | type AccountNumber = Opaque; 22 | ``` 23 | 24 | The `Token` parameter is required and must be unique, it allows the compiler to differentiate between types. 25 | 26 | ```ts 27 | type ThingOne = Opaque; 28 | // ~~~~~~~~~~~~~~ 29 | // Generic type 'Opaque' requires 2 type argument(s). 30 | 31 | type ThingTwo = Opaque; 32 | // 👍 So far, so good 33 | 34 | type ThingThree = Opaque; 35 | // 🚨 Non-unique `Token` parameter 36 | ``` 37 | 38 | #### Uniqueness 39 | 40 | While string literals are accepted tokens, we recommend [unique symbols](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-7.html#unique-symbol) for your opaque types to make them stronger. Each reference to a unique symbol implies a completely unique identity that’s tied to a given declaration. 41 | 42 | ```ts 43 | const AccountNumberSymbol: unique symbol = Symbol(); 44 | 45 | type AccountNumber = Opaque; 46 | ``` 47 | 48 | Another approach is to use recursive types. 49 | 50 | ```ts 51 | type Account = { 52 | accountNumber: Opaque; 53 | name: string; 54 | }; 55 | ``` 56 | 57 | ## Functions 58 | 59 | At runtime these are each equivalent to an [identity function](https://en.wikipedia.org/wiki/Identity_function). 60 | 61 | ### castToOpaque 62 | 63 | A generic helper function that takes a primitive value, and returns the value after casting it to the provided opaque type. 64 | 65 | ```ts 66 | function castToOpaque(value: bigint | number | string | symbol): OpaqueType; 67 | ``` 68 | 69 | Opaque types cannot be assigned to variables with standard type declarations—this is by design, ensuring that opaquely typed values flow through the program without degrading. 70 | 71 | ```ts 72 | const value: AccountNumber = 123; 73 | // ~~~~~ 74 | // Type 'number' is not assignable to type 'AccountNumber'. 75 | ``` 76 | 77 | Instead use `castToOpaque` to create opaquely typed values. 78 | 79 | ```ts 80 | type AccountNumber = Opaque; 81 | 82 | const value = 123; 83 | // → 'value' is 'number' 84 | const opaqueValue = castToOpaque(value); 85 | // → 'opaqueValue' is 'AccountNumber' 86 | ``` 87 | 88 | Ideally, each opaque type would have a companion function for managing their creation. 89 | 90 | ```ts 91 | export type AccountNumber = Opaque; 92 | 93 | export function createAccountNumber(value: number) { 94 | return castToOpaque(value); 95 | } 96 | ``` 97 | 98 | Ensures basic type safety before casting to avoid invalid primitive assignment. 99 | 100 | ```ts 101 | const value = castToOpaque('123'); 102 | // ~~~~~ 103 | // Argument of type 'string' is not assignable to parameter of type 'number'. 104 | ``` 105 | -------------------------------------------------------------------------------- /.github/assets/banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 59 |
60 |
61 | 67 | Thinkmill 68 | 78 |
79 |
80 |
81 |

Emery

82 |

Polish for the rough parts of TypeScript

83 |
84 |
85 |

💎

86 |
87 |
88 |
89 |
90 |
91 |
92 | -------------------------------------------------------------------------------- /docs/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { AppProps as NextAppProps } from 'next/app'; 3 | import Head from 'next/head'; 4 | import { NextRouter } from 'next/router'; 5 | import { MarkdocNextJsPageProps } from '@markdoc/next.js'; 6 | 7 | import { ErrorLayout, DocsLayout, HomeLayout } from '../components/layouts'; 8 | import { Footer, Header, SideNavContext, useSidenavState } from '../components/shell'; 9 | 10 | import '../public/global.css'; 11 | 12 | // Types 13 | // ------------------------------ 14 | 15 | type AppProps

= { 16 | pageProps: P; 17 | router: NextRouter; 18 | } & Omit, 'pageProps'>; 19 | 20 | type PageProps = MarkdocNextJsPageProps & { 21 | isErrorPage?: boolean; 22 | }; 23 | 24 | // App 25 | // ------------------------------ 26 | 27 | const BRAND = 'Emery'; 28 | const SUMMARY = 'Polish for the rough parts of TypeScript.'; 29 | 30 | export default function MyApp(props: AppProps) { 31 | const sidenavContext = useSidenavState(); 32 | const { Component, pageProps, router } = props; 33 | const { markdoc, isErrorPage } = pageProps; 34 | 35 | let title = BRAND; 36 | let description = SUMMARY; 37 | if (markdoc) { 38 | if (markdoc.frontmatter.title) { 39 | title = markdoc.frontmatter.title; 40 | } 41 | if (markdoc.frontmatter.description) { 42 | description = markdoc.frontmatter.description; 43 | } 44 | } 45 | const isHome = router.pathname === '/'; 46 | const isDocs = !isHome && !isErrorPage; 47 | 48 | const Layout = (() => { 49 | if (isErrorPage) { 50 | return ErrorLayout; 51 | } 52 | if (isHome) { 53 | return HomeLayout; 54 | } 55 | if (isDocs) { 56 | return DocsLayout; 57 | } 58 | 59 | return Fragment; 60 | })(); 61 | 62 | const brandAppendedTitle = `${title} — ${BRAND}`; 63 | 64 | return ( 65 | <> 66 | 67 | {isHome ? `${BRAND} - ${title}` : brandAppendedTitle} 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | {isDocs ? ( 85 | <> 86 | 87 | 88 | 89 | ) : ( 90 | <> 91 | 92 | 93 | 94 | )} 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 |

105 | 106 | 107 | 108 | 109 | 110 | {isHome ?