Enforce correct property use

Let's say I have a type that represents a user with their login and given name:

export interface User {
  login: string;
  name: string;
}

Now I want to query an API using that user's login:

const formatLogin (login: string) => login.toLowerCase().trim();
const getUser = (login: string) => ajax(`${API}/users/${formatLogin(login)}`);

This works. Later in my code I might use:

getUser(this.user.name);

but that's an error. It should be this.user.login. They are both strings, so TypeScript doesn't complain.

Is there a way to enforce that a property is used using TypeScript?

I have tried using export type Username = string but that doesn't work because name still satisfies that type since it's just a string. I've also tried export interface Username {} which doesn't work with formatLogin which requires string methods toLowerCase and trim. interface Username extends String {} has the same problem.

2 answers

  • answered 2018-02-13 02:29 estus

    It's impossible for typing system to differentiate between those two properties because they are arbitrary strings.

    This is the domain where test coverage should help eliminate human errors.

    The problem could be addressed not with types but with reasonable approach to method naming that leaves no place for errors. For example, this mistake

    getUserByLogin(this.user.name);
    

    will more likely draw attention.

    Depending on the case, getUser could accept not a string but a reference to entire object and get desired property from it:

    getUser = ({ login }: User ) => ajax(`${API}/users/${formatLogin(login)}`);
    

  • answered 2018-02-13 02:41 artem

    One approach is to use so-called branded types, following the example set in the sources of TypeScript compiler itself:

    export type LoginString = string & { __loginBrand: any};
    
    export interface User {
      login: LoginString;
      name: string;
    }
    
    
    const formatLogin = (login: string) => login.toLowerCase().trim();
    const getUser = (login: LoginString) => { };
    
    let user: User;
    
    formatLogin(user.login); // ok
    getUser(user.name); // error
    

    You have to manually cast ordinary string to LoginString type when you are initializing User object, you don't have to provide actual value for __loginBrand - it's for typechecking only.

    Another approach, suggested also by @estus, is to use destructured object as a kind of named parameters for API. It's not type-checked, but any mismatch is very noticeable, provided that all names are consistently used everywhere:

       export interface GetUser {
           login: string;
       }
       const getUser = ({login}: GetUser) => {}
    
       getUser(user); // ok
       getUser({login: user.name}); // huh?