TS,函数有两个参数,根据第一个参数约束第二个参数,并且推断出最终的结果?

TS,函数有两个参数,根据第一个参数约束第二个参数,并且推断出最终的结果。

比如我需要一个合并path和参数的函数,根据path来约束所传的参数,最终拼接path和params得出最终的string,比如:

type Path2Params = {
  '/order/detail': { orderId: string };
  '/product/list': { type: string; pageSize: string; pageNo: string };
};
const orderParams: Path2Params['/order/detail'] = { orderId: '123' };
const productListParams: Path2Params['/product/list'] = { type: 'electronics', pageSize: '10', pageNo: '1' };

通过函数能推断出orderUrl为/order/detail?orderId=123,productListUrl为/product/list?type=electronics&pageSize=10&pageNo=1

这是我自己的实现,但是有一些问题:

type Path2Params = {
  '/order/detail': { orderId: string };
  '/product/list': { type: string; pageSize: string; pageNo: string };
};

type BuildQueryString<TParams extends Record<string, string>> = {
  [K in keyof TParams]: `${Extract<K, string>}=${TParams[K]}`;
}[keyof TParams];

type FullUrl<
  TPath extends string,
  TParams extends Record<string, string>,
> = `${TPath}${TParams extends Record<string, never> ? '' : '?'}${BuildQueryString<TParams>}`;

/**
 * 构建一个带有查询参数的URL字符串。
 * @param path 路径参数,必须是`Path2Params`类型的键。
 * @param params 查询参数,根据路径参数`path`在`Path2Params`中定义的类型。
 * @returns 返回一个完整的URL字符串,包括路径和编码后的查询参数。
 * @template TPath `Path2Params`中的键类型,用于确保类型安全。
 * @template TParams `Path2Params`中由`TPath`指定的键对应的值的类型,用于确保类型安全。
 */
function buildStringWithParams<TPath extends keyof Path2Params, TParams extends Path2Params[TPath]>(
  path: TPath,
  params: TParams,
): FullUrl<TPath, TParams> {
  // 优化:使用数组和join来减少字符串操作的开销
  const encodedParams = Object.entries(params).map(([key, value]) => {
    // 安全性优化:确保value经过适当的编码
    const encodedValue = encodeURIComponent(value);
    // 类型安全优化:不再需要as string,因为value已经被编码为字符串
    return `${encodeURIComponent(key)}=${encodedValue}`;
  });
  const queryString = encodedParams.join('&');
  return `${path}${queryString ? '?' : ''}${queryString}` as FullUrl<TPath, TParams>;
}

const orderParams: Path2Params['/order/detail'] = { orderId: '123' };
const productListParams: Path2Params['/product/list'] = { type: 'electronics', pageSize: '10', pageNo: '1' };

const orderUrl = buildStringWithParams('/order/detail', orderParams);
const productListUrl = buildStringWithParams('/product/list', productListParams);


orderUrl被推断成为/order/detail?orderId=${string},productListUrl被推断成为联合类型了,而我希望函数能直接正确推断出来结果,所以怎么改?

buildStringWithParams函数能够不在运行的情况下就能推断出正确的结果
orderUrl为/order/detail?orderId=123
productListUrl为/product/list?type=electronics&pageSize=10&pageNo=1

阅读 829
avatarAI BotBETA

在TypeScript中,要实现在编译时就能完全推断出URL字符串的完整形式,你需要确保类型系统能够处理这种动态拼接的场景。然而,由于TypeScript的类型系统主要是静态的,它不能直接处理运行时数据的具体值。不过,我们可以通过一些技巧和泛型约束来尽可能地模拟这种行为。

在你的例子中,主要问题出现在FullUrl类型推断上,因为BuildQueryString<TParams>生成的是一个字符串的联合类型,而不是具体的字符串字面量。要解决这个问题,我们可以使用模板字面量类型(Template Literal Types),这是TypeScript 4.1及以上版本引入的功能。

下面是修改后的代码,使用模板字面量类型来更精确地表示URL的查询字符串部分:

type Path2Params = {
  '/order/detail': { orderId: string };
  '/product/list': { type: string; pageSize: string; pageNo: string };
};

type QueryParam = [key: string, value: string];

// 使用模板字面量类型构建查询字符串
type BuildQueryString<TParams extends Record<string, string>> = {
  [K in keyof TParams as `${string & K}=${TParams[K]}`]: '';
}[keyof TParams];

// 合并路径和查询字符串
type FullUrl<TPath extends keyof Path2Params, TParams extends Path2Params[TPath]> = 
  `${TPath}${'' extends BuildQueryString<TParams> ? '' : '?'}${BuildQueryString<TParams>}`;

function buildStringWithParams<TPath extends keyof Path2Params, TParams extends Path2Params[TPath]>(
  path: TPath,
  params: TParams
): FullUrl<TPath, TParams> {
  const encodedParams = Object.entries(params).map(([key, value]) => 
    `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
  );
  const queryString = encodedParams.join('&');
  return `${path}${queryString ? '?' : ''}${queryString}` as FullUrl<TPath, TParams>;
}

const orderParams: Path2Params['/order/detail'] = { orderId: '123' };
const productListParams: Path2Params['/product/list'] = { type: 'electronics', pageSize: '10', pageNo: '1' };

const orderUrl = buildStringWithParams('/order/detail', orderParams);
const productListUrl = buildStringWithParams('/product/list', productListParams);

// 在IDE中查看类型,应该能得到具体的URL字符串类型
// orderUrl: "/order/detail?orderId=123"
// productListUrl: "/product/list?type=electronics&pageSize=10&pageNo=1"

注意,这里我们使用了[K in keyof TParams as ${string & K}=${TParams[K]}]来映射键和值到一个新的字符串类型,这样BuildQueryString就能生成一个包含所有查询参数的字符串字面量类型的联合。然而,由于TypeScript的类型推断限制,它可能不会在IDE中直接显示为完全展开的字符串,但在大多数情况下,这足以让TypeScript的类型检查器在编译时捕捉到错误。

另外,请注意,虽然这种方法在类型层面上尽可能接近你的需求,但TypeScript的类型系统仍然是静态的,它不能保证在运行时生成的字符串与类型定义完全一致(例如,如果查询参数的值包含特殊字符或未正确编码)。因此,你仍然需要在运行时进行必要的检查和编码。

3 个回答
✓ 已被采纳
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void
  ? I
  : never;

type UnionToTuple<U> =
  UnionToIntersection<U extends any ? () => U : never> extends () => infer R
    ? [...UnionToTuple<Exclude<U, R>>, R]
    : [];

type JoinWithAmpersand<T extends any[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[],
]
  ? Rest extends []
    ? First
    : `${First}&${JoinWithAmpersand<Rest>}`
  : "";

type Path2Params = {
  "/order/detail": { orderId: string };
  "/product/list": { type: string; pageSize: string; pageNo: string };
};

type BuildQueryString<TParams extends Record<string, string>> = {
  [K in keyof TParams]: `${Extract<K, string>}=${TParams[K]}`;
}[keyof TParams];


type FinalBuildQueryString<T extends Record<string, string>> = JoinWithAmpersand<
  UnionToTuple<BuildQueryString<T>>
>;


type FullUrl<
  TPath extends string,
  TParams extends Record<string, string>,
> = `${TPath}${TParams extends Record<string, string> ? `?${FinalBuildQueryString<TParams>}` : ""}`;
// ......
const orderUrl = buildStringWithParams("/order/detail", orderParams);
const productListUrl = buildStringWithParams("/product/list", productListParams);

首先需要一些 TS 工具类型:

// UnionToIntersection<{ a: 1 } | { b: 2 }> => { a: 1 } & { b: 2 }
type UnionToIntersection<U> = (
    U extends unknown ? (k: U) => void : never
) extends (k: infer I) => void
    ? I
    : never

// LastOfUnion<1 | 2 | 3> => 3
type LastOfUnion<T> = UnionToIntersection<
    T extends unknown ? () => T : never
> extends () => infer R
    ? R
    : never

//! 注意:顺序不一致
// UnionToTuple<1 | 2 | 3> => [2, 1, 3]
type UnionToTuple<
    T,
    L = LastOfUnion<T>,
    N = [T] extends [never] ? true : false
> = true extends N ? [] : Push<UnionToTuple<Exclude<T, L>>, L>

type Push<T extends unknown[], V> = [...T, V]

type Join<T, Separator extends string = ','> = T extends [
    infer F extends string,
    ...infer R extends string[]
]
    ? R extends []
        ? `${F}`
        : `${F}${Separator}${Join<R, Separator>}`
    : never

// JoinUnion<'1' | '2' | '3'> => '1,2,3'
type JoinUnion<T, Separator extends string = ','> = Join<
    UnionToTuple<T>,
    Separator
>

然后就可以开始了:

enum Path {
    order_detail = '/order/detail',
    product_list = '/product/list'
}

type ParamsMap = {
    [K in Path]: {
        [Path.order_detail]: {
            orderId: string
        }
        [Path.product_list]: {
            type: string
            pageSize: string
            pageNo: string
        }
    }[K]
}

type BuildQuery<TParams> = JoinUnion<
    {
        [K in keyof TParams]: `${Extract<K, string>}=${Extract<TParams[K], string>}`
    }[keyof TParams],
    '&'
>

declare function buildURL<
    TPath extends Path,
    TParams extends ParamsMap[TPath],
    TQuery extends BuildQuery<TParams>
>(
    path: TPath,
    params: TParams
): `${TPath}${TQuery extends '' ? '' : `?${TQuery}`}`

// 注意需要使用 <const> 才能正确推断
// a: "/product/list?type=t1&pageSize=2&pageNo=1"
const a = buildURL(Path.product_list, <const>{
    type: 't1',
    pageSize: '2',
    pageNo: '1'
})
仅供学习使用,不建议在生产中这样做

参考:

interface Path2Params {
  '/order/detail': { orderId: string }
  '/product/list': { type: string, pageSize: string, pageNo: string }
}

type BuildQueryString<TParams extends Record<string, string>> = {
  [K in keyof TParams]: `${Extract<K, string>}=${TParams[K]}`;
}[keyof TParams];

// ---
type UnionToIntersection<T> = (T extends any ? (args: T) => any : never) extends (args: infer R) => any ? R : never;

type LastInUnion<T> = UnionToIntersection<
  (T extends any ? (arg: T) => any : never)
> extends (arg: infer R) => any ? R : never;

type UnionToTuple<T, U = T> = [T] extends [never] ? [] : [LastInUnion<T>, ...UnionToTuple<Exclude<U, LastInUnion<T>>>];

// 连接元组中的字符串,最后一个元素不添加 '&'
type Join<T extends string[]> =
  T extends []
    ? ''
    : T extends [infer F]
      ? F extends string
        ? `${F}`
        : never
      : T extends [infer F, ...infer R]
        ? F extends string
          ? R extends string[]
            ? `${F}&${Join<R>}`
            : never
          : never
        : never;

type FullUrl<
  TPath extends string,
  TParams extends Record<string, string>,
> = `${TPath}${TParams extends Record<string, never> ? '' : '?'}${
  Join<
    UnionToTuple<
      BuildQueryString<
        TParams
      >
    > extends string[] ? UnionToTuple<
        BuildQueryString<
          TParams
        >
      > : never
  >
}`;

// ---

/**
 * 构建一个带有查询参数的URL字符串。
 * @param path 路径参数,必须是`Path2Params`类型的键。
 * @param params 查询参数,根据路径参数`path`在`Path2Params`中定义的类型。
 * @returns 返回一个完整的URL字符串,包括路径和编码后的查询参数。
 * @template TPath `Path2Params`中的键类型,用于确保类型安全。
 * @template TParams `Path2Params`中由`TPath`指定的键对应的值的类型,用于确保类型安全。
 */
function buildStringWithParams<TPath extends keyof Path2Params, TParams extends Path2Params[TPath]>(
  path: TPath,
  params: TParams,
): FullUrl<TPath, TParams> {
  // 优化:使用数组和join来减少字符串操作的开销
  const encodedParams = Object.entries(params).map(([key, value]) => {
    // 安全性优化:确保value经过适当的编码
    const encodedValue = encodeURIComponent(value);
    // 类型安全优化:不再需要as string,因为value已经被编码为字符串
    return `${encodeURIComponent(key)}=${encodedValue}`;
  });
  const queryString = encodedParams.join('&');
  return `${path}${queryString
    ? '?'
    : ''}${queryString}` as FullUrl<TPath, TParams>;
}

// ↓ ↓ ↓ `as const`
const orderParams = { orderId: '123' } as const satisfies Path2Params['/order/detail'];
const productListParams = { type: 'electronics', pageSize: '10', pageNo: '1' } as const satisfies Path2Params['/product/list'];

const orderUrl = buildStringWithParams('/order/detail', orderParams);
const productListUrl = buildStringWithParams('/product/list', productListParams);

image.png

推荐问题
logo
Microsoft
子站问答
访问
宣传栏