TypeScript-DOM-lib-generator
TypeScript-DOM-lib-generator copied to clipboard
Add support for generics in the URLSearchParams interface
Suggestion
🔍 Search Terms
urlsearchparams
✅ Viability Checklist
My suggestion meets these guidelines:
- [x] This wouldn't be a breaking change in existing TypeScript/JavaScript code
- [x] This wouldn't change the runtime behavior of existing JavaScript code
- [x] This could be implemented without emitting different JS based on the types of the expressions
- [x] This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, new syntax sugar for JS, etc.)
- [x] This feature would agree with the rest of TypeScript's Design Goals.
⭐ Suggestion
I'd like to add support for generics in the URLSearchParams
interface which looks like this as of version 4.4.4 (the relevant part).
interface URLSearchParams {
/**
* Appends a specified key/value pair as a new search parameter.
*/
append(name: string, value: string): void;
/**
* Deletes the given search parameter, and its associated value, from the list of all search parameters.
*/
delete(name: string): void;
/**
* Returns the first value associated to the given search parameter.
*/
get(name: string): string | null;
/**
* Returns all the values association with a given search parameter.
*/
getAll(name: string): string[];
/**
* Returns a Boolean indicating if such a search parameter exists.
*/
has(name: string): boolean;
/**
* Sets the value associated to a given search parameter to the given value. If there were several values, delete the others.
*/
set(name: string, value: string): void;
sort(): void;
/**
* Returns a string containing a query string suitable for use in a URL. Does not include the question mark.
*/
toString(): string;
forEach(callbackfn: (value: string, key: string, parent: URLSearchParams) => void, thisArg?: any): void;
}
📃 Motivating Example
In many applications, URL search parameters are used to encode state so that the users can bookmark, share, and later revisit the important websites without losing page context. An example scenario might be a car configurator tool with many presets or a Storybook library with the controls addon.
The vast majority of web applications utilising URL search parameters for such purposes has a very narrow set of supported config keys. However, the current TypeScript interface does not allow us to restrict permitted keys without overriding the whole interface. Ideally, we could define the interface like this.
interface URLSearchParams<SupportedKeys extends string = string> {
/**
* Appends a specified key/value pair as a new search parameter.
*/
append(name: SupportedKeys, value: string): void;
/**
* Deletes the given search parameter, and its associated value, from the list of all search parameters.
*/
delete(name: SupportedKeys): void;
/**
* Returns the first value associated to the given search parameter.
*/
get(name: SupportedKeys): string | null;
/**
* Returns all the values association with a given search parameter.
*/
getAll(name: SupportedKeys): string[];
/**
* Returns a Boolean indicating if such a search parameter exists.
*/
has(name: SupportedKeys): boolean;
/**
* Sets the value associated to a given search parameter to the given value. If there were several values, delete the others.
*/
set(name: SupportedKeys, value: string): void;
sort(): void;
/**
* Returns a string containing a query string suitable for use in a URL. Does not include the question mark.
*/
toString(): string;
forEach(callbackfn: (value: string, key: SupportedKeys, parent: URLSearchParams) => void, thisArg?: any): void;
}
💻 Use Cases
This modified interface would allow us to create wrapper functions for working with search parameters in a type-safe way.
type SupportedKeys = 'foo' | 'bar';
const [searchParams, setSearchParams] = useSearchParams<SupportedKeys>();
// Before:
// const searchParams: URLSearchParams
// After:
// const searchParams: URLSearchParams<SupportedKeys>
console.log(searchParams.get('foo'); // ok
console.log(searchParams.get('baz'); // error!
@MartinJohns why do you disagree with this proposal?
Same reason I'm against making JSON.parse
generic. It doesn't add type-safety, but instead obfuscates and hides potential issues. Especially users not familiar with TypeScript could easily assume that using the generic type means no other parameters could be present, when this is not the case.
@MartinJohns I see. Do you have a different solution in mind for adding type safety to these APIs?
It's reasonable in a given project to want to have a list of possible query string to document which keys are going to be used, and prevent typos or misuses. Currently, we have to create a little boilerplate on the side to achieve this. The proposal in this ticket would make seems simpler.
URLSearchParams
is a container of generic things. Generics is the right abstraction to use here.
To address @MartinJohns's concerns, I think this proposal addresses the issue by having the default by string
, thus less expert users won't see a difference with today's behavior, while more advanced users have the option of more control over the possible keys to secure/document things.
I'd find this very handy developing in Remix, where I could be accessing the URL search params either client side in my route component, or server side in my action and loader functions. Given I might reference the same search params in 3 different places within the same file, I want to define from the outset the params I intend to use. This helps by:
- being explicit about what I'm expecting
- making those expectations more visible
- preventing typos, copy-paste errors, etc
@lwouis do you have some boilerplate you'd be happy to share as a workaround? I've got the following but I don't love the extent of the original interface I'm having to redefine.
type TypedURLSearchParams<T extends string> = Omit<
URLSearchParams,
'append' | 'delete' | 'forEach' | 'get' | 'getAll' | 'has' | 'set'
> & {
append(name: T, value: string): void;
delete(name: T, value?: string): void;
forEach(
callbackfn: (
value: string,
key: T,
parent: TypedURLSearchParams<T>,
) => void,
thisArg?: any,
): void;
get(name: T): string | null;
getAll(name: T): string[];
has(name: T, value?: string): boolean;
set(name: T, value: string): void;
};
function getTypedURLSearchParams<T extends string>(urlString: string) {
const url = new URL(urlString);
return url.searchParams as TypedURLSearchParams<T>;
}
type validSearchParams = 'userId' | 'noteId';
const getURLSearchParams = getTypedURLSearchParams<validSearchParams>;
const urlSearchParams = getURLSearchParams(myURL);
const userId = urlSearchParams.get('userId'); // safe
const noteId = urlSearchParams.get('noteId'); // safe
const foo = urlSearchParams.get('foo'); // error
This might help someone for setting queryParams with types
'use client';
import type { ReadonlyURLSearchParams } from 'next/navigation';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
type TypedURLSearchParams<T extends Record<string, unknown>> = Omit<
ReadonlyURLSearchParams,
'append' | 'delete' | 'forEach' | 'get' | 'getAll' | 'has' | 'set'
> & {
append: <K extends keyof T>(name: K, value: T[K]) => void;
delete: <K extends keyof T>(name: K, value?: T[K]) => void;
forEach: (
callbackfn: (value: string, key: keyof T, parent: TypedURLSearchParams<T>) => void,
thisArg?: unknown
) => void;
get: <K extends keyof T>(name: K) => T[K] | null;
getAll: <K extends keyof T>(name: K) => T[K][];
has: <K extends keyof T>(name: K, value?: T[K]) => boolean;
set: <K extends keyof T>(name: K, value: T[K]) => void;
};
function useQueryParams<T>() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams() as unknown as TypedURLSearchParams<Partial<T>>;
const urlSearchParams = new URLSearchParams(searchParams.toString());
function setQueryParams(params: Partial<T>) {
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) {
urlSearchParams.delete(key);
} else {
urlSearchParams.set(key, String(value));
}
});
const search = urlSearchParams.toString();
const query = search ? `?${search}` : '';
// replace since we don't want to build a history
router.replace(`${pathname}${query}`);
}
return { queryParams: searchParams, setQueryParams };
}
export { useQueryParams };
const { queryParams, setQueryParams } = useQueryParams<{
id: "1" | "2"
}>();
const value = queryParams.get('id'); // this will return type of "1" | "2"