Execute Program

Advanced TypeScript: Overloading With Options Objects

Welcome to the Overloading With Options Objects lesson!

This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!

  • In an earlier lesson, we saw function overloads. In a different lesson, we saw a way to statically type "options" objects passed to functions. Now we'll combine the two, which enables powerful static typing of complex functions.

  • Our example concerns database querying. The example below defines a function findUser(id: number): User. It retrieves a user object from the "database", then returns the object.

  • As usual, we won't actually use a database. Instead, we'll return hard-coded user objects. We want to focus on the types, not the database.

  • >
    function findUser(id: number) {
    return {id: 1, name: 'Amir'};
    }

    findUser(1);
    Result:
    {id: 1, name: 'Amir'}Pass Icon
  • Real software systems tend to outgrow simple query functions like findUser, mostly due to performance problems. Sometimes we need the whole user object. Sometimes we only need one of its properties or columns. Sometimes we need the user plus every comment that they've written.

  • In our User type below, the user's comments property is optional. When we pass {withComments: true} to our function, the returned user has a comments property. Otherwise, there's no comments property at all.

  • >
    type Comment = {
    subject: string
    };

    type User = {
    id: number
    name: string
    comments?: Comment[]
    };

    function findUser(
    id: number,
    options: {withComments?: boolean} = {}
    ): User {
    const user = {id: 1, name: 'Amir'};
    if (options.withComments) {
    return {...user, comments: [{subject: "Ms. Fluff's 4th birthday"}]};
    } else {
    return user;
    }
    }
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    findUser(1);
    Result:
    {id: 1, name: 'Amir'}Pass Icon
  • Here's a code problem:

    In the code below, we call findUser without providing any options. Modify the call so that our query includes comments as well.

    type Comment = {
    subject: string
    };

    type User = {
    id: number
    name: string
    comments?: Comment[]
    };

    function findUser(
    id: number,
    options: {withComments?: boolean} = {}
    ): User {
    const user = {id: 1, name: 'Amir'};
    if (options.withComments) {
    return {...user, comments: [{subject: "Ms. Fluff's 4th birthday"}]};
    } else {
    return user;
    }
    }
    findUser(1, {withComments: true});
    Goal:
    {id: 1, name: 'Amir', comments: [{subject: "Ms. Fluff's 4th birthday"}]}
    Yours:
    {id: 1, name: 'Amir', comments: [{subject: "Ms. Fluff's 4th birthday"}]}Pass Icon
  • All of that code works just fine, but our comments property has an awkward type. It's optional in our object type, so its effective type is always Comment[] | undefined. When we call findUser(1, {withComments: true}), we still have to handle that undefined case. That can lead to many unnecessary conditionals because our types are too general.

  • We either provide {withComments: true} or not, so the comments property is either there or it's not. It would be nice if the type reflected that. With function overloads, we have the power to define that type! We can overload findUser to return different types depending on the options object.

  • Here's the implementation. When we provide {withComments: true}, we get a UserWithComments back. When we don't provide it, we get a regular User back.

  • >
    type Comment = {
    subject: string
    };

    interface User {
    id: number
    name: string
    }

    interface UserWithComments extends User {
    comments: Comment[]
    }

    function findUser(id: number): User;
    function findUser(id: number, options: {withComments: true}): UserWithComments;
    function findUser(
    id: number,
    options: {withComments?: boolean} = {}
    ): User | UserWithComments {
    const user = {id: 1, name: 'Amir'};
    if (options.withComments) {
    return {...user, comments: [{subject: "Ms. Fluff's 4th birthday"}]};
    } else {
    return user;
    }
    }
  • If we don't provide {withComments: true}, then trying to access comments is a type error.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    const user = findUser(1);
    user.name;
    Result:
    'Amir'Pass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    const user = findUser(1);
    user.comments[0].subject;
    Result:
    type error: Property 'comments' does not exist on type 'User'.Pass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    const user = findUser(1, {withComments: true});
    user.comments[0].subject;
    Result:
    "Ms. Fluff's 4th birthday"Pass Icon
  • Now an important question: should we spend this much effort to avoid the Comment[] | undefined problem? As usual, the answer is "it depends".

  • As we saw in the original function overload lesson, overloads do introduce a type safety hole in the body of the function. And as we can see above, they also complicate our type definitions.

  • Our recommendation is to use overloads only in situations where there's a lot of awkward code duplication. For example, if we find ourselves writing if (user.comments !== undefined) { ... } in hundreds of places, and we can't find any other way to remove them, then an overload may be a good idea. But if we're only writing that conditional in a few places, then it's probably better to keep the function simple, avoid overloads, and accept those few extra conditionals.