TypeScript Helpers from Superforms

March 12, 2024 Updated: May 5, 2024

TypeScript Helpers from Superforms

Table of Contents

Superforms Types

The code in this post was written mostly by Andreas Söderlund, the creator of sveltekit-superforms, and is useable under the MIT license which must be included with all copies.

MIT License

Copyright (c) 2023 Andreas Söderlund

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

SuperStruct

The SuperStruct type is a utility type that transforms the properties of an object to an alternative provided type. It is used by sveltekit-superforms to transform the properties of a form schema to a type used for validation errors.

ts
export type AllKeys<T> = T extends T ? keyof T : never;

type PickType<T, K extends AllKeys<T>> = T extends { [k in K]: infer Item } ? Item : never;

// Thanks to https://dev.to/lucianbc/union-type-merging-in-typescript-9al
export type MergeUnion<T> = {
	[K in AllKeys<T>]: PickType<T, K>;
};

export type SuperStructArray<T extends Record<string, unknown>, Data, ArrayData = unknown> = {
	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	[Property in AllKeys<T>]?: [T] extends [any]
		? NonNullable<T[Property]> extends Record<string, unknown>
			? SuperStructArray<MergeUnion<NonNullable<T[Property]>>, Data, ArrayData>
			: NonNullable<T[Property]> extends (infer A)[]
				? ArrayData &
						Record<
							number | string,
							NonNullable<A> extends Record<string, unknown>
								? SuperStructArray<MergeUnion<NonNullable<A>>, Data, ArrayData>
								: Data
						>
				: Data
		: never;
};

export type SuperStruct<T extends Record<string, unknown>, Data> = Partial<{
	// eslint-disable-next-line @typescript-eslint/no-explicit-any
	[Property in AllKeys<T>]: [T] extends [any]
		? NonNullable<T[Property]> extends Record<string, unknown>
			? SuperStruct<MergeUnion<NonNullable<T[Property]>>, Data>
			: NonNullable<T[Property]> extends (infer A)[]
				? NonNullable<A> extends Record<string, unknown>
					? SuperStruct<MergeUnion<NonNullable<A>>, Data>
					: Data
				: Data
		: never;
}>;

Example

ts
type LogData = {
  id: number;
  message: string;
  date: string;
  user: {
    id: number;
    name: string;
  };
  tags: string[];
}

type LogErrors = SuperStruct<LogData, string[]>;

// Converts each property to string[] | undefined
// LogErrors["id"] => string[] | undefined

// Converts objects into a partial with each property as string[] | undefined
// LogErrors["user"] => Partial<{ id: string[], name: string[] }> | undefined
// Exclude<LogErrors["user"], undefined>["id"] => string[] | undefined

// Converts the tags array itself to string[] | undefined
// LogErrors["tags"] => string[] | undefined

type LogErrors = SuperStructArray<LogData, string[]>;

// Converts each property to string[] | undefined
// LogErrors["id"] => string[] | undefined

// Converts objects into an object with the same SuperStructArray type
// LogErrors["user"] => SuperStructArray<LogData["user"], string[]> | undefined
// Exclude<LogErrors["user"], undefined>["id"] => string[] | undefined

// Converts each value in the tags array to a string[] type
// LogErrors["tags"] => Record<number, string[]> | undefined

FormPathLeaves

The FormPathLeaves type is a utility type that takes an object type and returns a union of all the leaf paths of the object. It has an optional second parameter to specify the type (ex. Date or number) of paths to return. This is also used by sveltekit-superforms.

ts
// Thanks to https://stackoverflow.com/a/77451367/70894
type IsAny<T> = boolean extends (T extends never ? true : false) ? true : false;

export type AllKeys<T> = T extends T ? keyof T : never;

type PickType<T, K extends AllKeys<T>> = T extends { [k in K]: infer Item } ? Item : never;

// Thanks to https://dev.to/lucianbc/union-type-merging-in-typescript-9al
export type MergeUnion<T> = {
	[K in AllKeys<T>]: PickType<T, K>;
};

type DictOrArray = Record<PropertyKey, unknown> | unknown[];

/**
 * Lists all paths in an object as string accessors.
 */
export type FormPath<T extends DictOrArray, Type = any> = string &
	StringPath<T, { filter: "all"; objAppend: never; path: ""; type: Type }>;

/**
 * List paths in an object as string accessors, but only with non-objects as accessible properties.
 * Similar to the leaves in a node tree, if you look at the object as a tree structure.
 */
export type FormPathLeaves<T extends DictOrArray, Type = any> = string &
	StringPath<T, { filter: "leaves"; objAppend: never; path: ""; type: Type }>;

/**
 * List paths in an object as string accessors, but only with non-objects as accessible properties.
 * Also includes the _errors field for objects and arrays.
 */
export type FormPathLeavesWithErrors<T extends DictOrArray, Type = any> = string &
	StringPath<T, { filter: "leaves"; objAppend: "_errors"; path: ""; type: Type }>;

/**
 * List all arrays in an object as string accessors.
 */
export type FormPathArrays<T extends object, Type = any> = string &
	StringPath<
		T,
		{
			filter: 'arrays';
			objAppend: never;
			path: '';
			type: Type extends any[] ? Type : Type[];
		}
	>;

type Concat<Path extends string, Next extends string> = `${Path}${Path extends "" ? "" : "."}${Next}`;

type StringPathOptions = {
	filter: "arrays" | "leaves" | "all";
	objAppend: string | never;
	path: string;
	type: any;
};

type If<
	Options extends StringPathOptions,
	Pred extends keyof Options,
	Subj,
	Then,
	Else,
	Value
> = Options[Pred] extends Subj
	? Options["type"] extends never
		? Then
		: Value extends Options["type"]
			? Then
			: never
	: Else;

type StringPath<
	T extends DictOrArray,
	Options extends StringPathOptions = {
		filter: "all";
		objAppend: never;
		path: "";
		type: never;
	}
	// If T is an array, infer U
> = T extends (infer U)[]
	? // If objAppend is a string, return the path with objAppend appended, or never
			| If<Options, "objAppend", string, Concat<Options["path"], Options["objAppend"]>, never, T>
			// If filter is "arrays" or "all", return the path as is, or never
			| If<Options, "filter", "arrays" | "all", Options["path"], never, T>
			// If U is a record or array
			| (NonNullable<U> extends DictOrArray
					? // Recursively call StringPath on U
						StringPath<
							NonNullable<U>,
							{
								filter: Options["filter"];
								objAppend: Options["objAppend"];
								path: `${Options["path"]}[${number}]`;
								type: Options["type"];
							}
						>
					: // Otherwise, if the filter is "leaves" or "all", return the path with the array 
						// index appended, or never
						If<Options, "filter", "leaves" | "all", `${Options["path"]}[${number}]`, never, T>)
	: // Otherwise, T is a record. Iterate over the keys of T
		{
			// If T[K] is a record or array
			[K in Extract<AllKeys<T>, string>]: NonNullable<T[K]> extends DictOrArray
				? // If objAppend is a string, return the path with objAppend appended, or never
						| If<Options, "objAppend", string, Concat<Options["path"], Options["objAppend"]>, never, T[K]>
						// If T[K] is an array, infer U
						| NonNullable<T[K]> extends (infer U)[]
					? // If filter is "arrays" or "all", return the path with the key appended, or never
							| If<Options, "filter", "arrays" | "all", Concat<Options["path"], K>, never, T[K]>
							// If U, the array element, is also an array
							| (NonNullable<U> extends unknown[]
									? // If filter is "arrays" or "all", return the path with the key and array index appended, or never
										If<Options, "filter", "arrays" | "all", Concat<Options["path"], `${K}[${number}]`>, never, T[K]>
									: // Otherwise, if U is a record
										NonNullable<U> extends DictOrArray
										? // If T[K], which is an array of records, is any
											IsAny<T[K]> extends true
											? // Return the path with the key and index appended
												Concat<Options["path"], `${K}[${number}]`>
											: // If filter is "all", return the path with the key and index appended, or never
												If<Options, "filter", "all", Concat<Options["path"], `${K}[${number}]`>, never, U>
										: // Otherwise, if U is not a record or array, and filter is "leaves" or "all",
											// return the path with the key and index appended, or never
											If<Options, "filter", "leaves" | "all", Concat<Options["path"], `${K}[${number}]`>, never, U>)
							// If U is a record or array
							| (NonNullable<U> extends DictOrArray
									? // Recursively call StringPath on U
										StringPath<
											NonNullable<U>,
											{
												filter: Options["filter"];
												objAppend: Options["objAppend"];
												path: Concat<Options["path"], `${K}[${number}]`>;
												type: Options["type"];
											}
										>
									: never)
					: // If T[K] is any
						IsAny<T[K]> extends true
						? // Append the key to the path
							Concat<Options["path"], K>
						: // Otherwise, if filter is "all", return the path with the key appended, or never
								| If<
										Options,
										"filter",
										"all",
										Concat<Options["path"], K>,
										unknown extends T[K] ? Concat<Options["path"], K> : never,
										T[K]
								  >
								// And recursively call StringPath on T[K]
								| StringPath<
										NonNullable<T[K]>,
										{
											filter: Options["filter"];
											objAppend: Options["objAppend"];
											path: Concat<Options["path"], K>;
											type: Options["type"];
										}
								  >
				: // Otherwise, if T[K] is not a record or array, and filter is "leaves" or "all",
					// return the path with the key appended, or never
					If<Options, "filter", "leaves" | "all", Concat<Options["path"], K>, never, T[K]>;
			// Extract the values of the keys in the object
		}[Extract<AllKeys<T>, string>];

export type FormPathType<T, P extends string> = P extends keyof T
	? T[P]
	: P extends number
		? T
		: P extends `.${infer Rest}`
			? FormPathType<NonNullable<T>, Rest>
			: P extends `${number}]${infer Rest}`
				? NonNullable<T> extends (infer U)[]
					? FormPathType<U, Rest>
					: { invalid_path1: P; Type: T }
				: P extends `${infer K}[${infer Rest}`
					? K extends keyof NonNullable<T>
						? FormPathType<NonNullable<T>[K], Rest>
						: FormPathType<T, Rest>
					: P extends `${infer K}.${infer Rest}`
						? K extends keyof NonNullable<T>
							? FormPathType<NonNullable<T>[K], Rest>
							: NonNullable<T> extends (infer U)[]
								? FormPathType<U, Rest>
								: { invalid_path2: P; Type: T }
						: P extends `[${infer K}].${infer Rest}`
							? K extends number
								? T extends (infer U)[]
									? FormPathType<U, Rest>
									: { invalid_path3: P; Type: T }
								: P extends `${number}`
									? NonNullable<T> extends (infer U)[]
										? U
										: { invalid_path4: P; Type: T }
									: P extends keyof NonNullable<T>
										? NonNullable<T>[P]
										: P extends `${number}`
											? NonNullable<T> extends (infer U)[]
												? U
												: { invalid_path5: P; Type: T }
											: { invalid_path6: P; Type: T }
							: P extends ""
								? T
								: P extends AllKeys<T>
									? MergeUnion<T>[P]
									: { invalid_path7: P; Type: T };

Example

Given the following object type, we can see the different types of paths that can be generated. Even branded types are supported.

ts
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
export type Branded<T, B> = T & Brand<B>;
export type PostId = Branded<string, "PostId">;
export type UserId = Branded<string, "UserId">;

type PostData = {
  id: PostId;
  date: Date;
  message: string;
	tags: string[];
  user: {
    id: UserId;
    name: string;
		age: number;
  };
}

type PostLeaves = FormPathLeaves<PostData>;
//   ^? "id" | "message" | "date" | "user.id" | "user.name" | "user.age" | `tags[${number}]`
type PostStringLeaves = FormPathLeaves<PostData, string>;
//   ^? "id" | "message" | "user.id" | "user.name" | `tags[${number}]`
type PostNumberLeaves = FormPathLeaves<PostData, number>;
//   ^? "user.age"
type PostDateLeaves = FormPathLeaves<PostData, Date>;
//   ^? "date"
type LogBrandedLeaves = FormPathLeaves<LogData, Branded<unknown, unknown>>;
//   ^? "id" | "user.id"

Examples

These functions are used to get and set nested values in an object. They are not from the Superforms library, but rather showcases how some of the above types can be used. They work very similarly to lodash.get and lodash.set, but with stronger type safety. See the playground for examples.

These example functions were wrtten by me.

Get

In this get function, the path is a string of dot-separated keys, or brackets for numbers. The function should get the value at the given path. The path parameter will have autocomplete and the value parameter will be required to be of the same type as the property at that path.

ts
export function get<
	T extends Record<string | number, unknown>,
	P extends FormPathLeaves<T>,
	V extends FormPathType<T, P>
>(obj: T, path: P, defValue?: V) {
	// Split the path into an array of keys and convert any numeric strings to numbers.
	const pList = path
		.split(/[[\].]+/)
		.filter((i) => typeof i !== "undefined" && i !== "")
		.map((i) => (isNaN(Number(i)) ? i : Number(i)));

	// Remove the last key from the array and store it in a variable.
	const lastKey = pList.pop();
	if (lastKey === undefined) return undefined;

	// Create a pointer object that traverses the nested properties of the `obj` object based on the `path` provided.
	// If a property does not exist, it will be created as an empty object.
	const pointer = pList.reduce((accumulator: Record<string | number, unknown>, currentValue: string | number) => {
		if (accumulator[currentValue] === undefined) accumulator[currentValue] = {};
		return accumulator[currentValue] as Record<string | number, unknown>;
	}, obj);

	// Get the value of the last key in the pointer object, or return the default value if it doesn't exist.
	if (!(lastKey in pointer)) return defValue
	return pointer[lastKey] as FormPathType<T, P>;
}

Set

In this set function, the path is a string of dot-separated keys, or brackets for numbers. The function should set the value at the given path. The path parameter will have autocomplete and the value parameter will be required to be of the same type as the property at that path.

ts
export function set<T extends Record<string | number, unknown>, P extends FormPathLeaves<T>, V extends FormPathType<T, P>>(
	obj: T,
	path: P,
	value: V
) {
	// Split the path into an array of keys and convert any numeric strings to numbers.
	const pList = path
		.split(/[[\].]+/)
		.filter((i) => typeof i !== "undefined" && i !== "")
		.map((i) => (isNaN(Number(i)) ? i : Number(i)));
		
	// Remove the last key from the array and store it in a variable.
	const lastKey = pList.pop();
	if (!lastKey) throw new Error("Invalid path.");

	// Create a pointer object that traverses the nested properties of the `obj` object based on the `path` provided.
	// If a property does not exist, it will be created as an empty object.
	const pointer = pList.reduce((accumulator: Record<string | number, unknown>, currentValue: string | number) => {
		if (accumulator[currentValue] === undefined) accumulator[currentValue] = {};
		return accumulator[currentValue] as Record<string | number, unknown>;
	}, obj);

	if (!(lastKey in pointer)) throw new Error("Invalid path. This is likely due to a missing array index.");
	if (pointer[lastKey] && typeof pointer[lastKey] !== typeof value)
		throw new Error(`Invalid value type. Expected ${typeof pointer[lastKey]} but got ${typeof value}.`);

	// Set the value of the last key in the pointer object.
	pointer[lastKey] = value;
	return obj;
}

Example

Using the object from the previous example, we can set the value of the date property to a Date object, and the value of the tags[0] property to a string. We can also see that trying to set the date property to a string will result in a type error.

ts
setValue(logData, "date", new Date("2023-02-01"));
setValue(logData, "tags[0]", "test");
setValue(logData, "date", "2024-01-01"); // expected Date, received string