Skip to content

Instantly share code, notes, and snippets.

@TClark1011
Last active December 4, 2025 01:18
Show Gist options
  • Select an option

  • Save TClark1011/17df203095014b8fa2bf1166fd62d433 to your computer and use it in GitHub Desktop.

Select an option

Save TClark1011/17df203095014b8fa2bf1166fd62d433 to your computer and use it in GitHub Desktop.
Notes

Bridge Mobile Problems

This file lists some common problems you may run into when developing Homescreens for IOS and Android

bridge.readFile Fails

bridge.readFile will often fail on IOS. This seems to be caused by a quirk with the way the IOS bridge handles downloading files. Luckily, it will often work on repeated attempts, so you can create a dedicated readFile function that uses some kind of promise retry logic:

export const promiseWait = (ms: number) =>
	new Promise((resolve) => {
		setTimeout(resolve, ms);
	});

type RunAsyncWithRetryOptions = {
	onFailure?: (e: any, tryNumber: number) => void;
	retries?: number;
	delay?: number;
};

export const runAsyncWithRetries = async <T>(
	fn: () => Promise<T>,
	options: RunAsyncWithRetryOptions = {},
	startingRetries: number | undefined = undefined, // Should only be used internally
): Promise<T> => {
	const finalOptions: Required<RunAsyncWithRetryOptions> = {
		delay: 1000,
		retries: 3,
		onFailure: () => {},
		...options,
	};
	const { retries, delay, onFailure } = finalOptions;
	const actualStartingRetries = startingRetries ?? retries;

	const currentAttemptNumber = actualStartingRetries - retries + 1;
	try {
		return await fn();
	} catch (e) {
		onFailure(e, currentAttemptNumber);
		if (retries > 0) {
			await promiseWait(delay);
			return runAsyncWithRetries(fn, finalOptions, actualStartingRetries);
		}
		throw e;
	}
};

/**
 * `bridge.readFile` is risky on IOS, but often works when
 * retried. All calls to `bridge.readFile` should instead
 * use this function.
 */
export const readFile: typeof bridge.readFile = (params) =>
	runAsyncWithRetries(() => bridge.readFile(params), { retries: 10 });

Error when JSON.parse'ing strings from bridge.readFile

There is a bug in the Hub where escaped characters are stringified incorrectly when bridge.readFile is called on IOS, which causes an error to be thrown when you try to parse those strings with JSON.parse. I have observed the issues occurring with line breaks and escaped quotation marks inside strings.Hopefully this bug will be fixed in the hub, but until then you need to carefully manage the contents of JSON files:

  • Do not have any double quotes inside strings
  • Minify JSON files before uploading them to remove line breaks

Missing file.url when using bridge.getList

On mobile, when you fetch a list of files using bridge.getList, the downloadURL field will be missing, and at time of writing (10/05/2024) you cannot fetch it by using includeAttributes. you have 2 options:

  • If the file is an image, you can use the thumbnail field instead. I assume but have not confirmed that this will yield a lower resolution, compressed version of the image, so if its essential that it be high resolution, see the other option
  • For non images, or images that need to high resolution, you will need to use getEntity to fetch all the info of the file directly.

Broken Entity Text

The text encoding logic on IOS can cause special characters to break (most common is '&'), you can pass any strings that come from entities to the decodeHtml function exports by @gs-libs/utils.

Alternatively, if the problem is proving to be very common, we can wrap the doCall method of the bridge with a function that deeply maps through all results and decodes any strings that contain HTML entities:

import { BridgeServices } from "@gs-libs/bridge";
import { decodeHtml } from "@gs-libs/utils";

export const deepMapObject = <T>(
	obj: T,
	mapFn: (value: any, key: string) => any,
): T => {
	if (typeof obj !== "object") {
		return obj;
	}

	if (Array.isArray(obj)) {
		return obj.map((item) => deepMapObject(item, mapFn)) as any;
	}

	// obj is plain object...

	return mapValues(obj, (value, key) => {
		if (typeof value === "object") {
			return deepMapObject(value, mapFn);
		}

		return mapFn(value, key);
	}) as any;
};

/**
 * Extend a function by providing an additional function which runs
 * after the original, receiving the original function's return value.
 * Uses `Proxy` to ensure any `this` references are preserved.
 */
export const extendFunction = <Fn extends (...args: any[]) => any, NewReturn>(
	baseFn: (...args: Parameters<Fn>) => ReturnType<Fn>,
	extension: (result: ReturnType<Fn>) => NewReturn,
) =>
	new Proxy(baseFn, {
		apply: (target, thisArg, args) => {
			const result = Reflect.apply(target, thisArg, args);
			return extension(result);
		},
	});

const decodeAllStringsInObject = (obj: any) =>
	deepMapObject(obj, (value) => {
		if (typeof value === "string") {
			return decodeHtml(value);
		}
		return value;
	});

export const bridge = new BridgeServices();

/**
 * On IOS, text from entities returned  from the bridge can have
 * broken special characters, so we wrap the `doCall` method to
 * fix any strings we get back.
 */
bridge.doCall = extendFunction(bridge.doCall, (resultPromise) =>
	resultPromise.then(decodeAllStringsInObject),
);

Missing Story Excerpts

On IOS, stories don't include the excerpt field, so you should do something like this:

/**
 * On IOS, stories don't include the `excerpt` field, so we
 * need to fall back to the `message` field.
 *
 * NOTE: You also must specify `includeAttributes: ['message']`
 * in the call
 */
export const getStoryExcerpt = (story: Story) => story.excerpt || story.message;

Some Stories Not Showing Up with getList While Using Channel Parent ID

IOS has showAlias field in the getList method turned off by default, but every other platform has it as on. Just manually specify showAlias: true in the getList call.

Fetching Children of Channel Without Access

On most platforms, if you try to use getList to get the stories from a channel that the user does not have privilege to access, an error will be thrown. However, on IOS no error will be thrown and we instead just get an empty array. This is important if you are using getList to determine if a user has read access to a channel.

Old Story Data Being Returned After Calling bridge.editStory

on iOS, any story updates first are stored as a "draft", which acts as a queue for pending updates. You can query this list with bridge.getDraftList(). To make sure you are always getting the latest version of the story, you can keep calling bridge.getDraftList() on an interval, and only proceed once the story you are looking for is no longer in the list.

Sometimes if an update fails, it will stay in the draft list for a while, but while have a field draftStatus: "failed", so you should not count an item as a draft if it has this field.

Mobile Apps Don't Have Calendar

The mobile apps don't include a calendar menu like the web and windows apps do.

The easiest solution is to just instead open the 'Meetings' menu, which as far as I can tell just displays a user's scheduled calendar events in a list format.

Improving This In The Future: If clients keep asking for a proper calendar on mobile, we could develop one as a .btca app that we just upload for each client that wants it, then we just open that app from the homescreen. Best thing to do would be to setup the repo as a template so we can just drop in the client's branding and create a build.

Deleting Files

You can delete files using the bridgeAPI via passing a files field to the editStory method.

bridge.editStory({
	...otherParams,
	files: [],
});

the above snippet will delete all files in the story.

Only Deleting Specific Files

IMPORTANT: This has not been tested at time of writing, so it may not work as expected.

It may be possible to only delete specific files from a story by passing some of the stories current files to that files field.

const filesToKeep = story.files.filter(fileIsWanted);

bridge.editStory({
	...otherParams,
	files: filesToKeep,
});

entityName Type Inference Proof of Concept

This code is a proof of concept for how we could infer the return type on API methods such as getList and getEntity based on the provided entityName parameter.

import {
  Channel,
  File,
  FileCollection,
  GetEntityParams,
  GetListParams,
  Link,
  Story,
  Tab,
  User,
} from '@gs-libs/bridge';

type MethodKeyToParams = {
  getList: GetListParams;
  getEntity: GetEntityParams;
};

type EntityNameToEntity = {
  story: Story;
  file: File;
  channel: Channel;
  tab: Tab;
  user: User;
  fileCollection: FileCollection;
  link: Link;
};

type InferOperationReturn<
  MethodKey extends keyof MethodKeyToParams,
  EntityName
> = EntityName extends keyof EntityNameToEntity
  ? MethodKey extends 'getList'
    ? EntityNameToEntity[EntityName][]
    : MethodKey extends 'getEntity'
    ? EntityNameToEntity[EntityName]
    : never
  : never;

export const doBridgeOperation = <
  MethodKey extends keyof MethodKeyToParams,
  EntityName extends MethodKeyToParams[MethodKey] extends { entityName: string }
    ? MethodKeyToParams[MethodKey]['entityName']
    : keyof EntityNameToEntity
>(
  methodKey: MethodKey,
  params: MethodKeyToParams[MethodKey] &
    (MethodKeyToParams[MethodKey] extends { entityName: string }
      ? { entityName: EntityName }
      : Record<never, never>)
): Promise<InferOperationReturn<MethodKey, EntityName>> => {
  // Actual implementation not included, this is just a prototype
  // for the typing
  return {} as any;
};

const a: Promise<Link[]> = doBridgeOperation('getList', {
  entityName: 'link',
});

HS Form Submissions

Homescreens run inside an iframe element, and that iframe is not provided with the permissions to perform form submissions. You may be able to submit forms in dev, but it won't work in prod.

You need to store inputs as variables, and if you want to make use of submitting on an enter press, you need to do that manually with an event listener.

If you need to make use of full advanced form functionality, you can still use stuff like react-hook-form, you just need to write your own logic for when stuff like validation and the onSubmit function is run.

Improved Craco Config

When working on an older repo that still uses CRA/CRACO, you can us the following config (scripts/craco.config.js) so that builds will automatically apply our standard naming conventions.

const chalk = require("chalk");
const webpack = require("webpack");
const ZipPlugin = require("zip-webpack-plugin");
const getClientEnvironment = require("react-scripts/config/env");
const { whenProd, when } = require("@craco/craco");
const { execSync } = require("child_process");

// first 7 characters of the latest commit hash, used in the
// zipped bundle filename
const latestCommitHash = execSync("git rev-parse HEAD")
	.toString()
	.trim()
	.slice(0, 7);

const currentDate = new Date()
	.toLocaleDateString("en-GB", {
		day: "numeric",
		month: "short",
		year: "2-digit",
	})
	.replace(/ /g, "-")
	.toLowerCase();

/**
 * Overriding some CRA Webpack Config.
 * WARNING: might break when we update CRA templates!
 */

/**
 * NOTE: override some jest config too if needed
 */
module.exports = () => {
	return {
		webpack: {
			configure: (webpackConfig, { env, paths }) => {
				when(env.NODE_ENV !== "test", () =>
					console.log(chalk.green`Executing {bold CRA Configuration Override}`),
				);

				const packageJson = require(paths.appPackageJson);
				const reactEnv = getClientEnvironment(
					paths.publicUrlOrPath.slice(0, -1),
				);

				/**
				 * Force devtool to have no sourcemap on Production
				 */
				whenProd(() => {
					console.log(chalk.magenta`Forcing {bold devtools} off`);
					webpackConfig.devtool = false;
				});

				/**
				 * Adding ZIP plugin to bundle build to btca
				 */
				whenProd(() => {
					webpackConfig.plugins.push(
						new ZipPlugin({
							filename: `${packageJson.name}-${latestCommitHash}-${currentDate}`,
							extension: "btca",
						}),
					);
					console.log(chalk.magenta`{bold Zip Plugin} Added`);
				});

				/**
				 * Adding babel plugin lodash to optimise lodash import
				 * https://lodash.com/per-method-packages
				 */
				console.log(
					chalk.magenta`adding {bold babel-plugin-lodash} to improve lodash cherry pick`,
				);
				webpackConfig.module.rules.forEach((r) => {
					if (r.oneOf) {
						const babelLoader = r.oneOf.find(
							(rr) => rr.loader.indexOf("babel-loader") !== -1,
						);
						if (babelLoader && babelLoader.options) {
							babelLoader.options.plugins = [
								...babelLoader.options.plugins,
								require.resolve("babel-plugin-lodash"),
								[
									require.resolve("babel-plugin-react-remove-properties"),
									{
										properties: [/data-testid/],
									},
								],
							];
						}
					}
				});

				/**
				 * Overriding process.env to add app name and app version.
				 */
				console.log(
					chalk.magenta`Overriding env variables, adding {bold appname}`,
				);
				webpackConfig.plugins = webpackConfig.plugins.filter(
					(plugin) => !(plugin instanceof webpack.DefinePlugin),
				);

				webpackConfig.plugins.push(
					new webpack.DefinePlugin({
						"process.env": {
							...reactEnv.stringified["process.env"],
							BTC_GS_APP_NAME: JSON.stringify(packageJson.name),
							BTC_GS_APP_VERSION: JSON.stringify(packageJson.version),
						},
					}),
				);
				return webpackConfig;
			},
		},
	};
};

React Error Boundary

Look at the ErrorScreen component in gs-hs-clorox-roadmap for a good example of a helpful error screen.

You will need to adapt the code to fit other projects.

import { FC, useEffect, useRef } from "react";
import {
	useParams,
	useRouteError,
	useSearchParams,
	useMatches,
	useLocation,
	useNavigation,
	useOutletContext,
} from "react-router-dom";
import styles from "./ErrorScreen.module.scss";
import { extractErrorMessage } from "@/utils";
import { usePlanSnapshotStore } from "@/stores/planSnapshotStore";
import { useSearchStore } from "@/stores/searchStore";
import { usePlanNotesModalStore } from "@/stores/planNotesModalStore";
import { useCustomCourseModalStore } from "@/stores/customCourseModalStore";
import { queryClient } from "@/services";

const getAllStores = () => {
	const planSnapshot = usePlanSnapshotStore.getState();
	const search = useSearchStore.getState();
	const planNotesModal = usePlanNotesModalStore.getState();
	const customCourseModal = useCustomCourseModalStore.getState();

	return {
		planSnapshot,
		search,
		planNotesModal,
		customCourseModal,
	};
};

const getAllQueries = () => queryClient.getQueryCache().getAll();

const useFullRouterState = () => {
	const pathParams = useParams();
	const [searchParams] = useSearchParams();
	const matches = useMatches();
	const location = useLocation();
	const navigation = useNavigation();
	const outletContext = useOutletContext();

	return {
		pathParams,
		searchParams,
		matches,
		location,
		navigation,
		outletContext,
	};
};

const ErrorScreen: FC = () => {
	const error = useRouteError();
	const routerState = useFullRouterState();

	const hasLoggedRef = useRef(false);

	useEffect(() => {
		if (hasLoggedRef.current) return;

		const stores = getAllStores();
		const queries = getAllQueries();

		/**
		 * We log all of the app state we have access too
		 * for debugging purposes
		 */
		console.error("An error has occurred", {
			error,
			queries,
			stores,
			routerState,
		});

		hasLoggedRef.current = true;
	});

	return (
		<div className={styles.errorScreen}>
			<div className={styles.inner}>
				<h1 className={styles.title}>Error</h1>
				<p>{extractErrorMessage(error)}</p>
			</div>
		</div>
	);
};

export default ErrorScreen;

Improvement

Display the object that gets logged to the console on the screen with a button to copy it to the clipboard, or allow the user to download it as a file. Add instructions for the user to send that information to the support team.

Searching With Tags

You can see an example of searching with tags in the gs-hs-gore-configurable HS repo.

All the methodologies here can be used on either bridge.searchStories or bridge.searchFiles.

To apply tag filters to search queries made with either bridge.searchStories or bridge.searchFiles, you need to append the tags to the end of the query string like this:

bridge.searchStories({
	q: "search term here (tags:tag_1 AND tags:tag_2)",
});

That code snippet will search for stories that have both tag_1 and tag_2 applied to them. You can invert a tag filter to only look for entities without a specific tag by appending a - to the tag filter text like this:

bridge.searchStories({
	q: "search term here(tags:-tag_1)",
});

That will search for stories that do not have tag_1 applied to them.

You can also do OR searching:

bridge.searchFiles({
	q: "search term here (tags:tag_1 OR tags:tag_2)",
});

You can combine this with AND searching like this:

bridge.searchFiles({
	q: "search term here (tags:tag_1a OR tags:tag_1b) AND (tags:tag_2 AND tags:tag_3)",
});

width: max-content VS auto

Try to use width: auto instead of width: max-content where possible, as IOS safari can have non-standard behaviour when width: max-content is used in a flex container.

Dynamic text with no spaces

When you are displaying some dynamically fetched text in a limited amount of room, an important thing to test for is if it works with long text without spaces.

Eg; In Bigtincan home screens, its not uncommon to have long story or file names that use underscores or dashes instead of spaces, which will not wrap like text with spaces would.

Scrollbar Width

Some devices will always show scrollbars (Windows) and the presence of the scrollbar takes up width on the page, others will only show them when needed (Mac) and the scrollbar will show up over the top of the page and won't take up width. This can break if you have content with width: 100vw and you have vertical scrolling, as the vertical scroll bar will push you past the 100vw width and end up with a horizontal scrollbar as well.

You can change mac settings to behave the same as windows in System Preferences > General > Show Scroll Bars > (Always)

Do Not Use Margins to Simulate Spaces

Sometimes, it may make sense for you to have 2 pieces of text that you need displayed inline next to each other that have no actual space between them, so you use a margin instead to create space.

<p>
	<span class="primaryLabel">{primaryLabel}</span>
	<span class="secondaryLabel">{secondaryLabel}</span>
</p>
.primaryLabel {
	margin-right: 10px;
}

However, if you do this, it can break the browser's automatic line wrapping. Do this instead:

<p>
	<span class="primaryLabel">{primaryLabel}</span>{" "}
	<span class="secondaryLabel">{secondaryLabel}</span>
</p>
/**
* This allows you to use a CSS grid to have columns
* that automatically adjust to fill the entire width
* of the container
*/
.grid {
/* In order for this to work, you must have specific min and max widths
for the columns */
--column-max-width: your-max-width-here;
--column-min-width: your-min-width-here;
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(var(--column-min-width), var(--column-max-width))
);
justify-content: space-between; /* If it can't fill the parent exactly, space the items out */
}
/*
* This is a quick and easy method for having a grid where all columns
* are the same dynamic width, and the number of columns changes
* responsively at different breakpoints
*/
.grid {
--column-count: 2; /* The number of columns you want for mobile */
/*
* If you are designing for desktop primarily, the initial value for
* `--column-count` should be the number of columns you want for desktop,
* and adjust the media queries accordingly.
*/
display: grid;
grid-template-columns: repeat(var(--column-count), minmax(0, 1fr));
/*
* `minmax(0, 1fr)` makes sure all columns are the same width.
* If you need a minimum column width, pass it as the first argument
* in `minmax` instead of `0`.
*/
}
@media screen and (min-width: 768px) {
.grid {
--column-count: 4;
}
}
@media screen and (min-width: 1024px) {
.grid {
--column-count: 6;
}
}
@media screen and (min-width: 1280px) {
.grid {
--column-count: 8;
}
}
/**
* THIS IS A DRAFT OF A MINIMALIST LAYOUT CSS FRAMEWORK, THE GOAL WOULD BE TO
* TO PROVIDE A LIGHTWEIGHT, OPINIONATED APPROACH TO LAYOUTS LIKE BOOTSTRAP,
* BUT WITHOUT THE BAGGAGE OF A FULL CSS FRAMEWORK.
*/
// CONFIGURATION
$config-max-grid-columns: 12;
// When defining breakpoints, we treat mobile devices as the default
// and define breakpoints as min-width for larger devices.
$config-bp-tablet-min: 768px;
$config-bp-desktop-min: 1024px;
// TODO: define key/value pairs for breakpoints so they
// can be iterated through
@if $config-max-grid-columns < 1 {
@error "The maximum number of grid columns must be at least 1";
}
// TODO: define all the styles as a mixin, then define the base
// along with breakpoint conditionals.
// STYLES
.flex {
&.row,&.col {
display: flex;
}
&.row {
flex-direction: row;
&.h- {
&center {
justify-content: center;
}
&left {
justify-content: flex-start;
}
&right {
justify-content: flex-end;
}
&between {
justify-content: space-between;
}
&around {
justify-content: space-around;
}
}
&.v- {
&center {
align-items: center;
}
&top {
align-items: flex-start;
}
&bottom {
align-items: flex-end;
}
&stretch {
align-items: stretch;
}
}
}
&.col {
flex-direction: column;
&.h- {
&center {
align-items: center;
}
&top {
align-items: flex-start;
}
&bottom {
align-items: flex-end;
}
&stretch {
align-items: stretch;
}
}
&.v- {
&center {
justify-content: center;
}
&left {
justify-content: flex-start;
}
&right {
justify-content: flex-end;
}
&between {
justify-content: space-between;
}
&around {
justify-content: space-around;
}
}
}
}
.grid {
display: grid;
@for $i from 1 through $config-max-grid-columns {
&.col-#{$i} {
grid-template-columns: repeat($i, 1fr);
}
}
}
/**
* An example of how you can use Sass variables and CSS
* custom properties to theme your application.
*/
@use 'sass:map';
/**
* By using Sass variables to contain our CSS custom properties,
* we can prevent mistakes in our code, as the Sass compiler will
* throw an error if we reference a variable that does not exist.
*/
$color-primary: '--color-primary';
$color-primary-foreground: '--color-primary-foreground';
$color-secondary: '--color-secondary';
$color-secondary-foreground: '--color-secondary-foreground';
$color-schemes: (
light: (
mode: light,
primary: #007bff,
primary-foreground: #ffffff,
secondary: #6c757d,
secondary-foreground: #ffffff
),
dark: (
mode: dark,
primary: #375a7f,
primary-foreground: #ffffff,
secondary: #444,
secondary-foreground: #ffffff
)
);
/**
* Take a map containing the color values for a color scheme
* and define the corresponding CSS custom properties.
*/
@mixin apply-color-scheme($scheme) {
color-scheme: map.get($scheme, mode);
#{$color-primary}: map-get-essential($scheme, primary);
#{$color-primary-foreground}: map-get-essential($scheme, primary-foreground);
#{$color-secondary}: map-get-essential($scheme, secondary);
#{$color-secondary-foreground}: map-get-essential($scheme, secondary-foreground);
}
@function map-get-essential($map, $key) {
$value: map.get($map, $key);
@if not $value {
@error "Key `#{$key}` not found in map `#{$map}`.";
}
@return $value;
}
/**
* Shorthand QOL helper that lets us use our CSS custom properties
* without having to write out string interpolation every time.
*/
@function _var($name, $fallback: null) {
@return var(#{($name, $fallback)});
// The weird syntax with the list makes sure the fallback
// won't get passed in if its null.
}
html {
@include apply-color-scheme(map.get($color-schemes, light)); // Default to light theme
@each $key, $scheme in $color-schemes {
&.theme--#{$key} {
@include apply-color-scheme($scheme);
}
}
}
// EXAMPLE COMPONENT STYLE
button {
padding: 10px 20px;
border-radius: 5px;
background-color: _var($color-primary);
color: _var($color-primary-foreground);
border: none;
cursor: pointer;
&:hover {
background-color: _var($color-secondary);
color: _var($color-secondary-foreground);
}
}

Why Do SVGs Have Extra Bottom Padding?

Sometimes when you use an SVG, it may have what appears to be extra padding on the bottom, however when you inspect it in devtools, it says there is no padding or margin.

This is because SVGs are rendered by the browser using similar rules as it does for rendering text. That extra space at the bottom is there for text rendering to account for letters that dip beneath the baseline (eg; "y", "g", "j").

The Solution

Just add the following style to the svg:

.yourSvg {
	line-height: 0;
}

You may also have to apply the style to the parent elements, if they are simple wrappers that only contain the svg.

.yourSvgWrapper,
.yourSvgWrapper * {
	line-height: 0;
}
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* This is a basic implementation of type-safe dependency injection. It uses
* generator functions that yield `InjectionYield` instances, which specify
* what class then must be injected. The `injectDependenciesAndRun` takes
* such a generator function and an array of all the classes that can be
* injected, and runs the generator, injecting the required dependencies.
*/
/**
* TODO:
* - Find a way to allow substitute classes to extend from the base class.
* Currently the typing system only allows for classes that match the
* dependency class exactly, with no extra fields. Try disallowing the
* dependencies to be inferred from the array of depenendency classes,
* although this may remove the type-checking that enforces all dependency
* classes are provided, although that can be re-achieved by other means.
*/
// # UTILS
type Class<T, Arguments extends unknown[] = any[]> = {
prototype: Pick<T, keyof T>;
new (...arguments_: Arguments): T;
};
type InferClassInstance<T> = T extends Class<infer U> ? U : never;
type InferClassConstructorArguments<T> = T extends Class<any, infer U>
? U
: never;
type InferGeneratorReturnType<T extends Generator> = T extends Generator<
any,
infer U,
any
>
? U
: never;
//# MAIN LOGIC
type DependantGenerator<DependentClass extends Class<any>, Return> = Generator<
InjectionYield<DependentClass>,
Return,
InferClassInstance<DependentClass>
>;
class InjectionYield<TheClass extends Class<any>> {
public readonly args: InferClassConstructorArguments<TheClass>;
constructor(
public readonly cls: TheClass,
...args: InferClassConstructorArguments<TheClass>
) {
this.args = args;
}
*[Symbol.iterator](): Generator<
this,
InferClassInstance<TheClass>,
InferClassInstance<TheClass>
> {
const theInstance = yield this;
return theInstance;
}
}
const compareClasses = (a: Class<any>, b: Class<any>) => {
const aClasses = [a, ...(SUBSTITUTES.get(a) || [])];
const bClasses = [b, ...(SUBSTITUTES.get(b) || [])];
return aClasses.some((aCls) => bClasses.includes(aCls));
};
const withDependencies =
<
DependencyClass extends Class<any>,
TGenFunc extends (
...args: any[]
) => DependantGenerator<DependencyClass, any>,
>(
genFunc: TGenFunc,
dependencies: DependencyClass[],
) =>
(
...args: Parameters<TGenFunc>
): InferGeneratorReturnType<ReturnType<TGenFunc>> => {
const generator = genFunc(...args);
// Iterator over generator
let currentInjectionYield = generator.next();
while (!currentInjectionYield.done) {
const classToInject = dependencies.find((cls) =>
compareClasses(cls || cls, currentInjectionYield.value.cls),
);
if (!classToInject)
throw new Error(
`Dependency not provided: ${currentInjectionYield.value.cls.name}`,
);
currentInjectionYield = generator.next(
new classToInject(...currentInjectionYield.value.args),
);
}
return currentInjectionYield.value;
};
// Maps classes to other classes that can substitute for them
const SUBSTITUTES = new WeakMap<Class<any>, Class<any>[]>();
/**
* Class decorator that allows a class to be used as a substitute for
* another in dependency injection. The type of the substitute class
* must be compatible with the original class. It is recommended that
* substitute classes `implement` the original class to ensure type
* compatibility.
*
* Substitution relationships are birdirectional, so if `A` is a substitute
* for `B`, then `B` is also a substitute for `A`. This is required to allow
*
*/
function SubstituteFor<T extends Class<any>>(original: T) {
return function <U extends Class<any>>(target: U) {
const originalSubstitutes = SUBSTITUTES.get(original) || [];
originalSubstitutes.push(target);
SUBSTITUTES.set(original, originalSubstitutes);
const targetSubstitutes = SUBSTITUTES.get(target) || [];
targetSubstitutes.push(original);
SUBSTITUTES.set(target, targetSubstitutes);
};
}
// # EXAMPLE
class StubService {
doSomething() {
console.log("Hi from StubService");
}
}
@SubstituteFor(StubService) // This class will be recognized as a substitute for StubService in DI
class MockStubService implements StubService {
doSomething() {
console.log("I am a sneaky mock of StubService!");
}
}
class AltStubService {
constructor(public someArg: string) {}
exampleMethod() {
console.log("Hi from AltStubService, I received this arg:", this.someArg);
}
}
function* a() {
const stubService = yield* new InjectionYield(StubService);
const altStubService = yield* new InjectionYield(
AltStubService,
"an argument",
);
stubService.doSomething();
altStubService.exampleMethod();
return "All done!" as const;
}
const result = withDependencies(a, [StubService, AltStubService])();
console.log("[a RETURN VALUE] ", result);
// Nested DI function calls automatically add dependencies to typing
function* b(paramA: number, paramB: number) {
const resultFromA = yield* a();
return "hi from B" as const;
}
const result2 = withDependencies(b, [MockStubService, AltStubService])(1, 2); // Can pass in mock classes if the types work
// # Advanced example usage
/**
* This will demonstrate a use case of dependency injection, we will
* inject a mock service that ensures a normally unpredictable service
* returns values that are predictable, which could be useful for testing.
*/
/**
* Pseudo-random number generator. Generated numbers will be unpredictable,
* for humans, but will always be the same for the same seed.
*
* It works by taking a starting seed, then generating a new number using
* that seed, and then takes the new generated value and uses that as the
* next seed, and so on.
*/
class DeterministicRandomService {
constructor(private seed: number) {
this.next(); // Warm up the generator
}
next(): number {
this.seed = (this.seed * 48271) % 2147483647;
return this.seed / 2147483647;
}
}
function* randomNumberGame(seed: number) {
console.log("----- NEW GAME -----");
console.log("Starting a new game with seed:", seed);
const randomService = yield* new InjectionYield(
DeterministicRandomService,
seed,
);
const playerARoll = randomService.next();
const playerBRoll = randomService.next();
console.log(`Player A rolled: ${playerARoll}`);
console.log(`Player B rolled: ${playerBRoll}`);
if (playerARoll > playerBRoll) {
console.log("Player A wins!");
} else if (playerBRoll > playerARoll) {
console.log("Player B wins!");
} else {
console.log("It's a tie!");
}
}
console.log("Standard rounds:");
for (let i = 0; i < 10; i++) {
withDependencies(randomNumberGame, [DeterministicRandomService])(i);
}
@SubstituteFor(DeterministicRandomService)
class MockRandomService extends DeterministicRandomService {
next(): number {
return 1;
}
}
console.log("");
console.log(
"Mocked rounds, every game will be a tie with both players rolling 1:",
);
console.log(" ");
for (let i = 0; i < 10; i++) {
withDependencies(randomNumberGame, [MockRandomService])(i);
}
/**
* Draft of a helper for creating custom typed arrays of entities. Could be very good
* for implementing state management logic.
*/
const standardEquality = <T>(a: T, b: T): boolean =>
a === b;
type EntityHelperSuiteOptions<T, GenerateInput, FindByInput> = {
isEqual?: (a: T, b: T) => boolean;
generate: (input: GenerateInput) => T;
findByMatcher: (input: FindByInput, entity: T) => boolean;
clone?: (entity: T) => T;
}
type EntityHelperConstructorInput<T, GenerateInput, FindByInput> = EntityHelperSuiteOptions<T, GenerateInput, FindByInput> | EntityHelperSuite<T, GenerateInput, FindByInput>;
class EntityHelperSuite<T, GenerateInput, FindByInput> implements EntityHelperSuiteOptions<T, GenerateInput, FindByInput> {
isEqual: (a: T, b: T) => boolean;
clone: (entity: T) => T;
generate: (input: GenerateInput) => T;
findByMatcher: (input: FindByInput, entity: T) => boolean;
constructor(
input: EntityHelperConstructorInput<T, GenerateInput, FindByInput>
) {
this.isEqual = input.isEqual ?? standardEquality;
this.clone = input.clone ?? structuredClone;
this.generate = input.generate
this.findByMatcher = input.findByMatcher;
}
}
type EnforceUniqueMode = 'allow' | 'prevent' | 'override' | 'throw';
// allow: allow duplicate entities to exist
// prevent: discard any duplicate entity that would be added
// override: replace existing entity with the new one
// throw: throw an error if a duplicate entity is added
class EntityArray<T, GenerateInput, FindByInput> {
private helpers: EntityHelperSuite<T, GenerateInput, FindByInput>;
private enforceUniqueMode: EnforceUniqueMode = 'allow'; // TODO: IMPLEMENT THIS
constructor(
public items: T[],
helpers: EntityHelperConstructorInput<T, GenerateInput, FindByInput>
) {
this.helpers = new EntityHelperSuite<T, GenerateInput, FindByInput>(helpers);
}
// Configuration methods
/**
* NOTE: Must consider case where duplicates already exist when
* unique mode is turned on.
*/
strictUnique(
enforce: boolean = true
): this {
this.enforceUnique = enforce;
return this;
}
// Custom methods
clone(): EntityArray<T, GenerateInput, FindByInput> {
return new EntityArray<T, GenerateInput, FindByInput>(
this.items.map(item => this.helpers.clone(item)),
this.helpers
);
}
// Base Array Methods
find(
predicate: (item: T) => boolean
): T | undefined {
return this.items.find(predicate);
}
findBy(
input: FindByInput,
): T | undefined {
return this.items.find(item => this.helpers.findByMatcher(input, item));
}
filter(
predicate: (item: T) => boolean
): EntityArray<T, GenerateInput, FindByInput> {
return new EntityArray<T, GenerateInput, FindByInput>(
this.items.filter(predicate),
this.helpers
);
}
map<U>(
mapper: (item: T) => U
): U[] {
return this.items.map(mapper);
}
forEach(
callback: (item: T) => void
): void {
this.items.forEach(callback);
}
reduce<U>(
reducer: (accumulator: U, item: T) => U,
initialValue: U
): U {
return this.items.reduce(reducer, initialValue);
}
some(
predicate: (item: T) => boolean
): boolean {
return this.items.some(predicate);
}
every(
predicate: (item: T) => boolean
): boolean {
return this.items.every(predicate);
}
includes(
item: T
): boolean {
return this.items.some(existingItem => this.helpers.isEqual(existingItem, item));
}
includesBy(
input: FindByInput,
): boolean {
return this.items.some(item => this.helpers.findByMatcher(input, item));
}
push(
item: T
): number {
if (this.enforceU)
this.items.push(item);
return this.length;
}
pushNew(
input: GenerateInput
): T {
const item = this.helpers.generate(input);
this.push(item);
return item;
}
get length(): number {
return this.items.length;
}
}
// EXAMPLE USAGE
type Todo = {
id: number;
title: string;
completed: boolean;
}
const todoHelpers = new EntityHelperSuite({
generate: (input: { title: string; completed?: boolean }): Todo => ({
id: Math.floor(Math.random() * 1000), // Simulating ID generation
completed: input.completed ?? false,
title: input.title
}),
isEqual: (a, b) => a.id === b.id,
findByMatcher: (id: number, todo) => todo.id === id,
clone: (todo) => ({ ...todo }),
})
const todoArray = new EntityArray([], todoHelpers);
/**
* Prototype for a function that returns a deep frozen version of an object.
* The passed in object itself will not be frozen, but rather a frozen copy
* is returned.
*
* The freezing is actually done by a proxy, rather than using Object.freeze
* directly. Nested objects are only frozen when they are accessed to reduce
* performance overhead.
*
* NOTE: You probably don't need this function, if you want to make sure a
* developer does not mutate an object, you can just cast it as `Readonly<T>`.
* This function is a last resort, perhaps if you are afraid an object might
* be mutated by a third-party library, or your code might be used in JS
* projects that won't respect the `Readonly<T>` type.
*/
const toFrozenDeep = <T>(obj: T): Readonly<T> => {
if (typeof obj !== 'object' || obj === null) {
return Object.freeze(obj) as T;
}
const frozenChildCache = new WeakMap();
return new Proxy(obj, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
const cachedFrozen = typeof value === 'object'
? frozenChildCache.get(value)
: undefined;
const frozenValue = cachedFrozen ?? toFrozenDeep(value);
if (typeof value === 'object' && !frozenChildCache.has(value)) {
frozenChildCache.set(value, frozenValue);
}
return frozenValue;
},
set: () => {
throw new TypeError('Cannot modify a frozen object');
}
})
}
type LogCommandMethod = 'log' | 'info' | 'warn' | 'error' | 'debug';
class Logger {
constructor(private labels: string[]) { }
private send(method: LogCommandMethod, ...args: any[]) {
console[method](`[${this.labels.join('.')}]: `, ...args);
}
log(...args: any[]) {
this.send('log', ...args);
}
info(...args: any[]) {
this.send('info', ...args);
}
warn(...args: any[]) {
this.send('warn', ...args);
}
error(...args: any[]) {
this.send('error', ...args);
}
debug(...args: any[]) {
this.send('debug', ...args);
}
}
type PickByValue<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
};
type PickMethods<T> = PickByValue<T, (...args: any[]) => any>;
type MethodNames<T> = keyof PickMethods<T>;
const methodLoggers = <T extends { constructor: { name: string } }>(target: T): Record<MethodNames<T>, Logger> =>
new Proxy({} as any, {
get: (obj, prop) => {
if (!(prop in obj)) {
const labels = [target.constructor.name, prop as string];
obj[prop] = new Logger(labels);
}
return obj[prop];
}
})
// EXAMPLE USAGE
class ExampleService {
private loggers = methodLoggers(this);
doSomething() {
const logger = this.loggers.doSomething;
logger.info('Doing something...');
}
switchItUp(someId: number) {
const logger = this.loggers.switchItUp;
logger.warn(`Switching it up for ID: ${someId}`);
}
}
const a = new ExampleService();
a.doSomething();
a.switchItUp(42);
/**
* Minimal prototype for adapting HTML events to an stream/observable-like API.
* Allows for more functional and re-usable logic in handling events.
* TODO:
* - `scan` method (see simple counter example here: https://baconjs.github.io)
* - Combine multiple emitters into one
* - Filter events
*/
class Observable<Payload> {
private listeners: ((payload: Payload) => void)[] = [];
private constructor(
private setup: (emit: (payload: Payload) => void) => void,
private cleanup: (emit: (payload: Payload) => void) => void
) {
this.refresh();
}
/**
* Destroy existing listeners and set up new ones. If app context changes and new
* listeners need to be created (eg; more buttons are added to the DOM that need to
* be listened to), this method should be called.
*/
refresh(): void {
this.cleanup(this.emit);
this.setup(this.emit);
}
/**
* Listen to the observable
*/
on(listener: (payload: Payload) => void) {
this.listeners.push(listener);
return () => this.off(listener); // return callback to unsubscribe
}
/**
* Stop listening to the observable
*/
off(listener: (payload: Payload) => void): void {
this.listeners = this.listeners.filter(l => l !== listener);
}
emit = (payload: Payload): void => {
// Must be an arrow function to maintain `this` context
this.listeners.forEach(listener => listener(payload));
}
// Builder functions
/**
* Create an observable with no default condition to emit.
* Must manually trigger `emit`
*/
static make<Payload>(): Observable<Payload> {
return new Observable<Payload>(
emit => {
// No setup needed
},
emit => {
// No cleanup needed
}
);
}
/**
* Create an observable that listens to dom events on specific
* elements
*/
static fromEvent<EventType extends keyof HTMLElementEventMap>(
selector: string,
eventType: EventType
): Observable<HTMLElementEventMap[EventType]> {
const emitter = new Observable<HTMLElementEventMap[EventType]>(
emit => {
document.querySelectorAll(selector).forEach(element => {
element.addEventListener(eventType, emit as EventListener);
});
},
emit => {
document.querySelectorAll(selector).forEach(element => {
element.removeEventListener(eventType, emit as EventListener);
});
}
);
return emitter;
}
}
// EXAMPLE USAGE
const toggleModal = () => {
//...
}
const modalToggleClick = Observable.fromEvent(
'#modal-toggle',
'click'
)
modalToggleClick.on(() => {
toggleModal();
});
// Alternate
// Manually connect the observerable to the event callback,
// gives more control, useful for frontend frameworks eg;
// React, as you can connect the observable to the elements
// event handler props
const otherModalToggleClick = Observable.make<MouseEvent>();
document.querySelector<HTMLButtonElement>('#other-modal-toggle')
?.addEventListener('click', otherModalToggleClick.emit);
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* Prototype of a custom global state management system for React, using the "new"
* `useSyncExternalStore` hook.
* TODO:
* - SSR support (see jotai SSR) (look into potential issue of stores being re-used for multiple requests)
* - Action middleware (eg; logging, analytics)
*/
import { useSyncExternalStore } from "react";
// Utility functions, should be moved to a separate file
export const basicEquality = <T>(a: T, b: T): boolean => a === b;
export const memoize = <Fn extends (...args: any[]) => any>(fn: Fn): Fn => {
const cache: [Parameters<Fn>, ReturnType<Fn>][] = [];
return ((...args: Parameters<Fn>) => {
const cached = cache.find(
([cachedArgs]) =>
cachedArgs.length === args.length &&
cachedArgs.every((arg, i) => arg === args[i]),
);
if (cached) {
return cached[1];
}
const result = fn(...args);
cache.push([args, result]);
return result;
}) as unknown as Fn;
};
// Types
export type StoreListenerFn<T> = (state: Readonly<T>) => void;
export type StoreAction<Type extends string, Params extends unknown[]> = {
type: Type;
params: Params;
// replacing typical `payload` field with `params` array makes it MUCH easier
// to handle actions in a type-safe way.
};
export type ExtractActionByType<
Action extends StoreAction<string, unknown[]>,
Type extends string,
> = Extract<Action, { type: Type }>;
export type StoreActionHandlers<
Data,
Action extends StoreAction<string, unknown[]>,
> = {
[T in Action["type"]]: (
state: Data,
...params: ExtractActionByType<Action, T>["params"]
) => Data;
};
export type StoreSelectorFn<Data, Selection> = (state: Data) => Selection;
export type StoreSelectorFnBuilder<
Data,
Selection,
Params extends unknown[],
> = (...params: Params) => StoreSelectorFn<Data, Selection>;
// Store Class
export class Store<
Data,
Action extends StoreAction<string, any[]> = never,
Selectors extends Record<string, StoreSelector<Data, any, any[]>> = Record<
string,
never
>,
> {
private state: Data;
private listeners: StoreListenerFn<Data>[] = [];
private handlers: StoreActionHandlers<Data, Action> = {} as any;
private selectorBuilders: Selectors = {} as any;
private previousSelections: Map<StoreListenerFn<any>, any> = new Map();
constructor(state: Data) {
this.state = state;
}
private emit() {
for (const listener of this.listeners) {
listener(this.get());
}
}
// Subscription methods
unsubscribe(listener: StoreListenerFn<Data>) {
this.listeners = this.listeners.filter((l) => l !== listener);
}
subscribe(listener: StoreListenerFn<Data>): () => void {
this.listeners.push(listener);
listener(this.state);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
subscribeSelect<Selection>(
selector: (state: Data) => Selection,
selectionListener: (selection: Selection) => void,
equality: (a: Selection, b: Selection) => boolean = basicEquality,
) {
const fullListener: StoreListenerFn<Data> = (state) => {
const currentSelection = selector(state);
const selectionHasChanged =
!this.previousSelections.has(selectionListener) ||
!equality(
this.previousSelections.get(selectionListener),
currentSelection,
);
if (selectionHasChanged) {
selectionListener(currentSelection);
this.previousSelections.set(selectionListener, currentSelection);
}
};
return this.subscribe(fullListener);
}
// State management methods
private set(newState: Data): void {
this.state = newState;
this.emit();
}
get(): Readonly<Data> {
return this.state as Readonly<Data>;
}
// Actions
private handleAction(action: Action): void {
const handler = this.handlers[action.type as Action["type"]];
if (!handler) throw new Error(`No handler for action type: ${action.type}`);
const newState = handler(this.state, ...action.params);
this.set(newState);
}
addAction<Type extends string, Params extends unknown[]>(
type: Type,
handler: (state: Data, ...params: Params) => Data,
): Store<Data, Action | StoreAction<Type, Params>> {
(this.handlers as any)[type] = handler;
return this as any;
}
dispatch<Type extends Action["type"]>(
type: Type,
...params: ExtractActionByType<Action, Type>["params"]
): void {
const action = {
type,
params,
};
this.handleAction(action as never as Action);
}
// Selectors
select<Selection>(selector: (state: Data) => Selection): Readonly<Selection> {
return selector(this.get()) as Readonly<Selection>;
}
composeSelectorBuilder<Selection, Params extends unknown[]>(
selectorWithState: (state: Data, ...params: Params) => Selection,
): (...params: Params) => StoreSelectorFn<Data, Selection> {
// For optimization, we cache selector functions, so we only ever create
// a single selector function for each unique set of parameters.
return memoize(
(...params: Params) =>
(state: Data) =>
selectorWithState(state, ...params),
);
}
addSelector<Key extends string, Params extends unknown[], Selection>(
key: Key,
selectorWithState: (state: Data, ...params: Params) => Selection,
equality?: (a: Selection, b: Selection) => boolean,
): Store<
Data,
Action,
Selectors & Record<Key, StoreSelector<Data, Selection, Params>>
> {
const selector = new StoreSelector(this, selectorWithState, equality);
(this.selectorBuilders as any)[key] = selector;
return this as any;
}
get selectors(): Selectors {
return this.selectorBuilders;
}
}
// Selectors
/**
* Selectors exist as their own class so they can encapsulate equality
* and memoization logic. They also contain a reference to the underlying
* store, so they can be passed directly into `useStoreSelector` hook
* without the need to pass the store separately.
*/
class StoreSelector<Data, Selection, Params extends unknown[] = []> {
store: Store<Data, any, any>;
selectorBuilder: StoreSelectorFnBuilder<Data, Selection, Params>;
equality: (a: Selection, b: Selection) => boolean;
constructor(
store: Store<Data, any, any>,
selectorWithState: (state: Data, ...params: Params) => Selection,
equality: (a: Selection, b: Selection) => boolean = basicEquality,
) {
this.store = store;
this.selectorBuilder = store.composeSelectorBuilder(selectorWithState);
this.equality = equality;
}
}
export type InferStoreSelectorSelection<
TheSelector extends StoreSelector<any, any, any[]>,
> = TheSelector extends StoreSelector<any, infer Selection, any[]>
? Selection
: never;
export type InferStoreSelectorParams<
TheSelector extends StoreSelector<any, any, any[]>,
> = TheSelector extends StoreSelector<any, any, infer Params> ? Params : never;
// Hooks
export const useStore = <T>(store: Store<T, never>): Readonly<T> =>
useSyncExternalStore(
(listener) => store.subscribe(listener),
() => store.get(),
() => store.get(),
);
export const useStoreSelector = <
TheSelector extends StoreSelector<any, any, any[]>,
>(
selector: TheSelector,
...params: InferStoreSelectorParams<TheSelector>
): Readonly<InferStoreSelectorSelection<TheSelector>> => {
const selectorFn = selector.selectorBuilder(...params);
return useSyncExternalStore(
(listener) =>
selector.store.subscribeSelect(selectorFn, listener, selector.equality),
() => selector.store.select(selectorFn),
() => selector.store.select(selectorFn),
);
};
// EXAMPLE USAGE
type Todo = {
id: string;
text: string;
completed: boolean;
};
const todoStore = new Store<Todo[]>([])
// actions
.addAction("addTodo", (todos, text: string, completed: boolean) => [
...todos,
{ id: crypto.randomUUID(), text, completed },
])
.addAction("deleteTodo", (todos, id: string) =>
todos.filter((todo) => todo.id !== id),
)
.addAction("toggleTodo", (todos, id: string) =>
todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo,
),
)
.addAction("clearTodos", () => [])
// selectors
.addSelector("first", (todos) => todos[0])
.addSelector("id", (todos, id: string) =>
todos.find((todo) => todo.id === id),
)
.addSelector("completed", (todos) => todos.filter((todo) => todo.completed))
.addSelector("between", (todos, start: number, end: number) =>
todos.slice(start, end),
);
todoStore.dispatch("addTodo", "Learn TypeScript", false);
todoStore.dispatch("addTodo", "Build a React app", false);
todoStore.dispatch("clearTodos");
const useA = () => {
const todos = useStore(todoStore);
const firstTodo = useStoreSelector(todoStore.selectors.first);
const specificTodo = useStoreSelector(todoStore.selectors.id, "some-id");
const completedTodos = useStoreSelector(todoStore.selectors.completed);
const firstFiveTodos = useStoreSelector(todoStore.selectors.between, 0, 5);
};
// RECIPE EXAMPLE
/**
* Example of creating a store "recipe", which lets us create multiple stores
* that follow similar patterns. In this example we will create a recipe for
* managing modals that contain the UI for editing or creating entities.
*/
type EntityEditModalState =
| {
mode: "closed" | "new";
}
| {
mode: "edit";
entityId: string;
};
const composeEntityEditModalStore = () =>
new Store<EntityEditModalState>({ mode: "closed" })
// Actions
.addAction("openNew", () => ({ mode: "new" }))
.addAction("openEdit", (_, entityId: string) => ({
mode: "edit",
entityId,
}))
.addAction("close", () => ({ mode: "closed" }))
// Selectors
.addSelector("isOpen", (state) => state.mode !== "closed")
.addSelector("entityId", (state): string | undefined => {
if (state.mode !== "edit") return undefined;
return state.entityId;
});
const todoEditModalStore = composeEntityEditModalStore();
const EditTodoModal = () => {
const isOpen = useStoreSelector(todoEditModalStore.selectors.isOpen);
const editTodoId = useStoreSelector(todoEditModalStore.selectors.entityId);
};
type ReducerAction<Type extends string> = {
type: Type;
};
type ReducerPayloadAction<Type extends string, Payload> = ReducerAction<Type> & {
payload: Payload;
};
type Reducer<S, A> = (state: S, action: A) => S;
class ReducerBuilder<State, Action extends ReducerAction<any> | ReducerPayloadAction<any, any> = never> {
private handlers: Record<string, (state: State, payload?: any) => State> = {};
simpleCase = <Type extends string>(
type: Type,
handler: (state: State) => State
): ReducerBuilder<State, Action | ReducerAction<Type>> => {
this.handlers[type] = (state) => handler(state);
return this as any;
};
payloadCase = <Type extends string>(
type: Type
) => <Payload>(handler: (state: State, payload: Payload) => State): ReducerBuilder<State, Action | ReducerPayloadAction<Type, Payload>> => {
this.handlers[type] = handler;
return this as any;
};
reducer = (): Reducer<State, Action> => (state, action) => {
const handler = this.handlers[action.type];
if (handler) {
if ("payload" in action) {
return handler(state, action.payload);
} else {
return handler(state);
}
}
return state;
}
}
// EXAMPLE
type ModalState = {
mode: "closed";
} | {
mode: "open-new";
} |
{
mode: "open-edit";
id: string;
}
const reducer = new ReducerBuilder<ModalState>()
.simpleCase('closeModal', () => ({ mode: 'closed' }))
.simpleCase('openNewModal', () => ({ mode: 'open-new' }))
.payloadCase('openEditModal')((state, payload: { id: string }) => ({
mode: 'open-edit',
id: payload.id,
}))
.reducer();
reducer({ mode: 'closed' }, { type: 'openNewModal' }); // { mode: 'open-new' }
// Custom jotai helper prototype
// Needs adjustment to minimize amount of manual generic passing
const createAtom = <State, Action extends ReducerAction<any> | ReducerPayloadAction<any, any> = never>(
initialState: State,
reducerBuilder: ReducerBuilder<State, Action>
): [State, (action: Action) => void] => {
const reducer = reducerBuilder.reducer();
return [
initialState,
(action: Action) => {
const newState = reducer(initialState, action);
// Here you would typically set the new state in your state management system
// For example, if using Jotai, you would use setAtom(newState);
console.log('New State:', newState);
}
]
}
const initialState: ModalState = { mode: 'closed' };
createAtom(initialState, new ReducerBuilder<ModalState>()
.simpleCase('closeModal', () => ({ mode: 'closed' }))
.simpleCase('openNewModal', () => ({ mode: 'open-new' }))
.payloadCase('openEditModal')((state, payload: { id: string }) => ({
mode: 'open-edit',
id: payload.id,
}))
)
type LogCommandMethod = 'log' | 'info' | 'warn' | 'error' | 'debug';
class Logger {
constructor(private labels: string[]) { }
private send(method: LogCommandMethod, ...args: any[]) {
console[method](`[${this.labels.join('.')}]: `, ...args);
}
log(...args: any[]) {
this.send('log', ...args);
}
info(...args: any[]) {
this.send('info', ...args);
}
warn(...args: any[]) {
this.send('warn', ...args);
}
error(...args: any[]) {
this.send('error', ...args);
}
debug(...args: any[]) {
this.send('debug', ...args);
}
}
type PickByValue<T, V> = {
[K in keyof T]: T[K] extends V ? K : never;
};
type PickMethods<T> = PickByValue<T, (...args: any[]) => any>;
type MethodNames<T> = keyof PickMethods<T>;
const methodLoggers = <T extends { constructor: { name: string } }>(target: T): Record<MethodNames<T>, Logger> =>
new Proxy({} as any, {
get: (obj, prop) => {
if (!(prop in obj)) {
const labels = [target.constructor.name, prop as string];
obj[prop] = new Logger(labels);
}
return obj[prop];
}
})
// EXAMPLE USAGE
class ExampleService {
private loggers = methodLoggers(this);
doSomething() {
const logger = this.loggers.doSomething;
logger.info('Doing something...');
}
switchItUp(someId: number) {
const logger = this.loggers.switchItUp;
logger.warn(`Switching it up for ID: ${someId}`);
}
}
const a = new ExampleService();
a.doSomething();
a.switchItUp(42);
/**
* Fun challenge to see if I can implement the signals pattern
* proposed in https://github.com/tc39/proposal-signals
*/
/**
* Flaws of this implementation:
* - Signal listeners are needlessly executed multiple times in some cases
* - State signals are only tracked as dependencies if they are read
* during the first computation of a computed signal. This will lead to
* issues if a computed signal conditionally reads from a signal and does
* not read it the first time.
*/
type SignalListener<T> = (value: T) => void;
abstract class Signal<T> {
protected static observer: Signal<any> | undefined = undefined;
// tracks the current ComputedSignal that is being evaluated
// so any dependencies can be registered
protected listeners: SignalListener<T>[] = [];
private constructor() {
}
/**
* Add a listener that is called whenever the signal's value changes
*/
add(callback: SignalListener<T>) {
this.listeners.push(callback);
}
/**
* Remove a previously added listener
*/
remove(callback: SignalListener<T>) {
this.listeners = this.listeners.filter(listener => listener !== callback);
}
protected notify(value: T) {
this.listeners.forEach(listener => listener(value));
}
abstract get(): T;
protected registerWithCurrentObserver() {
if (
!Signal.observer ||
!(Signal.observer instanceof ComputedSignal)
) return;
Signal.observer._dependencies.push(this);
}
protected static electObserver(newObserver: Signal<any>) {
// IMPORTANT: This is a naive implementation that could lead to deadlocks,
// as it holds the main thread waiting for the observer to be released, if
// the observer release takes time, it will block the whole application.
while (!!Signal.observer) {
// wait for the observer to become available
}
this.observer = newObserver;
return;
}
/**
* As per the proposal, signals are created with `new Signal.State()` and `Signal.Computed()`,
* so we just return the corresponding class prototypes via static getters.
*/
static get State() {
return StateSignal;
}
static get Computed() {
return ComputedSignal;
}
}
class StateSignal<T> extends Signal<T> implements Signal<T> {
constructor(
private value: T
) {
super();
}
get() {
this.registerWithCurrentObserver();
// if this signal is read as part of a computed signal's computation,
// we register it as a dependency of that computed signal
return this.value;
}
set(value: T) {
// NOTE: Relies on reference equality for objects, so if you set
// a signal to a new object, mutate that object, then set it again,
// no change will be detected. I personally think this is fine, and
// that having a rule of no object mutation is a good and reasonable
// compromise to make.
// If we support that kind of mutation detection, then users may also
// expect signals to automatically detect when it's object is mutated,
// rather than only when set() is called, and that would completely
// blow up the complexity of this implementation.
if (value === this.value) return;
this.value = value;
this.notify(value);
}
}
class ComputedSignal<T> extends Signal<T> implements Signal<T> {
_dependencies: Signal<any>[] = []; // would ideally be private but can't be with this implementation
// could potentially store dependencies on a protected static Map on parent Signal class,
// keyed by the ComputedSignal instance
// We store the callbacks so we can later remove them
private dependencyCallbacks: WeakMap<Signal<any>, SignalListener<T>> = new WeakMap();
private mustRecompute = true;
private cachedValue: T;
constructor(
private compute: () => T,
) {
super();
this.cachedValue = this.get();
}
get() {
try {
this.registerWithCurrentObserver();
// if being read as part of another computed signal's computation,
// register as a dependency
if (!this.mustRecompute) return this.cachedValue;
Signal.electObserver(this); // set self as current observer to track dependencies
// untrack dependencies from previous evaluation
this._dependencies.forEach(dependency => {
const callback = this.dependencyCallbacks.get(dependency);
if (!callback) return;
dependency.remove(callback);
this.dependencyCallbacks.delete(dependency);
});
this._dependencies = [];
this.cachedValue = this.compute();
Signal.observer = undefined; // release self as observer
// Notify on dependency change
this._dependencies.forEach(dependency => {
const callback = () => {
// If a dependency changes, then we next read of this computed signal
// must recompute its value
this.mustRecompute = true;
this.notify(this.get());
// ^this line might be the cause of the duplicate evaluations, shouldn't we be
// notifying once the computation is done? Not at the same time we mark for recompute?
}
this.dependencyCallbacks.set(dependency, callback);
dependency.add(callback);
});
this.mustRecompute = false;
return this.cachedValue;
} finally {
// If there is an error between electing self as observer and
// releasing, we ensure we still release the observer
if (Signal.observer === this) {
Signal.observer = undefined;
}
}
}
}
// --- TEST CODE ---
// EXTRA CHALLENGE: Account for some dependencies not being read
// in initial computation of a computed signal
// TESTS:
// - Basic state signal reaction
// - Computed signal with state signal dependency
// - Computed signal with computed signal dependency
// - Computed signal with multiple dependencies
const toggle = new Signal.State(false);
const counter = new Signal.State(1);
const doubled = new Signal.Computed(() => counter.get() * 2);
const labelled = new Signal.Computed(() =>
`Counter: ${counter.get()}`);
const isEvenMessage = new Signal.State("Counter is even");
const conditionalDependency = new Signal.Computed(() => {
const counterIsOdd = counter.get() % 2 === 1;
if (counterIsOdd) return `Odd Counter ${counter.get()}`;
// `isEvenMessage` should not become a dependency after the first read
// because it is not read in the first evaluation when counter is 1 (odd)
return isEvenMessage.get();
});
conditionalDependency.add((value) => {
console.log('[conditionalDependency] Conditional dependency changed:', value);
});
toggle.add((value) => {
console.log('[toggle] Toggle changed:', value);
});
counter.add((value) => {
console.log('[counter] Counter changed:', value);
})
labelled.add((value) => {
console.log('[labelled] Labelled changed:', value);
});
console.log('🍎 Initial values: ', {
counter: counter.get(),
toggle: toggle.get(),
conditionalDependency: conditionalDependency.get(),
computed: {
doubled: doubled.get(),
labelled: labelled.get(),
}
})
console.log('🍎 Updating `isEvenMessage`, `conditionalDependency` updates should not trigger since counter is odd')
isEvenMessage.set("Counter is super even!!");
// isEvenMessage.set("Counter is SUPER SUPER even!!");
console.log('🍎 Changing counter to even and updating `isEventCounterMessage`, this should trigger `conditionalDependency` listeners');
counter.set(2);
isEvenMessage.set("Even.");
// toggle.set(false);

Custom Express Schema Validation

We want to apply validation to a route and then get that type-inferrence/safety in the route handler, without having to do any complex system of inferrence/passing data around, and we don't want the overhead of running data parsing again in the request handler just to get typing.

Example Problem

//routes.ts
app.post('/user', ourValidationMiddleware, theHandler);

//handlers.ts
function theHandler(req: Request, res: Response) {
  // Within this function, we don't have the type information from the validation middleware
}

If you just want to achieve the IDE type safety, you can re-run the validation in the handler:

function theHandler(req: Request, res: Response) {
  const validatedData = ourValidationSchema.parse(req.body);
  // Now we have type safety
}

However, the problem here is that we are adding overhead by performing validation twice.

Solution

TL;DR: We use a system of validation that caches the validation performed in the middleware, and then in the handler we just retrieve the cached result, throwing if the middleware has not been run.

We have a class, RouteValidator that contains a schema. There are 2 important methods:

  • middleware: Standard express middleware function that validates requests on a route
  • infer: Used in the route handler, takes the request and returns the validation result from the middleware, throwing if the middleware has not been run.

It works like this:

  • The middleware does the actual data parsing/validation and caches the result, using the request object as the key
  • When infer is called, it just tries to retrieve the cached data created by middleware, if the cache entry does not exist it throws.

The result is:

  • Validation middleware is 100% regular express middleware
  • We can safely use the validation type information in the route handler with basically 0 performance or DX overhead
import type { UseTRPCMutationOptions } from '@trpc/react-query/shared';
type InferUseTRPCMutationOptionsInput<T extends UseTRPCMutationOptions<any, any, any>> =
T extends UseTRPCMutationOptions<infer Input, any, any> ? Input : never;
type InferUseTRPCMutationOptionsError<T extends UseTRPCMutationOptions<any, any, any>> =
T extends UseTRPCMutationOptions<any, infer Error, any> ? Error : never;
type InferUseTRPCMutationOptionsOutput<T extends UseTRPCMutationOptions<any, any, any>> =
T extends UseTRPCMutationOptions<any, any, infer Output> ? Output : never;
type TrpcOptimisticUpdateCallbacks<T extends UseTRPCMutationOptions<any, any, any>, OptimisticResult> = {
/**
* As soon as the mutation is called, perform an optimistic update.
* If any extra data or context is generated during the optimistic update,
* it should be returned by the callback so it can be be used later in
* `reconcile` and `rollback` callbacks.
*/
optimisticUpdate: (input: InferUseTRPCMutationOptionsInput<T>) => OptimisticResult;
/**
* Reconcile the optimistic update with the data returned from the server.
*/
reconcile: (
responseData: InferUseTRPCMutationOptionsOutput<T>,
optimisticResult: OptimisticResult,
input: InferUseTRPCMutationOptionsInput<T>
) => void;
/**
* Rollback the optimistic update in case of an error.
*/
rollback: (
error: InferUseTRPCMutationOptionsError<T>,
optimisticResult: OptimisticResult,
input: InferUseTRPCMutationOptionsInput<T>
) => void;
};
const NOT_SET = Symbol('NOT_SET');
/**
* Wrap trpc mutation options object, providing logic for optimistic updates.
*/
const trpcOptimisticUpdateOptions = <T extends UseTRPCMutationOptions<any, any, any>, OptimisticResult>(
options: T,
callbacks: TrpcOptimisticUpdateCallbacks<T, OptimisticResult>
): T => {
let optimisticResult: OptimisticResult | typeof NOT_SET = NOT_SET;
return {
...options,
onMutate: (input, ...remainingParams) => {
try {
optimisticResult = callbacks.optimisticUpdate(input);
} finally {
return options.onMutate?.(input, ...remainingParams);
}
},
onSuccess: (result, input, context) => {
try {
if (optimisticResult === NOT_SET) {
throw new Error('Optimistic result was not set, this should be unreachable');
}
callbacks.reconcile(result, optimisticResult, input);
} finally {
return options.onSuccess?.(result, input, context);
}
},
onError: (error, input, context) => {
try {
if (optimisticResult === NOT_SET) {
throw new Error('Optimistic result was not set, this should be unreachable');
}
callbacks.rollback(error, optimisticResult, input);
} finally {
return options.onError?.(error, input, context);
}
}
};
};
/**
* TODO:
* - Fix static Result async methods handling multiple possible errors
* - Serialization and deserialization of Result instances
*/
// GENERIC UTILS
type AnyClassPrototype = new (...args: any[]) => any;
type ValueOf<T> = T extends (infer U)[] ? U : T[keyof T];
const instanceOfAny = <const ClassArr extends AnyClassPrototype[]>(
value: unknown,
classes: ClassArr
): value is InstanceType<ValueOf<ClassArr>> => {
return classes.some(cls => value instanceof cls);
}
// BRANCH TYPES
class Ok<T> {
constructor(
public value: T
) {
}
}
class Err<E> {
constructor(
public error: E
) {
}
/**
* Create a parser function that takes a value, if that value is
* an instance of any of the provided classes, then it is returned,
* otherwise an error is thrown.
*
* This can be used with `Result.fromThrowable`, you can define your
* own error classes to represent the different possible errors a function
* can throw, and then use this function to create a parser and pass
* it into `fromThrowable`
*/
static instanceParser<const ClassArr extends AnyClassPrototype[]>(
classes: ClassArr
): (err: unknown) => InstanceType<ValueOf<ClassArr>> {
return (err) => {
if (!instanceOfAny(err, classes)) {
throw new TypeError(`Expected error to be an instance of one of ${classes.map(c => c.name).join(', ')}`);
}
return err;
}
}
}
// RESULT
// Represents the types created by `Result.ok` and `Result.err`
type MinResult<T, E> = Result<T, never> | Result<never, E>;
type InferResultOk<R extends Result<any,any>> = R extends Result<infer T, any> ? T : never;
type InferResultErr<R extends Result<any,any>> = R extends Result<any, infer E> ? E : never;
type FlattenMinResult<R extends MinResult<any, any>> =
Result<InferResultOk<R>, InferResultErr<R>>;
export class Result<T, E> {
private constructor(
private content: Ok<T> | Err<E>,
) {
}
// Map
map<const Ta>(fn: (value: T) => Ta): Result<Ta, E> {
if (this.content instanceof Err) {
return new Result<Ta, E>(this.content)
}
const newValue = fn(this.content.value)
return new Result<Ta, E>(new Ok(newValue))
}
async mapAsync<const Ta>(fn: (value: T) => Promise<Ta>): Promise<Result<Ta,E>> {
if (this.content instanceof Err) {
return new Result(this.content)
}
const nextValue = await fn(this.content.value);
return Result.ok(nextValue);
}
// FlatMap
flatMap<const Ta, const Ea>(fn: (value: T) => Result<Ta, Ea>): Result<Ta, E | Ea> {
if (this.content instanceof Err) {
return new Result<Ta, E | Ea>(this.content)
}
const newResult = fn(this.content.value);
return newResult;
}
async flatMapAsync<const Ta, const Ea>(
fn: (value: T) => Promise<Result<Ta, Ea>>
): Promise<Result<Ta, E | Ea>> {
if (this.content instanceof Err) {
return new Result(this.content)
}
const newResult = await fn(this.content.value);
return newResult;
}
// mapErr
mapErr<const Ea>(fn: (error: E) => Ea): Result<T, Ea> {
if (this.content instanceof Ok) {
return new Result<T, Ea>(this.content);
}
const newError = fn(this.content.error);
return Result.err(newError);
}
async mapErrAsync<const Ea>(fn: (error: E) => Promise<Ea>): Promise<Result<T, Ea>> {
if (this.content instanceof Ok) {
return new Result<T, Ea>(this.content);
}
const newError = await fn(this.content.error);
return Result.err(newError);
}
// flatMapErr
flatMapErr<const Ea>(fn: (error: E) => Result<T, Ea>): Result<T, Ea> {
if (this.content instanceof Ok) {
return new Result<T, Ea>(this.content);
}
const newResult = fn(this.content.error);
return newResult;
}
async flatMapErrAsync<const Ea>(
fn: (error: E) => Promise<Result<T, Ea>>
): Promise<Result<T, Ea>> {
if (this.content instanceof Ok) {
return new Result<T, Ea>(this.content);
}
const newResult = await fn(this.content.error);
return newResult;
}
// Misc methods
match<Tr, Er>(
okHandler: (value: T) => Tr,
errHandler: (value: E) => Er,
): Tr | Er {
if (this.content instanceof Err) {
return errHandler(this.content.error)
}
return okHandler(this.content.value)
}
// Utility create methods
static ok<const T>(value: T): Result<T, never> {
return new Result(new Ok(value));
}
static err<const E>(error: E): Result<never, E> {
return new Result(new Err(error));
}
// Run a callback that returns a Result created by either
// `Result.ok` or `Result.err` and combine the outputs into
// a single Result.
static attempt<Res extends MinResult<any,any>>(fn: () => Res): Result<InferResultOk<Res>, InferResultErr<Res>> {
// Weird typing is to allow it to handle multiple types of results
return fn();
}
static attemptAsync<Res extends MinResult<any,any>>(fn: () => Promise<Res>): Promise<Result<InferResultOk<Res>, InferResultErr<Res>>> {
return fn()
}
// Create a function that returns a result.
static fn<Params extends any[], T, E>(theFn: (...args: Params) => MinResult<T, E>): (...args: Params) => Result<T, E> {
return (...args: Params) =>
theFn(...args);
}
static asyncFn<Params extends any[], T, E>(
theFn: (...args: Params) => Promise<MinResult<T, E>>
): (...args: Params) => Promise<Result<T, E>> {
return async (...args: Params) => {
const result = await theFn(...args);
return result;
}
}
static fromThrowable<const T, const E>(
fn: () => T,
getErr: (err: unknown) => E
): Result<T, E> {
try {
const value = fn();
return Result.ok(value);
} catch (err) {
const errValue = getErr(err);
return Result.err(errValue);
}
}
/**
* If you have an object that could represent either a success or an error,
* you can use this method to parse it into a Result.
* Provide a function that parses a success value and another that parses an error value.
* This is useful when dealing with APIs or other data sources where the response
* can be ambiguous or when you want to handle both success and error cases uniformly.
*/
static parse<const T, const E>(
value: unknown,
okParser: (value: unknown) => T,
errParser: (value: unknown) => E
): Result<T, E> {
try {
const okValue = okParser(value);
return Result.ok(okValue);
} catch (err) {
const errValue = errParser(err);
return Result.err(errValue);
}
}
}
// EXAMPLE USAGE
const fiftyFifty = Result.attempt(() => {
const isGood = Math.random() > 0.5;
return isGood
? Result.ok("good")
: Result.err("bad")
})
const divideBy = Result.fn((a: number, b: number) => {
if (b === 0) {
return Result.err("Cannot divide by zero");
}
return Result.ok(a / b);
})
const wa = Result.fromThrowable(() => {
if (Math.random() > 0.5) {
throw new Error("Random error occurred");
}
return "Success!" as const;
}, () => "Failure!" as const)
class FailedChanceError extends Error {
a = "asfsaf"
constructor() {
super("You failed")
}
}
class FailedChanceBadlyError extends Error {
b = "askjfasf"
constructor() {
super("You failed really badly")
}
}
const otherFiftyFifty = Result.fromThrowable(() => {
const roll = Math.random()
if (roll > 0.5) {
return "good"
}
if (roll < 0.5 && roll > 0.25) {
throw new FailedChanceError()
}
throw new FailedChanceBadlyError();
}, Err.instanceParser([FailedChanceError, FailedChanceBadlyError]));
const betterOtherFiftyFifthy = Result.attempt(() => {
const roll = Math.random()
if (roll < 0.25) return Result.err(new FailedChanceBadlyError())
if (roll < 0.5) return Result.err(new FailedChanceError())
return Result.ok("good")
})
type ServerFnSuccess = {
status: "success";
data: number;
}
// Example of how you might use this with server actions or API responses
const parseServerFnSuccess = (response: unknown): ServerFnSuccess => {
return {
status: "success",
data: 42 // Example data, replace with actual parsing logic
}
}
type ServerFnError = {
status: "error";
message: string;
}
const parseServerFnError = (error: unknown): ServerFnError => {
return {
status: "error",
message: "An error occurred" // Example error message, replace with actual parsing logic
}
}
const serverFnResponse = {} as ServerFnSuccess | ServerFnError;
const serverFnResult = Result.parse(
serverFnResponse,
parseServerFnSuccess,
parseServerFnError
)
/**
* Implementation of all basic math operations in typescript
* types.
*
* Numbers are represented as tuples of digits
*/
type Zero = 0;
type One = 1;
type Two = 2;
type Three = 3;
type Four = 4;
type Five = 5;
type Six = 6;
type Seven = 7;
type Eight = 8;
type Nine = 9;
type Digit =
| Zero
| One
| Two
| Three
| Four
| Five
| Six
| Seven
| Eight
| Nine;
type IncrementDigit<T extends Digit> = T extends Zero
? One
: T extends One
? Two
: T extends Two
? Three
: T extends Three
? Four
: T extends Four
? Five
: T extends Five
? Six
: T extends Six
? Seven
: T extends Seven
? Eight
: T extends Eight
? Nine
: never;
type DecrementDigit<T extends Digit> = T extends Nine
? Eight
: T extends Eight
? Seven
: T extends Seven
? Six
: T extends Six
? Five
: T extends Five
? Four
: T extends Four
? Three
: T extends Three
? Two
: T extends Two
? One
: T extends One
? Zero
: never;
type TrimLeadingZeros<T extends Digit[]> = T extends [Zero]
? [Zero]
: T extends [Zero, ...infer Rest extends Digit[]]
? TrimLeadingZeros<Rest>
: T;
type Increment<T extends Digit[]> = T extends [
...infer Rest extends Digit[],
infer Last extends Digit,
]
? Last extends Nine
? [...(Rest extends [] ? [One] : Increment<Rest>), Zero]
: [...Rest, IncrementDigit<Last>]
: never;
type Decrement<T extends Digit[]> = T extends [
...infer Rest extends Digit[],
infer Last extends Digit,
]
? TrimLeadingZeros<
Last extends Zero
? Rest extends []
? []
: [...Decrement<Rest>, Nine]
: [...Rest, DecrementDigit<Last>]
>
: never;
type Add<L extends Digit[], R extends Digit[]> = R extends [Zero]
? L
: Add<Increment<L>, Decrement<R>>;
type Sub<L extends Digit[], R extends Digit[]> = R extends [Zero]
? L
: Sub<Decrement<L>, Decrement<R>>;
type Mult<
L extends Digit[],
R extends Digit[],
Result extends Digit[] = [Zero],
> = R extends [Zero] ? Result : Mult<L, Decrement<R>, Add<L, Result>>;
type Div<
L extends Digit[],
R extends Digit[],
Result extends Digit[] = [Zero],
> = L extends [Zero] ? Result : Div<Sub<L, R>, R, Increment<Result>>;
type Pow<
L extends Digit[],
R extends Digit[],
Result extends Digit[] = L,
> = R extends [Zero]
? [One]
: R extends [One]
? Result
: Pow<L, Decrement<R>, Mult<Result, L>>;
// TESTING
type ASeven = Add<[3], [4]>;
type AHundred = Add<[7, 3], [2, 7]>;
type SThree = Sub<[5], [2]>;
type SNineSeven = Sub<[1, 0, 0], [3]>;
type MEight = Mult<[4], [2]>;
type MFiveHundred = Mult<[5], [1, 0, 0]>;
type DFive = Div<[1, 0], [2]>;
type MZero = Mult<[0], [2, 5, 6]>;
type PFour = Pow<[2], [2]>;
type PEight = Pow<[2], [3]>;
type PSixteen = Pow<[2], [4]>;
type PBig = Pow<[4], [5]>;
/**
* This is a prototype that combines `zod` with `decode-formdata` to create
* a type-safe way to decode `FormData` into regular objects with proper types.
* Inferrence will make sure that you provide the exact right configuration for
* the decoder based on the schema you provide.
*/
import { decode } from "decode-formdata";
import { type Simplify } from "type-fest";
import z from "zod";
type ValueOf<T> = T[keyof T];
type JoinPath<A extends string, B extends string> = A extends ""
? B
: B extends ""
? A
: `${A}.${B}`;
type InternalObjectPath<
T extends Record<string, any> | readonly any[],
Current extends string = "",
> = T extends readonly (infer Elem)[]
? Elem extends Date
? JoinPath<Current, "$">
: Elem extends Blob
? JoinPath<Current, "$">
: Elem extends Record<string, any> | readonly any[]
? InternalObjectPath<Elem, JoinPath<Current, "$">> | JoinPath<Current, "$">
: JoinPath<Current, "$">
: ValueOf<{
[K in keyof T]: T[K] extends readonly (infer ArrayItem)[]
? ArrayItem extends Date
?
| JoinPath<Current, JoinPath<K & string, "$">>
| JoinPath<Current, K & string>
: ArrayItem extends Blob
?
| JoinPath<Current, JoinPath<K & string, "$">>
| JoinPath<Current, K & string>
: ArrayItem extends Record<string, any> | readonly any[]
?
| InternalObjectPath<
ArrayItem,
JoinPath<Current, JoinPath<K & string, "$">>
>
| JoinPath<Current, JoinPath<K & string, "$">>
| JoinPath<Current, K & string>
:
| JoinPath<Current, JoinPath<K & string, "$">>
| JoinPath<Current, K & string>
: T[K] extends Date
? JoinPath<Current, K & string>
: T[K] extends Blob
? JoinPath<Current, K & string>
: T[K] extends Record<string, any> | readonly any[]
?
| InternalObjectPath<T[K], JoinPath<Current, K & string>>
| JoinPath<Current, K & string>
: JoinPath<Current, K & string>;
}>;
export type ObjectPath<T extends Record<string, any>> = InternalObjectPath<T>;
type User = {
id: number;
username: string;
};
type TestObj = {
hello: string;
nested: {
value: number;
moreNested: {
flag: boolean;
anotherString: string;
};
someDate: Date;
};
numArray: number[];
userArray: User[];
arrayOfNumberArrays: number[][];
aFileBlob: Blob;
};
const testObjSchema = z.object({
hello: z.string(),
nested: z.object({
value: z.number(),
moreNested: z.object({
flag: z.boolean(),
anotherString: z.string(),
}),
someDate: z.date(),
}),
numArray: z.array(z.number()),
userArray: z.array(
z.object({
id: z.number(),
username: z.string(),
}),
),
arrayOfNumberArrays: z.array(z.array(z.number())),
aFileBlob: z.instanceof(Blob),
});
type TestObjPath = Simplify<ObjectPath<TestObj>>;
export type GetPath<
Obj extends Record<string, any> | readonly any[],
P extends ObjectPath<Obj>,
> = P extends `${infer Key}.${infer Rest}`
? Key extends "$"
? Obj extends readonly (infer Elem)[]
? GetPath<Elem, Rest>
: never
: Key extends keyof Obj
? GetPath<Obj[Key], Rest>
: never
: P extends "$"
? Obj extends readonly (infer Elem)[]
? Elem
: never
: P extends keyof Obj
? Obj[P]
: never;
type PathWithValue<Obj extends Record<string, any>, Value> = ValueOf<{
[P in ObjectPath<Obj>]: GetPath<Obj, P> extends Value ? P : never;
}>;
type OmitNever<T extends Record<string, any>> = {
[K in keyof T as T[K] extends never ? never : K]: T[K];
};
type RemoveNeverArrays<T extends Record<string, any>> = OmitNever<{
[K in keyof T]: T[K] extends readonly any[]
? T[K] extends never[]
? never
: T[K]
: T[K];
}>;
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
type LastInUnion<U> = UnionToIntersection<
U extends any ? () => U : never
> extends () => infer L
? L
: never;
type PushOrderless<Arr extends any[], V> = [...Arr, V] | [V, ...Arr];
type TuplifyUnionRecursively<U, L = LastInUnion<U>, T extends any[] = []> = [
U,
] extends [never]
? T
: TuplifyUnionRecursively<
Exclude<U, L>,
LastInUnion<Exclude<U, L>>,
PushOrderless<T, L>
>;
type ExhaustiveArray<U> = TuplifyUnionRecursively<U>;
type SmartDecodeOptions<Obj extends Record<string, any>> = RemoveNeverArrays<{
arrays: ExhaustiveArray<PathWithValue<Obj, readonly any[]>>;
dates: ExhaustiveArray<PathWithValue<Obj, Date>>;
booleans: ExhaustiveArray<PathWithValue<Obj, boolean>>;
numbers: ExhaustiveArray<PathWithValue<Obj, number>>;
files: ExhaustiveArray<PathWithValue<Obj, Blob>>;
}>;
const smartDecode = <Schema extends z.ZodType<any, any>>(
schema: Schema,
formData: FormData,
options: SmartDecodeOptions<z.input<Schema>>,
): z.infer<Schema> => {
const decoded = decode(formData, options);
const parsed = schema.parse(decoded);
return parsed;
};
// TESTING
export const testResult = smartDecode(testObjSchema, new FormData(), {
dates: ["nested.someDate"],
numbers: [
"nested.value",
"numArray.$",
"userArray.$.id",
"arrayOfNumberArrays.$.$",
],
arrays: [
"numArray",
"userArray",
"arrayOfNumberArrays",
"arrayOfNumberArrays.$",
],
booleans: ["nested.moreNested.flag"],
files: ["aFileBlob"],
});
import { Request, RequestHandler, Router } from "express";
import { JsonObject } from "type-fest";
import * as z from "zod/v4";
// Used as the base for what kind of schemas are allowed
export type AnyValidatorSchema = z.ZodType<JsonObject, JsonObject>
export type RequestValidator<Schema extends AnyValidatorSchema> = {
middleware: RequestHandler;
retrieve: (req: Request) => z.infer<Schema>;
}
/**
* Internal helper for constructing the functions that build validators
*/
const validatorFactoryFactory = (getData: (req: Request) => unknown) => <Schema extends AnyValidatorSchema>(
schema: Schema
): RequestValidator<Schema> => {
const cache = new WeakMap<Request, z.infer<Schema>>();
return {
middleware: (req, res, next) => {
const result = schema.safeParse(getData(req));
if (!result.success) {
res.status(400).json({
error: "Invalid request data",
details: result.error.issues
});
return;
}
cache.set(req, result.data);
next();
},
retrieve: (req) => {
const cached = cache.get(req);
if (!cached) {
throw new Error("Attempted to retrieve validation result from an unvalidated request");
}
return cached;
}
};
};
// Validator builders
export const bodyValidator = validatorFactoryFactory(req => req.body);
export const queryValidator = validatorFactoryFactory(req => req.query);
export const paramsValidator = validatorFactoryFactory(req => req.params);
// Example Usage
const previewEmailParamsValidator = paramsValidator(z.object({
emailId: z.string()
}));
const previewEmailQueryValidator = queryValidator(z.record(z.string(), z.string()))
const router = Router();
router.get(
"/emails/preview/:templateId",
previewEmailParamsValidator.middleware,
previewEmailQueryValidator.middleware,
(req, res) => {
const { emailId } = previewEmailParamsValidator.retrieve(req);
const variables = previewEmailQueryValidator.retrieve(req);
}
)

Easy Fallback Images

<picture>
	<source srcset="the-primary-image-url" />
	<img src="fallback-image-url" />
</picture>

Intelligent Word Wrapping

If you are in a situation where you are displaying text in a tight space, you may need to break a word into a new line. This can be problematic and look really bad, so I'm going to go over the smart way to do this.

1. Annotate With Line Break Opportunity Tags

In HTML, the <wbr> tag denotes a possible spot in some text where it could be wrapped to a new line if needed. If you have the ability to manually annotate your text with this, it is the best way to control where the text is broken.

For example:

<div>Operation<wbr />-Elephant</div>

Here, the browser will attempt to display the text "Operation-Elephant" on one line, but if it can't fit it will break it at the <wbr> tag.

Dynamic Text

If you have no manual control over the content of the text being displayed, you can use some JavaScript code to dynamically insert <wbr> tags at appropriate places.

const isLowerCase = (char: string) => char === char.toLowerCase();

/**
 * Mark line break opportunities in a string. Line
 * break opportunities include characters that often
 * substitute spaces, such as hyphens and slashes, or
 * a change from lowercase to uppercase.
 *
 * A line break opportunity is marked by a `<wbr>` tag
 * being inserted after the occurrence. For a case change,
 * the tag is inserted between the two characters.
 */
export const markLineBreakOpportunities = (text: string): string => {
	// TODO: Include brackets as possible break point.
	// `wbr` can be inserted before an opening bracket
	// and after a closing bracket.

	const words = text.split(" ");
	const markedWords = words.map((word) =>
		word
			.split("")
			.map((char, index) => {
				const nextChar = word[index + 1];
				if (index === 0 || !nextChar) {
					// ignore first and last character
					return char;
				}

				const caseChanges = isLowerCase(char) && !isLowerCase(nextChar);
				const isPunctuation = ["-", "/", "_", ".", ","].includes(char);
				if (caseChanges || isPunctuation) {
					return `${char}<wbr>`;
				}

				return char;
			})
			.join(""),
	);
	return markedWords.join(" ");
};

2. Worst Case Fallback

If you are still having issues, apply the CSS style overflow-wrap: anywhere to the element containing the text. The end result will be that the browser will first try to resolve the text wrapping using the <wbr> tags, and if it still can't fit, it will break the word at any character.

Special Mobile Keyboards

The inputmode (inputMode in react) prop on input elements allows you to specify what kind of keyboard the user will see on mobile devices.

A good default for numeric inputs is "decimal" which shows the numpad with a decimal point.

Advanced Type Tips

Some advanced stuff you can do with typescript types.

Removing Object Fields Based on Value Type

If you set an object key to type never, it will be removed from the object type. If you combine this with mapped types, you can remove keys based on the type of the corresponding value.

type OmitNumberFields<T> = {
  [K in keyof T as T[K] extends number ? never : K]: T[K];
};

// Example usage
type Test = OmitNumberFields<{
  num1: number;
  num2: number;
  str: string;
}>; // { str: string;}

Using the infer Keyword

Descriptions and example of the infer keyword often over-complicate it a bit, I'll try to give it a more simple explanation.

The infer keyword can be used when writing a generic type and allows you to take a type (we will refer to an example type T) that was defined via another generic type and extract the type parameters that were passed to the generic to define type T.

type OrNumber<T> = T | number;

type GetOriginalType<T> = T extends OrNumber<infer U> ? U : never;

type StringOrNumber = OrNumber<string>; // string | number
type Test = GetOriginalType<StringOrNumber>; // string

The above is a hyper-simplified example and doesn't actually make much sense logically (if you just wanted to remove the | number type there are much easier ways of doing it), but it shows the most basic use of the infer keyword. We define a new type as OrNumber<string>, and then we can use infer to extract the string parameter that was passed to the OrNumber generic type.

The actual use cases of infers is when you are dealing with much more complex types, perhaps ones that you don't have full visibility over (eg; types imported from a library).

Auto Versioning + Release Setup

You can look at the simple-new-tab repo for a really good working example of this.

We use semantic-release to automatically update the package.json version based on the commits that have happened since last release. It will also automatically generate a changelog and create new git releases, with the built files attached.

It requires you to generate a GitHub token with write access to the repo, this can be a pain since GitHub strongly recommends against creating tokens that last a long time, so you will need to recreate this occasionally. In README steps, I just wrote generating a new token as part of the pushing process.

CLI Caching

To setup simple caching in a CLI app, use the packages flat-cache and find-cache-directory.

import findCacheDirectory from 'find-cache-directory';
import { createFromFile } from 'flat-cache';

const APP_NAME = 'your-app-name-here';
const cacheDirectory = findCacheDirectory({ name: APP_NAME });
if (!cacheDirectory) throw new Error('Cache directory not found');

const cache = createFromFile(`${cacheDirectory}/cache`);

cache.get('your-key-here');
cache.set('your-key-here', 'your-value-here');

cache.save();

Controlling Workspace Setups

When using workspaces in JS package manager, its quite likely that you want a workspace to export the contents of its src folder from its base path.

You can do this with the exports property in the package.json

{
	"exports": {
		".": {
			"import": "./src/index.ts"
		},
		"./schema": {
			"import": "./src/schema.ts"
		}
	}
}

The above allows src/index.ts to be imported via package-name and src/schema.ts to be imported via package-name/schema.

It may also be possible to enable a total import-aliasing into src using wildcards, but I haven't tested that yet.

Fallback

If that doesn't work, you can use "main": "src" in the package.json of the workspace, however this only ensures that src/index.ts will be exported from the base path, it will not allow for importing deeper file paths.

DIY Exhaustive Switch Enforcing

If setting up proper exhaustive switch checking with eslint is not an option for you, you can do this hack to enforce it.

Source: https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking

function doSomething(status: 'on' | 'off' | 'pending') {
  switch (status) {
    case 'on':
      // do something
      break;
    case 'off':
      // do something
      break;
    case 'pending':
      // do something
      break;
    default:
      const _exhaustiveCheck: never = status;
      return _exhaustiveCheck;
    // BELOW IS MY OLD WAY OF DOING IT, IN CASE THE OFFICIAL
    // TS WAY DOESN'T WORK
    // @ts-expect-error this is a workaround to show an error in the editor if switch is not exhaustive
    // status.toString();
  }
}

If the switch is exhaustive, then the type of status will be never in the default case, so when we assign it to a variable of type never it won't give an error unless the switch isn't exhaustive.

Explanation of My Old Way

It is essentially guaranteed that all pieces of data in JS will have a .toString() method, pretty much the only way they wouldn't is if they have either the never or unknown types. The status.toString() call above will give a type error if it's type is never which will be the case if the switch is exhaustive. By using @ts-expect-error, it makes it so our IDE will actually warn us if there is NO type error (meaning the switch is not exhaustive) and will leave us alone if there IS a type error (meaning the switch is exhaustive).

Durations

A utility for defining a duration of time in a human-readable format.

/**
 * Returns a duration in milliseconds from an object that defines
 * a duration in a readable way.
 */
export function duration(duration: Partial<Record<DurationLength, number>>): number {
  let total = 0;

  for (const [key, value] of Object.entries(duration)) {
    if (!isDurationLength(key)) throw new TypeError(`Unexpected key in duration object: ${key}`);
    const extraMs = DURATION_IN_MS[key] * value;
    total += extraMs;
  }

  return total;
}

type DurationLength = 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds' | 'weeks';

const SECONDS_MS = 1000;
const MINUTES_MS = 60 * SECONDS_MS;
const HOURS_MS = 60 * MINUTES_MS;
const DAYS_MS = 24 * HOURS_MS;
const WEEKS_MS = 7 * DAYS_MS;

const DURATION_IN_MS: Record<DurationLength, number> = {
  milliseconds: 1,
  seconds: SECONDS_MS,
  minutes: MINUTES_MS,
  hours: HOURS_MS,
  days: DAYS_MS,
  weeks: WEEKS_MS
};

const isDurationLength = (value: unknown): value is DurationLength => Object.keys(DURATION_IN_MS).includes(value as string);

Example Usage

const thirtyDays = duration({ days: 30 }); // 2592000000
const oneHourThirtyMinutes = duration({ hours: 1, minutes: 30 }); // 5400000

Env Setup

Loading Env Files

If you are using a framework that handles the loading of .env files into the app automatically, you can skip this step.

Use the dotenv-cli CLI tool, which allows you to explicitly specify which env variables to load in, and allows multiple which will apply cascades.

Since this gives you exact control over which .env files are loaded, you can keep all your env files in a dedicated env folder.

Validating Envs

The most straight forward approach to validating envs is to use a a runtime validation library like zod, which will be used in the example code. Create an env.ts file, and use something like the following:

import { z } from "zod";

const envSchema = z.object({
	API_ROOT_URL: z.string(),
});

type Env = z.infer<typeof envSchema>;

const envInput: Record<keyof Env, string | undefined> = {
	API_ROOT_URL: process.env.EXPO_PUBLIC_API_ROOT_URL,
};

export const env = envSchema.parse(envInput);

Running Validation at Build Time

Depending on how your build process works, it's very possible that this env code will only get executed at runtime, and as such you won't get an error during the build if the env variables are incorrect.

To fix this, you need to make sure your env.ts file is imported somewhere in your codebase that will be executed during the build. Eg; a config file for your framework.

If your config files are all in js format, they will not be able to import your env.ts file, so you will have to write the file in js, using the js-doc syntax to define the types. You can look at the t3-env package for an example of this.

Exhaustive Tuple Array

Here is how to create a type that represents an array that must contain all possible values of a union type.

Its actually very simple to create a type that represents a tuple that contains every value of a set of values, eg;

type AllValuesTuple<A, B, C> =
	| [A, B, C]
	| [A, C, B]
	| [B, A, C]
	| [B, C, A]
	| [C, A, B]
	| [C, B, A];

As you can see that's a bit tedious, but extremely simple.

Where this union-tuple stuff gets tricky, is when you need to extract the individual values from a union type. TypeScript doesn't really have any intended way of achieving this, but there are some some workarounds.

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
	k: infer I,
) => void
	? I
	: never;

type LastInUnion<U> = UnionToIntersection<
	U extends any ? () => U : never
> extends () => infer L
	? L
	: never;

type PushOrderless<Arr extends any[], V> = [...Arr, V] | [V, ...Arr];

type TuplifyUnionRecursively<U, L = LastInUnion<U>, T extends any[] = []> = [
	U,
] extends [never]
	? T
	: TuplifyUnionRecursively<
			Exclude<U, L>,
			LastInUnion<Exclude<U, L>>,
			PushOrderless<T, L>
	  >;

type ExhaustiveArray<U> = TuplifyUnionRecursively<U>;

NOTE: I am pretty sure this approach will break if you try to use it with a union type of functions, or any other type that JS considers to be callable, eg; classes. I believe there are other ways to do this though

Basic Formatting Setup

How to setup basic formatting for a new typescript project using ESLint and prettier.

Skipping Ahead: If you already have ESLint and Prettier installed in your porject and just need to integrate them, skip to Integrating Prettier with ESLint.

1. Setting up ESLint

yarn add --dev @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint @types/eslint

Create a .eslintrc.cjs file and paste this in:

/**
 * @type {import("eslint").Linter.Config}
 */
module.exports = {
	extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
	parser: "@typescript-eslint/parser",
	plugins: ["@typescript-eslint"],
	root: true,
	overrides: [
		{
			files: ["*.{c,}js"],
			env: {
				node: true,
			},
		},
	],
};

Make sure your tsconfig.json includes your .eslintrc.cjs file and has the allowJs compiler rule set to true

{
	"compilerOptions": {
		"allowJs": true
	},
	"include": ["src", ".eslintrc.js"]
}

Add lint to your package.json scripts:

{
	"lint": "eslint --ext .ts,.tsx src"
}

2. Installing Prettier

yarn add --dev prettier

It is recommended you got into package.json and set the prettier dependency to only install the exact version by removing the ^ from the version number.

Create a .prettierrc.json file in your project root and paste in this:

{
	"trailingComma": "es5",
	"singleQuote": true,
	"endOfLine": "auto",
	"printWidth": 80,
	"useTabs": true
}

Add format to your package.json scripts:

{
	"format": "prettier --write \"src/**/*.{ts,tsx,css}\""
}

Make sure you to add/remove items from the specified file extensions as needed for your project.

3. Integrating Prettier with ESLint

yarn add --dev eslint-plugin-prettier eslint-config-prettier

Modify your .eslintrc.cjs file in the following ways:

  • Add 'prettier' to the 'plugins' array.
  • Add 'prettier' to the end of the 'extends' array. NOTE: It is important that this always remains the last item in the "extends" array to be certain that your prettier rules are applied with the highest priority.
  • Add 'prettier/prettier': 'error' to the rules

Change the format script in your package.json to:

{
	"format": "yarn lint --fix"
}

Now set your IDE's "format on save" feature to use ESLint.

FP Function Composition

Credit: https://code.lol/post/programming/typesafe-function-composition/

type SingleArgFunction<Input, Output> = (arg: Input) => Output;

type FunctionsAreComposable<
	F1 extends SingleArgFunction<any, any>,
	F2 extends SingleArgFunction<any, any>,
> = F1 extends (arg: any) => infer F1Output
	? F2 extends (arg: F1Output) => any
		? true
		: false
	: false;

type HasNoFalse<T extends boolean[]> = T extends [true, ...infer Rest]
	? Rest extends boolean[]
		? HasNoFalse<Rest>
		: false
	: T extends []
	? true
	: false;

type AdjacentPairs<Tuple extends any[]> = Tuple extends [
	infer I1,
	infer I2,
	...infer Rest,
]
	? [[I1, I2], ...AdjacentPairs<[I2, ...Rest]>]
	: [];

type MapFunctionPairsComposability<
	FunctionPairs extends [
		SingleArgFunction<any, any>,
		SingleArgFunction<any, any>,
	][],
> = {
	[Key in keyof FunctionPairs]: FunctionsAreComposable<
		FunctionPairs[Key][0],
		FunctionPairs[Key][1]
	>;
};

type IsValidFunctionPipeline<Functions extends SingleArgFunction<any, any>[]> =
	HasNoFalse<MapFunctionPairsComposability<AdjacentPairs<Functions>>>;

type NonEmptyArray<T> = [T, ...T[]];

const INVALID_PIPELINE_SYMBOL = Symbol("Invalid pipeline");
// eslint-disable-next-line @typescript-eslint/naming-convention
type PIPELINE_IS_INVALID = typeof INVALID_PIPELINE_SYMBOL;

type Head<Tuple extends any[]> = Tuple extends [infer TheHead, ...any[]]
	? TheHead
	: never;

type Last<Tuple extends any[]> = Tuple extends [...any[], infer LastItem]
	? LastItem
	: never;

type MapFunctionsToReturnType<Functions extends SingleArgFunction<any, any>[]> =
	{
		[Key in keyof Functions]: ReturnType<Functions[Key]>;
	};

type PipelineInput<Functions extends SingleArgFunction<any, any>[]> =
	FirstParameter<Head<Functions>>;

type FinalReturnOfPipeline<Functions extends SingleArgFunction<any, any>[]> =
	Last<MapFunctionsToReturnType<Functions>>;

type FirstParameter<T extends (...args: any[]) => any> = Parameters<T>[0];
/**
 * This is a bit of a hacky Typescript workaround to get the type information
 * when hovering to render the actual final underlying type information, rather
 * than just an incomprehensible mess of Generic type signatures.
 */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
type Resolve<T extends unknown> = T;

type PipedFunction<
	FunctionPipeline extends NonEmptyArray<SingleArgFunction<any, any>>,
> = Resolve<
	(
		input: IsValidFunctionPipeline<FunctionPipeline> extends true
			? PipelineInput<FunctionPipeline>
			: PIPELINE_IS_INVALID,
	) => FinalReturnOfPipeline<FunctionPipeline>
>;

type Composable<
	FunctionPipeline extends NonEmptyArray<SingleArgFunction<any, any>>,
> = IsValidFunctionPipeline<FunctionPipeline> extends true
	? FunctionPipeline
	: never;

/**
 * Not quite perfect, but pretty close. It should work for any number
 * of functions. Main downside is it won't automatically infer the
 * input type of functions in the pipeline, so you'll have to specify
 * them manually, however it will give a type error if you specify an
 * incorrect type.
 * Input and return types of the created function are automatically
 * inferred.
 * Does not allow multiple parameters for the first function in the pipeline.
 */
const makePipe =
	<Functions extends NonEmptyArray<SingleArgFunction<any, any>>>(
		...functions: Composable<Functions>
	): PipedFunction<Functions> =>
	(arg) =>
		functions.reduce((acc, fn) => (fn as any)(acc as any), arg) as any;

const a = makePipe(
	(x: number) => x + 1,
	(x: number) => x.toString(),
);

const pipe = <Functions extends NonEmptyArray<SingleArgFunction<any, any>>>(
	input: PipelineInput<Functions>,
	...functions: Composable<Functions>
): FinalReturnOfPipeline<Functions> =>
	functions.reduce((acc, fn) => (fn as any)(acc as any), input) as any;

const b = pipe(
	5,
	(x: number) => x + 1,
	(x: number) => new Date(x),
);

Did some refactoring near the end, and I think the invalid pipeline symbol doesn't actually do anything now and can probably be removed. I would do it now but I need to do my actual job.

Generics That Accept All With Exceptions

In Typescript, it's easy to have a function generic be constrained to a specific type.

const someFunction = <T extends string>(val: T) => val;

However, sometimes you may want to accept all types except a few. There is a way to do this, although it is admittedly a bit hacky:

// IF YOU ARE SKIM READING THIS NOTE, THIS IS THE
// IMPORTANT PART
const takesAllExceptNumbers = <T>(val: Exclude<T, number>) => val;

That code works in an interesting way, because technically, the type T can be a number, however by excluding number from the param type, we will get a type error, since the the result of Exclude<T, number> will be never if T is a number.

Google Cloud Logger

A small wrapper around console logging methods that output logs in a format that will be parsed correctly by Google Cloud Logging.

/**
 * TODO:
 * - More readable output in local dev
 * - Support `stdout` and `stderr`
 * - Context string (provided in constructor, readonly)
 */

//# JSON Type Utils

export type JSONPrimitive = string | number | boolean | null;
export type JSONArray = JSONValue[];
export type JSONObject = { [key: string]: JSONValue };

export type JSONValue = JSONPrimitive | JSONArray | JSONObject;

export const isJSONPrimitive = (value: any): value is JSONPrimitive =>
	typeof value === "string" ||
	typeof value === "number" ||
	typeof value === "boolean" ||
	value === null;

export const isJSONArray = (value: any): value is JSONArray =>
	Array.isArray(value) && value.every(isJSONValue);

export const isJSONObject = (value: any): value is JSONObject =>
	typeof value === "object" &&
	value !== null &&
	!Array.isArray(value) &&
	Object.values(value).every(isJSONValue) &&
	Object.keys(value).every((key) => typeof key === "string");

export const isJSONValue = (value: any): value is JSONValue =>
	isJSONPrimitive(value) || isJSONArray(value) || isJSONObject(value);

//# Main Code
// import { isJSONObject, isJSONValue, JSONObject } from "../types/json-types";
//^Uncomment if keeping JSON helpers in separate file

const GOOGLE_CLOUD_ERROR_LOG_TYPE =
	"type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent";

export type GoogleCloudLogSeverity =
	| "DEBUG"
	| "INFO"
	| "WARNING"
	| "ERROR"
	| "CRITICAL";

const logMethods: Record<GoogleCloudLogSeverity, typeof console.log> = {
	DEBUG: console.debug,
	INFO: console.info,
	WARNING: console.warn,
	ERROR: console.error,
	CRITICAL: console.error,
};

export type GoogleCloudErrorJSONPayload = {
	message?: string;
	stack_trace?: string;
	exception?: string;
	"@type"?: string;
};

export type GoogleCloudLog = {
	severity: GoogleCloudLogSeverity;
	message?: string;
} & JSONObject;

/**
 * A custom wrapper around console that outputs logs in a format compliant
 * with Google Cloud Logging.
 */
export class GoogleCloudCompliantConsole {
	private _actuallyLog(severity: GoogleCloudLogSeverity, args: any[]) {
		const jsonPayload: JSONObject = {};
		const messageParts: string[] = [];
		let actualSeverity = severity;

		args.forEach((arg, index) => {
			const fallbackKey = `_arg${index}`;

			/**
			 * Since parameter typing on log args is `any`, we need to handle
			 * various different possible types, the basics are:
			 * - Strings are joined with commas and put in `message`
			 * - Objects that are valid JSON are merged into the object
			 * - Errors are translated into a structured object accepted by
			 * Google Cloud Logging and merged into the object
			 * - Any other types are inserted into the object with an auto-generated
			 * key based on their index in the args list.
			 * - Non-JSON values are stringified before insertion.
			 */

			if (typeof arg === "string") {
				messageParts.push(arg);
			} else if (arg instanceof Error) {
				const errorLogData: GoogleCloudErrorJSONPayload = {
					"@type": GOOGLE_CLOUD_ERROR_LOG_TYPE,
					message: arg.message,
					exception: arg.name,
					stack_trace: arg.stack,
				};
				Object.assign(jsonPayload, errorLogData);
				actualSeverity = "ERROR";
			} else if (isJSONObject(arg)) {
				Object.assign(jsonPayload, arg);
			} else if (isJSONValue(arg)) {
				jsonPayload[fallbackKey] = arg;
			} else {
				jsonPayload[fallbackKey] = String(arg);
			}
		});

		const payload: GoogleCloudLog = {
			severity: actualSeverity,
			...jsonPayload,
		};

		const message = messageParts.join(", ");
		if (message) {
			payload.message = message;
		}

		const log = logMethods[severity];

		log(JSON.stringify(payload));
	}

	private composeLogMethod = (severity: GoogleCloudLogSeverity) => {
		return (...args: any[]) => {
			this._actuallyLog(severity, args);
		};
	};

	// Logging methods
	log = this.composeLogMethod("INFO");
	debug = this.composeLogMethod("DEBUG");
	info = this.composeLogMethod("INFO");
	warn = this.composeLogMethod("WARNING");
	error = this.composeLogMethod("ERROR");
	critical = this.composeLogMethod("CRITICAL");
}

export const appLogger = new GoogleCloudCompliantConsole();

How to Typecheck

tsc --noEmit

Monorepo Versioning + Release Setup

This is relatively basic and involves a lot of manual by hand work, and leaves out some stuff you may want. This is because versioning on its own can be pretty complex, and messing it up can be an absolute ballache. So I would rather have a simple process that requires a bit of manual work than a complex process that can go wrong.

Changesets

Rather than doing inferring release information from commits, we will instead be using changesets. Changesets takes a simpler, more direct approach, wherein the developer manually writes the information about a release and specifies version bumping by hand, rather than that information being read from the commit history. It's a bit more leg work for each release, but ultimately its harder to fuck up.

Workflow

Everytime you are ready to commit a change to a package that is going to be published, you run changesets in the terminal, select the packages you want to update and how to update them. Then you include the generated file in your commit. Then once that commit has been sent, run changeset version which will generate the changelog and bump package versions, commit these changes in a chore commit.

Notes on NaN

Equality

NaN values are not equal to each other:

NaN === NaN; // false;

The reasoning behind this is that if you were doing a comparison of the results of 2 math operations, and both of them returned NaN, you would not want the result of that comparison to say that they are "equal", since in reality they both errored out.

Checking for NaN in JS/TS

JS has 2 "isNaN" functions. There is the global isNaN and the static number method Number.isNaN. The distinction between these 2 is as follows:

  • isNaN checks if a value would return NaN if it was converted to a number
  • Number.isNaN checks if a value already is NaN

Overriding Module Types

You can augment a module's types by creating a d.ts file in the src directory:

// customTypes.d.ts

declare module "react" {
	interface HTMLAttributes<T> extends DOMAttributes<T> {
		// extends React's HTMLAttributes
		custom?: string;
	}
}

The above adds an optional custom property to react's HTMLAttributes type.

If you want to completely override some typing rather than simply augmenting, you can do the following.

// customTypes.d.ts
declare module "react" {
	type _HTMLAttributes = "my-override";
	export { _HTMLAttributes as HTMLAttributes };
}

Now react's HTMLAttributes type will be completely overridden by our own type.

Platform Compatibility Issues

This note goes over some of the biggest compatibility issues on common web platforms.

window.open in Async

On Safari (IOS and Desktop), there are rules regarding window opening having to be prompted by user interaction. If you try to open a window too deep into an async function after some time has passed since the click, it will most likely fail.

If you are okay with not opening in a new tab, you can use window.location.href to navigate to the new page.

window.location.href = "https://example.com";

If you absolutely MUST open in a new tab, there is this method, but its not ideal, as it will result in a user starting at an empty blank tab while you load the new page in the background.

const newWindow = window.open("", "_blank");

const nextUrl = await getNextUrl();

newWindow.location.href = nextUrl;

There is a better way to open in a new tab on desktop safari by simulating a click on a fake link element.

const link = document.createElement("a");
link.href = "https://example.com";
link.target = "_blank";
link.click();

This will not work on mobile safari, however.

My recommendation for a comprehensive solution is this:

const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;

// let newWindow: ReturnType<typeof window.open> = isIOS ? window.open('', '_blank') : null;
// ^re-enable if you 100% MUST open in a new tab on all platforms

const theUrl = await getNextUrl();

const newWindow = window.open(theUrl, "_blank");

/**
 * if (isIOS) {
 *  newWindow.location.href = theUrl;
 *  return;
 * }
 * newWindow = window.open(theUrl, '_blank');
 */
// ^re-enable this and remove the `const newWindow` line above if
// you are using the IOS new tab workaround

if (!newWindow) {
	// window.open returns `null` if it fails
	if (isIOS) {
		window.location.href = theUrl;
		// IOS is worst case, just bite the bullet and give up
		// on opening in a new tab
		return;
	}

	// Desktop Safari workaround, click a fake link
	const link = document.createElement("a");
	link.href = theUrl;
	link.target = "_blank";
	link.click();
}

Potential Improvement for IOS new tab workaround: If have decided to use the awkward workaround for opening in a new tab on IOS, there is a method you could try to improve it (I haven't tried it yet). You could create a new page for your site that just displays a loading indicator, then when you create the initial new tab you point it to that loading page instead of a blank page, that way the user will be looking at your loading indicator while waiting instead of just a white screen.

The biggest flaw with this approach is that your loading page will itself have to load, so if you are doing this you have to try to minimise the bundle size for your loading page as much as possible. If you are using a client-side rendering framework eg; react or vue where you can't avoid bundling large JS into the page, you could try actually creating your loading page as a static HTML file in the public directory and defining it without your framework.

Redux Style Types

Utility types that will help you implement a redux-esque pattern of state management in your application, with reducers and actions.

Some code may use some generic utility types that should be stored in a common utility types file somewhere in your project, those will be shown in seperate code blocks beneath the main code in each section.

Actions

This code uses these utility tryp

const NO_PAYLOAD = Symbol("NO_PAYLOAD");
type NoPayload = typeof NO_PAYLOAD;

type StateAction<
	Type extends string,
	Payload extends AnyObject | NoPayload = NoPayload,
> = OmitByValue<
	{
		type: Type;
		payload: Payload;
	},
	NoPayload
>;
// We make Payload extend AnyObject, because non-object payloads make the code
// less obvious

Utility Types:

type AnyObject = Record<string, unknown>;

type OmitByValue<Base, ValueToOmit> = {
	[K in keyof Base as Base[K] extends ValueToOmit ? never : K]: Base[K];
	// Works because typing an object key as `never` will remove it from the object
};

Reducers

type StateReducer<State, Action> = (state: State, action: Action) => State;

Dispatch

type StateDispatch<Action> = (action: Action) => void;

Replacing Enums

How to achieve enum equivalent developer experience in TypeScript without using enums:

type AsEnumLiteral<T extends Record<string, string>> = T[keyof T];
// Example of creating a pseudo-enum for user roles

// We create an object with the enum values that we can use
// to reference the values in code
const UserRoles = {
  Admin: 'admin',
  User: 'user'
} as const; // `as const` is 100% necessary here

// We use `AsEnumLiteral` to create a type that represents the values of the object
type UserRole = AsEnumLiteral<typeof UserRoles>; // "admin" | "user"
// user role "enum" usage examples:
const sampleA: UserRole = UserRoles.User; // Valid
const sampleB: UserRole = 'admin'; // Also valid

function myFunction(role: UserRole) {
  // ...
}

myFunction(UserRoles.Admin); // Valid
myFunction('user'); // Also valid

If you want, you can actually use the same name for the type and the object:

const LikeKind = {
  Like: 'like',
  Dislike: 'dislike'
} as const;

type LikeKind = AsEnumLiteral<typeof LikeKind>;

const likeKindSample: LikeKind = LikeKind.Like; // Valid

Full Example

export const UserRoles = {
  Admin: 'admin',
  User: 'user',
  SuperViewer: 'superviewer'
};

export type UserRole = AsEnumLiteral<typeof UserRoles>; // "admin" | "user" | "superviewer"

export type User = {
  id: string;
  role: UserRole;
  name: string;
  hashedPassword: string;
  dateOfBirth: Date;

  createdAt: Date;
  updatedAt: Date;
};

export const USER_ROLES_WITH_GLOBAL_VIEW_ACCESS: UserRole[] = [UserRoles.Admin, UserRoles.SuperViewer];

Serializing Data For Search Params

Stringify the data with JSON.stringify or an equivalent and then encode it with compressToEncodedURIComponent from the lz-string package.

import { compressToEncodedURIComponent } from 'lz-string';
const data = {
  name: 'John',
  age: 30,
  city: 'New York'
};

const serializedData = compressToEncodedURIComponent(JSON.stringify(data));

Then you can decode it with decompressFromEncodedURIComponent from the lz-string package.

import { decompressFromEncodedURIComponent } from 'lz-string';

const data = JSON.parse(decompressFromEncodedURIComponent(serializedData));

Dependency Free

JS Now also ships with a built-in alternative; encodeURIComponent.

const data = {
  name: 'John',
  age: 30,
  city: 'New York'
};

const serializedData = encodeURIComponent(JSON.stringify(data));

const data = JSON.parse(decodeURIComponent(serializedData));

However is it important to understand that this will likely result in much longer data strings than lz-string.

Tips on Getting Playwright Tests to Work Consistently

1. Use Sharding in CI

Sharding speeds up the process by distributing it amongst multiple instances, and the reduction of strain on the individual instances makes the tests more consistent.

Look in the bag of holding .github/workflows/test.yml for an example of how to set up sharding.

NOTE: At time of writing, the bag of holding workflow does not merge the outputs of the test at the end, which is recommended. You can find instructions for that here.

2. Wait/Check for Things the Right Way

  • When navigating, use page.waitForLoadState("domcontentloaded")

  • For dialogs, make sure you have set them up the correct accessible way so that the browser recognises them as dialogs and can read their title. Then you can use the following code:

page
	.getByRole("dialog", {
		name: "your-dialog-name",
	})
	.waitFor({ state: "visible" });
  • When checking if you are on a certain page, a reliable method is to check the pathname:
page.evaluate(() => window.location.pathname);

3. Use a Single Worker for Local Testing

Install the is-ci package, let the "workers" option in the playwright config be assigned automatically in CI, but set it to 1 when running locally.

export default {
	workers: isCI ? undefined : 1,
};

Script Tips

Tips for writing automation scripts in typescript.

Persisting Data Across Script Runs

Here are 3 options for persisting data accross script runs:

  1. Use the packages flat-cache and find-cache-dir
  2. Write it to a JSON file
  3. Store in a Redis database

1. Use the packages flat-cache and find-cache-dir

A common approaches that CLI apps use is to create a folder named something like .cache within the node_modules directory and write files to that folder. This means data is persisted across runs and the file is out of the way of the user.

The main downside to this is that it means the persisted data is ignored by git, so data is only persisted across multiple runs on the same machine.

The flat-cache package manages the actual read/write logic and the find-cache-dir package helps you find an appropriate directory to write to.

You could probably figure out a simple way to replace find-cache-dir, but it is a very lightweight package and should only be a dev dependency.

Example:

import findCacheDir from "find-cache-dir";
import cacheManager from "flat-cache";
// The exact export signature of the libraries may be different

const APP_NAME = "your-app-name-here";
const cacheDirectory = findCacheDir({ name: APP_NAME });
if (!cacheDirectory) throw new Error("Cache directory not found");

const cache = cacheManager.load(APP_NAME, cacheDirectory);

cache.get("your-key-here");
cache.set("your-key-here", "your-value-here");

cache.save();

If you need to write complex data structures you can use superjson to serialize/deserialize the data.

2. Write it to a JSON file

In typescript you can just import a JSON file and it will have good typing. Then you can just modify that object and when you are done with it run it through JSON.stringify and write it back to the file with fs.writeFile.

Example:

import { writeFileSync } from "fs";
import yourData from "./your-data.json";

yourData.someKey = "someValue";

writeFileSync("./your-data.json", JSON.stringify(yourData));

Important Caveat: While thi data can be persisted accross different machines by just having the JSON file be tracked by git, you will need to take some extra steps if you want this data to be written to during CI or a build step. Once the script runs in CI/build step, you need to make sure that the changes are committed and pushed back to the repo.

3. Store in a Redis database

This option is the most powerful but also the most complex to set up. You can create a free redis database ("upstash" is a good choice) and then simply connect to it and store data in it.

Framework for Complex Scripts

If you need to write a script that takes args, use the cmd-ts library. It's very straight forward with good type safety.

Executing Commands As If The User Typed Them

Use the execa package to be able to execute terminal commands with a script and have the output be printed to the terminal as if the user typed it.

import { $ as _$ } from "execa";

const $ = _$({ stdio: "inherit" });

$`echo hi`;

Type Checking ESLint Config Files

  1. Your eslint file must be a JS file,
  2. Your tsconfig.json must allow js and and include your eslint config file.
  3. Make sure you have @types/eslint installed as a dev dependency
  4. Add the comment /** @type {import('eslint').Linter.BaseConfig} */ just above the config code

THIS MAY BE OUT OF DATE WHEN ESLINT V9 COMES OUT, AS IT HAS BEEN ANNOUNCED THAT IT WILL USE A NEW CONFIG FILE STRUCTURE

Example Config File:

/** @type {import('eslint').Linter.BaseConfig} */
module.exports = {
  env: {
    browser: true,
    es2021: true
  },
  extends: ['plugin:react/recommended', 'standard-with-typescript'],
  overrides: [],
  parserOptions: {
    ecmaVersion: 'latest',
    sourceType: 'module'
  },
  plugins: ['react'],
  rules: {}
};

Linting New Flat Config Files

It's basically the same as above, but the type to apply to the config is different. Each item in the config array should be typed as import('eslint').Linter.Config, so the config array should be typed as an array of that, eg;

/** @type {import('eslint').Linter.Config[]} */
export default [
  //your config here
];

Type Safe Error Handling

We can achieve type safe error handling in try/catch statements using the instanceof keyword. instanceof lets us easily check if an object is an instance of a class.

Simple

The most straightforward way to achieve some basic type safety in "catch" blocks is by checking the caught item with instanceof Error:

export const getUserWithId = async (userId: string) => {
  try {
    const user = await fetch(`/api/users/${userId}`).then((res) => res.json());
    if (!user) {
      throw new Error(`User with id "${userId}" not found`);
    }
  } catch (e) {
    if (e instanceof Error) {
      // handle error
    }
  }
};

You could also use

if (!(e instanceof Error)) throw new Error(`Non-error object thrown ${e}`);
// Handle error below...

Advanced (Custom Error Types)

To achieve maximum error type-safety and be able to easily handle different types of errors, we can create our own custom error types by extending the base Error class, and then using instanceof to check if an error is an instance of a specific error type.

First we will create this utility function in a utils file somewhere:

// utils.ts
export const createCustomError = <Input>(messageGenerator: ((input: Input) => string) | string) => {
  class NewCustomError extends Error {
    constructor(input: Input) {
      const message: string = typeof messageGenerator === 'string' ? messageGenerator : messageGenerator(input);
      super(message);
    }
  }

  return NewCustomError;
};

Then we can use it in our functions like so:

// userApi.ts

const UserNotFoundError = createCustomError<string>((userId) => `User with id "${userId}" not found`);
const UnableToDeriveUserPrivilegesError = createCustomError<string>((userId) => `Unable to derive privileges for user with id "${userId}"`);

export const getUserPrivileges = async (userId: string) => {
  try {
    const user = await getUser(userId);
    if (!user) {
      throw new UserNotFoundError(userId);
    }

    const privileges = await deriveUserPrivileges(user);
    if (!privileges) {
      throw new UnableToDeriveUserPrivilegesError(userId);
    }

    return privileges;
  } catch (err) {
    if (err instanceof UserNotFoundError) {
      // handle UserNotFoundError
    } else if (err instanceof UnableToDeriveUserPrivilegesError) {
      // handle UnableToDeriveUserPrivilegesError
    } else {
      // handle other errors
    }
  }
};

Advanced+

To give more context in your custom errors, forget the createCustomError utility function and just create your own custom error classes:

class ValidationError extends Error {
  constructor(message: string, public field: string) {
    super(message);
    this.name = 'ValidationError';
  }
}

When viewing the error in dev tools the error should include the extra fields.

type StringWithBoth<A extends string, B extends string> =
	| `${string}${A}${string}${B}${string}`
	| `${string}${B}${string}${A}${string}`;

type ValueOf<T> = T[keyof T];

// type ParamRoutePathTest<Params extends Record<string, string>> =
//   `${string}:${Extract<keyof Params, string>}${string}`;
type ParamRoutePathTest<Params extends Record<string, string>> = ValueOf<{
	[K in keyof Params]: ParamRoutePathTest<Omit<Params, K>> extends never
		? `${string}:${Extract<K, string>}${string}`
		: StringWithBoth<
				`:${Extract<K, string>}`,
				ParamRoutePathTest<Omit<Params, K>>
		  >;
}>;

type TestRoutePath = ParamRoutePathTest<{
	a: string;
	b: string;
	c: string;
}>;

// @ts-expect-error - has no params
export const noParams: TestRoutePath = "/root";

// @ts-expect-error - does not have all params
export const onlyOne: TestRoutePath = ":a";

// @ts-expect-error - does not have all params
export const onlyTwo: TestRoutePath = "/root/:a/child/:b";

export const correct: TestRoutePath = "/root/:a/child/:b/:c";

Typed Routing

This is draft for code that helps you define type-safe routes for any framework that supports routing.

// # UTILITY TYPES
type ValidStringLiteral = string | number | bigint | boolean | null | undefined;

type Join<
	Arr extends ValidStringLiteral[],
	Separator extends string = "",
> = Arr extends [
	infer Head extends ValidStringLiteral,
	...infer Tail extends ValidStringLiteral[],
]
	? `${Head}${Tail extends [] ? "" : Separator}${Join<Tail, Separator>}`
	: "";

type Simplify<T> = {
	[Key in keyof T]: T[Key];
} & {};

type Merge<A, B> = Simplify<Omit<A, keyof B> & B>;

type EmptyObject = Record<string, never>;

// # MAIN

type RouteParamDefinition = {
	name: string;
	type: "number" | "string";
};

type InferRouteParamDefinitionInput<T extends RouteParamDefinition> =
	T["type"] extends "number" ? number : string;

type RoutePathSegment = string | RouteParamDefinition;

type InferRoutePathSegmentInput<Segment extends RoutePathSegment> =
	Segment extends RouteParamDefinition
		? InferRouteParamDefinitionInput<Segment>
		: Segment;

type InferRoutePathSegmentPattern<T extends RoutePathSegment> =
	T extends RouteParamDefinition ? `:${T["name"]}` : T;

type InferRouteInputSegments<Segments extends RoutePathSegment[]> =
	Segments extends [
		infer Head extends RoutePathSegment,
		...infer Tail extends RoutePathSegment[],
	]
		? [InferRoutePathSegmentInput<Head>, ...InferRouteInputSegments<Tail>]
		: [];

type InferRoutePatternSegments<Segments extends RoutePathSegment[]> =
	Segments extends [
		infer Head extends RoutePathSegment,
		...infer Tail extends RoutePathSegment[],
	]
		? [InferRoutePathSegmentPattern<Head>, ...InferRoutePatternSegments<Tail>]
		: [];

type InferRouteLiteral<Segments extends RoutePathSegment[]> = Join<
	InferRouteInputSegments<Segments>,
	"/"
>;

type InferRoutePattern<Segments extends RoutePathSegment[]> = Join<
	InferRoutePatternSegments<Segments>,
	"/"
>;

type InferRouteParams<
	Segments extends RoutePathSegment[],
	Result extends Record<string, any> = {},
> = Segments extends [
	infer Head extends RoutePathSegment,
	...infer Tail extends RoutePathSegment[],
]
	? InferRouteParams<
			Tail,
			Merge<
				Result,
				Head extends RouteParamDefinition
					? {
							[Key in Head["name"]]: InferRouteParamDefinitionInput<Head>;
					  }
					: {}
			>
	  >
	: Result; 

/**
 * Helper type for defining route path segments.
 * Not strictly necessary, just helps by giving type
 * errors if a mistake is made in the segments.
 */
type RPS<T extends RoutePathSegment[]> = T;

// # TYPE EXAMPLES

type NewPostRouteSegments = RPS<["posts", "new"]>;
type NewPostRoutePath = InferRouteLiteral<NewPostRouteSegments>;
type NewPostRoutePattern = InferRoutePattern<NewPostRouteSegments>;

type EditPostRouteSegments = RPS<
	[
		"posts",
		{
			name: "postId";
			type: "string";
		},
		"edit",
	]
>;
type EditPostRoutePath = InferRouteLiteral<EditPostRouteSegments>;
type EditPostRoutePattern = InferRoutePattern<EditPostRouteSegments>;
type EditPostRouteParams = InferRouteParams<EditPostRouteSegments>;

type ViewUserFolderItemsRouteSegments = RPS<
	[
		"user",
		{
			name: "userId";
			type: "string";
		},
		"folder",
		{
			name: "folderId";
			type: "number";
		},
	]
>;

type ViewUserFolderItemsRoutePath =
	InferRouteLiteral<ViewUserFolderItemsRouteSegments>;
type ViewUserFolderItemsRoutePattern =
	InferRoutePattern<ViewUserFolderItemsRouteSegments>;
type ViewUserFolderItemsRouteParams =
	InferRouteParams<ViewUserFolderItemsRouteSegments>;

// # FUNCTIONAL CODE

const getRouteSegmentsPattern = <Segments extends RoutePathSegment[]>(
	segments: Segments,
): InferRoutePattern<Segments> =>
	segments
		.map((segment) =>
			typeof segment === "string" ? segment : `:${segment.name}`,
		)
		.join("/") as InferRoutePattern<Segments>;

type RouteHelper<Segments extends RoutePathSegment[]> = {
	/**
	 * Pass to routing library to define the route pattern.
	 */
	pattern: InferRoutePattern<Segments>;


	/**
	 * Unsafely casts the input to the expected route params type. Intended
	 * for usage when param data is extracted by a library with loose typing,
	 * eg; `react-router` or `next.js`.
	 */
	castAsParams: (input: Record<string, unknown>) => InferRouteParams<Segments>;

	/**
	 * Generate a URL path for the route based on the input params.
	 */
	getLink: (...params: (InferRouteParams<Segments> extends EmptyObject ? [] : [input: InferRouteParams<Segments>])) => InferRouteLiteral<Segments>;



	/**
	 * No actual purpose/data, should only be used with `typeof` to infer the
	 * type of the route params.
	 */
	_inferParams: InferRouteParams<Segments>;

};

const buildRouteHelper = <const Segments extends RoutePathSegment[]>(
	segments: Segments,
): RouteHelper<Segments> => ({
	pattern: getRouteSegmentsPattern(segments),
	// path: (p) => p,
	castAsParams: (i) => i as InferRouteParams<Segments>,
	getLink: (...params) => {
		if (params.length === 0) {
			return getRouteSegmentsPattern(segments) as InferRouteLiteral<Segments>;
		}
		const evaluatedSegments = segments.map((segment) => {
			if (typeof segment === "string") {
				return segment;
			}
			const paramValue = params[0][segment.name];
			if (segment.type === "number" && typeof paramValue !== "number") {
				throw new Error(`Expected ${segment.name} to be a number, but got ${typeof paramValue}`);
			}
			if (segment.type === "string" && typeof paramValue !== "string") {
				throw new Error(`Expected ${segment.name} to be a string, but got ${typeof paramValue}`);
			}
			return String(paramValue);
		});
		return evaluatedSegments.join("/") as InferRouteLiteral<Segments>;
	},
	_inferParams: {} as InferRouteParams<Segments>,
});

// TODO: Add a way of validating/retrieving route params.
// Could implement something like `routeHelperBuilderFactory`,
// where we can define how to retrieve/validate route params,
// and that is then used when building route helpers.

// # FUNCTIONAL CODE EXAMPLES

const viewUserFolderRoute = buildRouteHelper([
	"user",
	{
		name: "userId",
		type: "string",
	},
	"folder",
	{
		name: "folderId",
		type: "number",
	},
]);

type ViewUserFolderRouteParams = typeof viewUserFolderRoute._inferParams;

const untypedParams = {}; // Imagine this is from a function eg; `useParams()`
const typeSafeParams = viewUserFolderRoute.castAsParams(untypedParams);

const linkToSomeUser = viewUserFolderRoute.getLink({folderId: 123, userId: "abc"});
// SCROLL TO BOTTOM TO SEE HOW TO PLAY
type Team = 'noughts' | 'crosses';
type Empty = '#';
type Nought = 'O';
type Cross = 'X';
type Piece = Nought | Cross;
type Square = Empty | Piece;
type GetTeamPiece<TheTeam extends Team> = TheTeam extends 'noughts' ? Nought : Cross;
type Row<Square1 extends Square, Square2 extends Square, Square3 extends Square> = `${Square1}${Square2}${Square3}`;
type AnyRow = Row<Square, Square, Square>;
type Board<Row1 extends AnyRow, Row2 extends AnyRow, Row3 extends AnyRow> = {
t: Row1;
m: Row2;
b: Row3;
};
type AnyBoard = Board<AnyRow, AnyRow, AnyRow>;
type EmptyRow = Row<Empty, Empty, Empty>;
type EmptyBoard = Board<EmptyRow, EmptyRow, EmptyRow>;
type VerticalPosition = 't' | 'm' | 'b';
type HorizontalPosition = 'l' | 'c' | 'r';
type PiecePosition = `${VerticalPosition}${HorizontalPosition}`;
type ProcessRowMove<
TheRow extends AnyRow,
TheTeam extends Team,
Horizontal extends HorizontalPosition
> = TheRow extends Row<infer Left, infer Middle, infer Right>
? [Horizontal, Left] extends ['l', Empty]
? Row<GetTeamPiece<TheTeam>, Middle, Right>
: [Horizontal, Middle] extends ['c', Empty]
? Row<Left, GetTeamPiece<TheTeam>, Right>
: [Horizontal, Right] extends ['r', Empty]
? Row<Left, Middle, GetTeamPiece<TheTeam>>
: never
: never;
type ProcessMove<TheBoard extends AnyBoard, TheTeam extends Team, Position extends PiecePosition> = TheBoard extends Board<infer TopRow, infer MiddleRow, infer BottomRow>
? Position extends `t${infer Horizontal extends HorizontalPosition}`
? Board<ProcessRowMove<TopRow, TheTeam, Horizontal>, MiddleRow, BottomRow>
: Position extends `m${infer Horizontal extends HorizontalPosition}`
? Board<TopRow, ProcessRowMove<MiddleRow, TheTeam, Horizontal>, BottomRow>
: Position extends `b${infer Horizontal extends HorizontalPosition}`
? Board<TopRow, MiddleRow, ProcessRowMove<BottomRow, TheTeam, Horizontal>>
: never
: never;
// PLAY
// type A = ProcessMove<EmptyBoard, 'crosses', 'bl'>;
// type B = ProcessMove<A, 'noughts', 'bc'>;
// type C = ProcessMove<B, 'crosses', 'br'>;

Typescript Utility Types

Omit/Pick By Field Value

Versions of typescript's built-in Pick and Omit utility types that are instead conditional on the value of the fields rather than the keys.

export type OmitByValue<Base, ValueToOmit> = {
  [K in keyof Base as Base[K] extends ValueToOmit ? never : K]: Base[K];
  // Works because typing an object key as `never` will remove it from the object
};

export type PickByValue<Base, ValueToPick> = Omit<Base, keyof OmitByValue<Base, ValueToPick>>;

Common Naming Conflicts

'Selected' Entities and State 'Selectors'

If you are working in a large app with a lot of state, you may have the term "selectors" popping up a lot in your code. You may also have a feature wherein one or more entities can be "selected".

If you have both of these, you should keep using the word "select" when referring to "selector" functions, and find an alternate word for the "selected" entities, as well as different verbs for the act of "selecting" them. Some alternatives:

  • "active"/"activate"
  • "highlighted"/"highlight"
  • "marked"/"mark"

Composing labels Out of Multiple Nullable Pieces

Sometimes you may need to compose a label for something out of multiple pieces of data, eg;

const fullName = `${title} ${firstName} ${middleName} ${lastName}`;

However, what if title or middleName could be undefined?

The easiest way to handle this is to put these items into an array, filter out the nully values, then join the array.

const fullNameSegments = [title, firstName, middleName, lastName].filter(
	Boolean,
);

const fullName = fullNameSegments.join(" ");

Complex Situation

What if you need to apply transformations to a nullable value, eg;

const fullName = `(${title}) ${firstName} ${middleName} ${lastName}`;

Here, we can see we need to wrap title in brackets, but only if it's not null.

There is no easy 1 step trick to this, we will need to apply some custom logic. One way to do it is to actually break down each segment into its own sub array with each item being a nullable string, if a sub array contains any nully value, then it gets filtered out entirely.

type NullableLabelSegment =
	| (string | null | undefined)[]
	| string
	| null
	| undefined;
const composeLabelFromSegments = (segments: NullableLabelSegment[]) =>
	segments
		.filter((segment) => {
			if (Array.isArray(segment)) {
				return segment.every(Boolean);
			}
			return Boolean(segment);
		})
		.join(" ");

Now we can use this function to compose our label.

const fullName = composeLabelFromSegments([
	["(", title, ")"],
	firstName,
	middleName,
	lastName,
]);

Data Migrations

This is a general guide on how to structure code related to migrating old data to newer versions of data.

Any code examples will use typescript, but I've tried to write it so the ideas can be applied to other languages.

When Is This Useful?

The absolute ideal for data migration is simply running a migration program that finds all old data and updates it to the newer version, then you never have to think about it again.

This guide is not for doing that, this is for when you are not able to migrate all existing old data at once, for instance; when your dealing with localStorage that is stored on the users device.

Here We Go!

In our examples we will be updating a hypothetical User type.

Start by creating a file called something like data-migrator, then paste the Data Migrator code from the Code heading at the bottom of this file. If not using typescript you will need to rewrite the code to work with your language.

When you need to upgrade a data structure in a way that will break compatibility, first you create a folder dedicated to that data type, then create a file in that folder called v1 and paste all your current validation code in there. If the validation code is spread across multiple files, then use a folder called v1 and make sure there is an index file in there that exports all of the validation functions. We will continue to reference creating "files" for different versions, but you can replace that with "folder" if you need to.

Now, create a v2 file, paste in all the code from v1 and then make your changes.

TIP: For v2 and further versions, you should include a version field in your data that is a static literal containing the version number. This will allow you to immediately determine what version some data is, and will avoid situations where a piece of data could possibly be interpreted as multiple versions.

Now create a file called migrations inside the folder for the data type. At the top of the file import all of the v1 and v2 logic into single variables:

import * as userV1 from "./v1";
import * as userV2 from "./v2";

...

Code

Data Migrator

// data-migrator.ts

type Migration<CurrentData, NewData> = {
	parser: (data: any) => NewData;
	upgradeOldData: (data: CurrentData) => NewData;
};

const FAILED_TO_PARSE = Symbol("FAILED_TO_PARSE");

export class DataMigrator<Data> {
	parsers: ((data: any) => Data)[] = [];

	upgradeFunctions: ((data: any) => any)[] = [];

	constructor(dataParser: (data: any) => Data) {
		this.parsers.push(dataParser);
		this.upgradeFunctions.push((data) => data);
	}

	public addMigration<NewData>(migration: Migration<Data, NewData>) {
		const thisCopy = this as any as DataMigrator<NewData>;
		thisCopy.parsers.push(migration.parser);
		thisCopy.upgradeFunctions.push(migration.upgradeOldData);

		return thisCopy;
	}

	public parse(data: any): Data {
		let parserIndex = -1;
		let parsed: any = FAILED_TO_PARSE;

		let parseError: any;
		[...this.parsers].reverse().find((parser) => {
			try {
				parsed = parser(data);
				parserIndex = this.parsers.indexOf(parser);
				return true;
			} catch (e) {
				parseError = e;
				return false;
			}
		});

		if (parsed === FAILED_TO_PARSE) {
			throw parseError;
		}

		const upgradeFunctionsToUse = this.upgradeFunctions.slice(parserIndex + 1);
		const final = upgradeFunctionsToUse.reduce((result, upgrade) => {
			try {
				return upgrade(result);
			} catch (e) {
				return result;
			}
		}, parsed);

		return final;
	}

	public composeParserFunction() {
		return (data: any) => this.parse(data);
	}

	public composeTypeGuardFunction() {
		return (data: any): data is Data => {
			try {
				this.parse(data);
				return true;
			} catch (e) {
				return false;
			}
		};
	}
}

Doing Fancy Inputs

If you have a situation where you need to do some fancy input stuff (eg; a number input that can include the currency symbol and commas), the basic methodology to use is this.

The input itself needs to store it's value as a string that is the value that is displayed (eg; for currency, the numeric value is the number 1000, but the value of the input is the string "$1,000"). Then you have 2 "helper" functions, a "formatter" and a "parser".

Formatter: Your formatter function takes the actual raw value that the input represents (eg; the input's value is the string "$1,000", so the "raw" value is the number 1000), and then converts it to the string format that is shown in the input (so to go back to the currency example the formatter would convert the number 1000 to the string "$1,000").

Parser: Your parser is the exact reverse of your formatter, it takes the formatted string from the input, and converts it to the raw value that the input represents (eg; it converts string "$1,000" to number 1000).

Now, you have some state that contains the "raw" data value for the input, then you subscribe to that state, and everytime it changes, you take the new value, run it through the formatter, then set the input's value to the formatted string. Then you have an onChange handler on the input, that takes the input's value, runs it through the parser, and then sets the state to the new raw value.

/**
* A headless component for rendering a list that helps with
* applying transition animations when items are added/removed.
*/
import { ReactNode, useEffect, useMemo, useState } from "react";
type TransitionStatus = "entering" | "exiting" | "stable";
type TransitionListProps<ListEntity> = {
data: ListEntity[];
transitionMsDuration: number;
render: (entity: ListEntity, status: TransitionStatus) => ReactNode;
compare: (a: ListEntity, b: ListEntity) => boolean;
sorter: (a: ListEntity, b: ListEntity) => number;
};
type TransitionEntityStatus<T> = {
entity: T;
status: TransitionStatus;
};
type ComposeTransitionListStatusReportInput<T> = {
baseList: T[];
debouncedList: T[];
compare: (a: T, b: T) => boolean;
sorter: (a: T, b: T) => number;
};
const composeTransitionListStatusReport = <T,>({
baseList,
debouncedList,
compare,
sorter,
}: ComposeTransitionListStatusReportInput<T>): TransitionEntityStatus<T>[] => {
const reportOnExistingItems: TransitionEntityStatus<T>[] = debouncedList.map(
(debouncedListEntity) => {
const entityMustExit = !baseList.some((baseListEntity) =>
compare(baseListEntity, debouncedListEntity),
);
if (entityMustExit) {
return { entity: debouncedListEntity, status: "exiting" };
}
return {
entity: debouncedListEntity,
status: "stable",
};
},
);
const newItems = baseList.filter((baseListEntity) => {
const alreadyExists = debouncedList.some((debouncedListEntity) =>
compare(baseListEntity, debouncedListEntity),
);
return !alreadyExists;
});
const reportOnNewItems: TransitionEntityStatus<T>[] = newItems.map(
(newItem) => ({
entity: newItem,
status: "entering",
}),
);
const finishedReport: TransitionEntityStatus<T>[] = [
...reportOnExistingItems,
...reportOnNewItems,
].sort((a, b) => sorter(a.entity, b.entity));
return finishedReport;
};
const useDebouncedValue = <T,>(value: T, delayMs: number): T => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timeout = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => {
clearTimeout(timeout);
};
}, [value, delayMs]);
return debouncedValue;
};
export const TransitionList = <ListEntity,>({
data,
transitionMsDuration,
render,
compare,
sorter,
}: TransitionListProps<ListEntity>) => {
const debouncedList = useDebouncedValue(data, transitionMsDuration);
const allItemsWithStatus = useMemo(
() =>
composeTransitionListStatusReport({
baseList: data,
debouncedList,
compare,
sorter,
}),
[data, debouncedList, compare, sorter],
);
return (
<>
{allItemsWithStatus.map(({ entity, status }) => render(entity, status))}
</>
);
};
/**
* Experimenting with better type inference for Joi schemas
*/
import { Joi } from 'express-validation';
import {AnySchema, BooleanSchema, NumberSchema, Schema, StringSchema, ObjectSchema } from 'joi'
type PrimitiveSchema = StringSchema | BooleanSchema | NumberSchema;
type InferPrimitiveSchema<T extends AnySchema> = T extends StringSchema ? string :
T extends BooleanSchema ? boolean :
T extends NumberSchema ? number :
never;
type InferJoiType<TheSchema extends AnySchema> = TheSchema extends PrimitiveSchema ? InferPrimitiveSchema<TheSchema> :
TheSchema extends Schema<infer T> ? T :
never;
type A = InferJoiType<ObjectSchema<{
firstName: string;
}>>;
type InferJoiFromFieldSchemas<FieldSchemas extends Record<string, AnySchema>> = {
[Key in keyof FieldSchemas]: InferJoiType<FieldSchemas[Key]>;
};
const betterJoiObject = <FieldSchemas extends Record<string, AnySchema>>(fields: FieldSchemas) => Joi.object<InferJoiFromFieldSchemas<FieldSchemas>>(fields);
const betterJoiArray = <ItemSchema extends AnySchema>(itemSchema: ItemSchema) => Joi.array().items(itemSchema) as AnySchema<InferJoiType<ItemSchema>[]>;
const wa = betterJoiObject({
firstName: Joi.string().required(),
ids: betterJoiArray(Joi.number()).required(),
opt: Joi.string().optional()
})
const testOutput = wa.validate({}).value!;
import { getContext, hasContext, setContext } from "svelte";
export const composeContextHelpers = <Data>(key: string) => ({
get: () => getContext<Data | undefined>(key), // returns undefined if the context has not been set
getEssential: () => {
// Throws an error if the context has not been set
if (!hasContext(key)) {
throw new Error(
`Tried to access essential context "${key}" but it has not been set.`,
);
}
return getContext<Data>(key);
},
set: (data: Data) => setContext(key, data),
has: () => hasContext(key),
});

Svelte React Equivalents

This document lists a few practices that are common in react and how to achieve effectively the same thing in svelte.

Authentication State Management

In react, for auth state we would typically use something like react query or another hook-based solution wherein the auth data is fetched initially, then we can get that information anywhere using a hook like useAuth

In svelte, specifically svelte-kit, this can be achieved with a server loader at the layout level:

// routes/+layout.server.ts

export const load = async () => {
	const auth = await getAuthData();

	return {
		auth,
	};
};
<!-- routes/any/path/+page.svelte -->
<script lang="ts">
	import type { PageProps } from './$types';

	let { data }: PageProps = $props();
</script>

<div>
	Sign In Status: {!!data.auth}
</div>

In the example server loader, the file is at the root layout level, however if you know only a specific set of routes will require authentication, then you should place the loader at the layout level of those routes.

To be able to access auth data easily from within components, you can pass that data into context in the +layout.svelte file and then retrieve from any context.

Create css_custom_data.json inside the .vscode directory, paste in the following:

{
	"version": 1.1,
	"atDirectives": [
		{
			"name": "@tailwind",
			"description": "Use the @tailwind directive to insert Tailwind's `base`, `components`, `utilities`, and `screens` styles into your CSS."
		},
		{
			"name": "@apply",
			"description": "Use @apply to inline any existing utility classes into your own custom CSS."
		}
	]
}

Now in .vscode/settings.json add this:

{
	"css.customData": [".vscode/css_custom_data.json"]
}

Tailwind Component Styling

See this: https://github.com/TClark1011/tailwind-variants-idea-test/.

Specifically look at the stuff in the components folder

TAILWIND V4

This was written for tailwind v3, however tailwind v4 replaces the theme() function with CSS variables, so you can just replace theme(colors.blue.500) with var(--color-blue-500).

The problem

When defining variants for components with tailwind you end up with alot of repeated extremely similar code

// "tv" is from the "tailwind-variants" library, which you should
// be using (or a similar equivalent eg; cva, )
const button = tv({
  base: '...',
  variants: {
    colorScheme: {
      red: 'bg-red-500 hover:bg-red-600 active:bg-red-700',
      blue: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700',
      green: 'bg-green-500 hover:bg-green-600 active:bg-green-700'
    }
  }
});

As we can see, all of the color scheme styles are extremely similar, they just apply the same set of classes for each different color. This becomes even worse when you multiple variants and color schemes.

Wouldn't it be great if we could define just the color to use in the colorScheme prop, and then just define how those colors are used in a single place?

There is a hack in tailwind we can use involving CSS variables and tailwind's arbitrary value syntax and theme functionality to make this possible.

Theme Function: The tailwind theme() CSS function is designed to let you access values from your tailwind config inside css files, like this:

body {
  background-color: theme(colors.gray.800);
}

However, we can actually also use it inside a tailwind arbitrary value, eg;

<button className="[background-color:theme(colors.blue.500)]">do something</button>

Normally this would be pointless since you can just use the standard tailwind classes, however where it gets interesting is when we use this method to create CSS variables:

<button className="[--main-color:theme(colors.blue.500)] bg-[var(--main-color)]"></button>

Putting It All Together

Now we can change our button variant styles to this:

const button = tv({
  base: '...',
  variants: {
    colorScheme: {
      blue: ['[--color-component-main:theme(colors.blue.500)]', '[--color-component-hover:theme(colors.blue.600)]', '[--color-component-active:theme(colors.blue.700)]'],
      red: ['[--color-component-main:theme(colors.red.500)]', '[--color-component-hover:theme(colors.red.600)]', '[--color-component-active:theme(colors.red.700)]'],
      green: ['[--color-component-main:theme(colors.green.500)]', '[--color-component-hover:theme(colors.green.600)]', '[--color-component-active:theme(colors.green.700)]']
    }
  }
});

This is a good start, however it's still a bunch of repeated code, right? Well yes, however, if you think about it, these color scheme variant styles would actually be the exact same for every component that has a color scheme right? So we can just extract it to a variable:

// The "cx" function is just a helper that tells VSCode to
// enable tailwind intellisense for the string
export const colorSchemeVariantSpread = {
  colorScheme: {
    blue: cx('[--color-component-main:theme(colors.blue.500)]', '[--color-component-hover:theme(colors.blue.600)]', '[--color-component-active:theme(colors.blue.700)]'),
    red: cx('[--color-component-main:theme(colors.red.500)]', '[--color-component-hover:theme(colors.red.600)]', '[--color-component-active:theme(colors.red.700)]'),
    green: cx('[--color-component-main:theme(colors.green.500)]', '[--color-component-hover:theme(colors.green.600)]', '[--color-component-active:theme(colors.green.700)]')
  }
};

Now when we define any component that has a color scheme, we can do this:

const slider = tv({
  base: '...',
  variants: {
    ...colorSchemeVariantSpread
  }
});

You can also make referencing those color variables much easier by adding a special component color to your tailwind config:

export default {
  // For this to work, you need to make sure your `.ts` files are being
  // checked for tailwind compilation
  content: ['./src/**/*.tsx', './src/**/*.ts'],
  theme: {
    extend: {
      colors: {
        component: {
          main: 'var(--color-component-main)',
          hover: 'var(--color-component-hover)',
          active: 'var(--color-component-active)'
        }
      }
    }
  },
  plugins: []
};

Example Usage

const button = tv({
  base: 'rounded-md px-4 py-2',
  variants: {
    ...colorSchemeVariantSpread,
    variant: {
      solid: ['bg-component-main', 'hover:bg-component-hover', 'active:bg-component-active'],
      outline: ['border-2 border-component-main text-component-main', 'hover:bg-component-main hover:text-component-hover', 'active:bg-component-hover active:text-component-active']
    }
  }
});

More Extreme Version

For a more extreme (and powerful) version of this, we can define CSS variables for each specific color shade, rather than just main/hover/active. This allows us to define things like borders, text colors, focus rings, etc all in terms of the color scheme.

export const colorSchemeVariantSpread = {
  colorScheme: {
    blue: cx(
      '[--color-component-50:theme(colors.blue.50)]',
      '[--color-component-100:theme(colors.blue.100)]',
      '[--color-component-200:theme(colors.blue.200)]',
      '[--color-component-300:theme(colors.blue.300)]',
      '[--color-component-400:theme(colors.blue.400)]',
      '[--color-component-500:theme(colors.blue.500)]',
      '[--color-component-600:theme(colors.blue.600)]',
      '[--color-component-700:theme(colors.blue.700)]',
      '[--color-component-800:theme(colors.blue.800)]',
      '[--color-component-900:theme(colors.blue.900)]'
    ),
    ...otherColors
  }
};

If you wanted, to get around the transparency limitation mentioned below, you could event define variables for transparent versions of each color shade too:

"[--color-component-50:theme(colors.blue.50)]"
"[--color-component-50-10:theme(colors.blue.50 / 0.1)]"
...

Tailwind V4: I haven't tested this, only read the docs, but in tailwind v4 you can set the transparency of a theme color with --alpha(), eg; --alpha(var(--color-gray-950) / 10%)

Limitations

  • Transparency: When using CSS variables for colors, it's not possible to use tailwind's color transparency utilities, eg bg-component-500/10. TO get around this, you can define extra CSS variables for transparent versions of each color shade, as mentioned above.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment