I'm working on a TypeScript service where a function returns a User | null
. I want to modify the return type to be User
when the throwIfNotFound
parameter is set to true. This ensures an exception is thrown if the user isn't found, avoiding redundant null checks with TypeScript's strictNullChecks
enabled.
Reproducible example:
interface IOptions {
throwIfNotFound?: boolean;
}
interface User {
id: number;
}
const userArray: User[] = [{ id: 1 }, { id: 2 }, { id: 3 }];
function findUserById(id: number, options: IOptions): User | undefined {
const { throwIfNotFound = false } = options;
let user: User | undefined;
if (id !== null) {
user = userArray.find((u) => u.id === id);
}
if (throwIfNotFound && !user) {
throw new Error("User not found");
}
return user;
}
const user1 = findUserById(1, { throwIfNotFound: true }); //User exists so no error is thrown and user1 isn't undefined
console.log(user1.id); // 'user1' is possibly 'undefined'.ts(18048)
Is there a way to do this using TypeScript?
Thanks
You want the output of findUserById()
to either be User
or User | undefined
depending on the inputs. But the only way for function output types to depend on input types is if you either overload the function (giving it multiple call signatures) or make it generic. Neither method can be verified as type safe by the compiler in the implementation (it's beyond the compiler's abilities) so each approach is mostly useful from the caller's perspective.
Traditionally you would overload it, like this:
// call signatures
function findUserById(id: number, options: IOptions & { throwIfNotFound: true }): User;
function findUserById(id: number, options: IOptions): User | undefined;
// implementation
function findUserById(id: number, options: IOptions) {
const { throwIfNotFound = false } = options;
let user: User | undefined;
if (id !== null) {
user = userArray.find((u) => u.id === id);
}
if (throwIfNotFound && !user) {
throw new Error("User not found");
}
return user
}
The function body is only loosely checked by the compiler. If you changed the check (throwIfNotFound && !user)
to (!throwIfNotFound && !user)
the compiler wouldn't be able to tell anything was wrong. So be careful.
When you call an overloaded function the compiler resolves the call signature (mostly) by trying each call signature in order until it finds a match, which gives you the behavior you're looking for:
const user1 = findUserById(1, { throwIfNotFound: true });
// ^? const user1: User
const user2 = findUserById(1, {});
// ^? const user2: User | undefined
Alternatively, you could make the function generic and give it a conditional return type that depends on the input type. This approach can sometimes be preferable to overloads, especially if the input-output relationship is too complicated to be written as some small number of nongeneric call signatures.
The conditional type could look like this:
type FindUserById<O extends IOptions> =
User | (O["throwIfNotFound"] extends true ? never : undefined)
Where FindUserById<O>
will always contain User
, but will either contain nothing else (the never
type) or undefined
depending on whether the input type O
constrained to IOptions
has a true
type for throwIfNotFound
(using the indexed access type O["throwIfNotFound"]
to look up that property).
Then the function looks like
function findUserById<O extends IOptions>(id: number, options: O): FindUserById<O> {
const { throwIfNotFound = false } = options;
let user: User | undefined;
if (id !== null) {
user = userArray.find((u) => u.id === id);
}
if (throwIfNotFound && !user) {
throw new Error("User not found");
}
return user as FindUserById<O>
}
Again the compiler can't check the function body properly. Here if you wrote return user
the compiler would complain because it can't be sure if user
is appropriately a FindUserById<O>
. So we use a type assertion as FindUserById<O>
to tell the compiler we're sure we know what we're doing. Again, that's something we need to be careful about; the same issue with !throwIfNotFound
would happen.
And again, this looks good from the caller's side:
const user1 = findUserById(1, { throwIfNotFound: true });
// ^? const user1: User
console.log(user1.id);
const user2 = findUserById(1, {});
// ^? const user2: User | undefined