-
Notifications
You must be signed in to change notification settings - Fork 65
Description
Wayfinder Version
0.1.12
Laravel Version
12.0
PHP Version
8.4.7
Description
Hello 👋,
When using Laravel Wayfinder in a TypeScript project in strict mode (or using vue-tsc), the generated file
resources/js/wayfinder.ts contains several expressions that produce TypeScript errors.
These errors appear even when excluding the file, because the actions generated by Wayfinder import it via relative imports, which forces TypeScript to analyze it.
Here are examples of the errors:
TS2533: Object is possibly 'null' or 'undefined'.
TS2339: Property 'forEach' does not exist on type 'string | number | boolean | string[] | Record<string, ...>'.
TS7053: Element implicitly has an 'any' type because the expression of type 'string' cannot be used to index type ...
TS2407: Right-hand side of a 'for...in' statement must be of type 'any', an object, or a type parameter.
TS7006: Parameter 'value' implicitly has an 'any' type.
TS2345: Argument of type ... is not assignable to parameter of type 'string | number | boolean'.
These errors come from expressions like:
query[key]
query[key].forEach(...)
for (const subKey in query[key])
params.set(key, getValue(query[key]))
The root problem is:
QueryParams is a union type, and TypeScript cannot safely index a union without narrowing.
✔ Proposed fix for wayfinder.ts
A strictly typed version of the problematic parts (especially inside queryParams) can be implemented like this:
export type QueryParams = Record<
string,
| string
| number
| boolean
| string[]
| null
| undefined
| Record<string, string | number | boolean>
>;
type Method = "get" | "post" | "put" | "delete" | "patch" | "head" | "options";
let urlDefaults: Record<string, unknown> = {};
export type RouteDefinition<TMethod extends Method | Method[]> = {
url: string;
} & (TMethod extends Method[] ? { methods: TMethod } : { method: TMethod });
export type RouteFormDefinition<TMethod extends Method> = {
action: string;
method: TMethod;
};
export type RouteQueryOptions = {
query?: QueryParams;
mergeQuery?: QueryParams;
};
export const queryParams = (options?: RouteQueryOptions) => {
if (!options || (!options.query && !options.mergeQuery)) {
return "";
}
const query = options.query ?? options.mergeQuery;
const includeExisting = options.mergeQuery !== undefined;
const getValue = (value: string | number | boolean) => {
return value === true ? "1" : value === false ? "0" : value.toString();
};
const params = new URLSearchParams(
includeExisting && typeof window !== "undefined"
? window.location.search
: ""
);
for (const key in query) {
const val = query[key];
if (val === undefined || val === null) {
params.delete(key);
continue;
}
// ---- string[] ----
if (Array.isArray(val)) {
params.delete(`${key}[]`);
val.forEach((item) => params.append(`${key}[]`, item.toString()));
continue;
}
// ---- Record<string, primitive> ----
if (typeof val === "object") {
const record = val as Record<string, string | number | boolean>;
params.forEach((_, paramKey) => {
if (paramKey.startsWith(`${key}[`)) {
params.delete(paramKey);
}
});
for (const subKey in record) {
const subVal = record[subKey];
if (subVal !== undefined) {
params.set(`${key}[${subKey}]`, getValue(subVal));
}
}
continue;
}
// ---- primitive ----
params.set(key, getValue(val));
}
const str = params.toString();
return str.length > 0 ? `?${str}` : "";
};
export const setUrlDefaults = (params: Record<string, unknown>) => {
urlDefaults = params;
};
export const addUrlDefault = (
key: string,
value: string | number | boolean
) => {
urlDefaults[key] = value;
};
export const applyUrlDefaults = <T extends Record<string, unknown> | undefined>(
existing: T
): T => {
const existingParams = { ...(existing ?? ({} as Record<string, unknown>)) };
for (const key in urlDefaults) {
if (existingParams[key] === undefined && urlDefaults[key] !== undefined) {
existingParams[key] = urlDefaults[key];
}
}
return existingParams as T;
};
export const validateParameters = (
args: Record<string, unknown> | undefined,
optional: string[]
) => {
const missing = optional.filter((key) => !args?.[key]);
const expectedMissing = optional.slice(-missing.length);
for (let i = 0; i < missing.length; i++) {
if (missing[i] !== expectedMissing[i]) {
throw new Error(
"Unexpected optional parameters missing. Unable to generate a URL."
);
}
}
};
This version:
- safely narrows union types (string | number | boolean | string[] | object)
- prevents implicit any values
- avoids unsafe indexing
- fixes all TypeScript strict errors
- keeps the same behavior as today
✔ Why this should be fixed upstream
- The generated file is always imported via relative imports in generated actions
- Because of that, users cannot exclude the file from TypeScript checks
- TypeScript strict mode is now the default recommendation in modern frontend stacks
- This currently forces users to use workarounds (index.d.ts masking, wrappers, path hacks)
Fixing the file upstream removes the need for all these hacks.
Thanks again for the great work on Wayfinder!
If needed, I can open a PR with the corrected version.
✔ Fork with working fix
I created a fork including the corrected TypeScript-safe version here:
👉 https://github.com/DamienW-dev/wayfinder
If needed, I can also open a Pull Request with the changes.