Search code examples
typescripttypescript-generics

Typescript generic type read only in part of the type declaration


I have a sanitizer that may or may not receive a set of possible values. If it receives, it should enforce those to be the only possible values, if not, it should just check if it is string type, and if any of the checks failed, I want to return defaultValue. My code so far is the following:

function validateEnum<T extends string>(
    value: string,
    set: Set<T>
): value is T {
    return set.has(value as T);
}

function sanitize<S extends string = never>(value: any, defaultValue: S extends never ? string : S, set?: Set<S>): S extends never ? string : S {
    if (typeof value !== "string") {
        return defaultValue;
    }
    if (set && !validateEnum(value, set)) {
        return defaultValue;
    }
    return value; // This line throws an error "Type 'string' is not assignable to type 'S extends never ? string : S'"
}

const a = sanitize("abc", "a", new Set(["a", "b", "c"])); // should resolve to "a" | "b" | "c", this is correct
const b = sanitize("abc", "b", new Set(["c"])); // should resolve to just "c", and "b" as defaultValue should throw a typescript error, it is now resolving to "b" | "c"
const c = sanitize("abc", "c"); // should resolve to generic string, it is now resolving to "c"

Typescript understands that whatever is being passed down to defaultValue should be appended to the generic S. How can I make S type read only for defaultValue, and how can that return value be correctly assigned?


Solution

  • I'd say that you should write sanitize() like this:

    function sanitize<T extends S, S extends string = string>(value: any,
      defaultValue: T, set?: Set<S>): S {
      if (typeof value !== "string") {
        return defaultValue;
      }
      if (set && !validateEnum(value, set)) {
        return defaultValue;
      } else {
        return value as S;
      }
    }
    

    The call signature is

    <T extends S, S extends string = string>(
      value: any, 
      defaultValue: T, 
      set?: Set<S>
    ) => S
    

    That's generic in two type parameters; the type S of the elements of set, which is constrained to string, and then the type T of defaultValue, which is constrained to S. Since T is constrained to S, it means that defaultValue's type must be assignable to the element type of set. This is, I think, what you meant by making S "read-only". Really it's that S should be inferred from set only, and then defaultValue should be checked against S without affecting it:

    const a = sanitize("abc", "a", new Set(["a", "b", "c"]));
    //    ^? const a: "a" | "b" | "c"
    const b = sanitize("abc", "b", new Set(["c"])); // error!
    //                        ~~~
    // Argument of type '"b"' is not assignable to parameter of type '"c"'.
    

    Also note that set is optional. If you call the function without passing in an argument for set, then there won't be anywhere from which to infer S. And so the inference will fail, and S will fall back to the default of string:

    const c = sanitize("abc", "c");
    //    ^? const c: string
    

    Note that inside the function I needed to use a type assertion to claim that if set is undefined, it implies that value is of type S (value as S). Technically that's not guaranteed by the call signature to sanitize, because a caller can decide to manually specify the T and S type arguments:

    const d = sanitize<"c", "c">("abc", "c");
    //    ^? const f: "c", oops!
    

    That's not good because a pathological caller of your function could convince TypeScript of something untrue at runtime. But in practice that's vanishingly unlikely to happen (nobody's going to call the function that way), so the type assertion is safe. Still, there's an open feature request at microsoft/TypeScript#58977 to allow some way to say that if set is undefined that S must be string, and then the type assertion wouldn't be necessary.

    Playground link to code