DEV Community

loading...

It's 2021 and I still don't know the best way to write this in TypeScript.

reggi profile image Thomas Reggi ・2 min read

Below I have three things they're not the same, but they're not that different. They all actually have their own set of pros and cons. I wish it was actually much simpler to choose between them, and there was one way to do it that had the simplicity and brevity all in one.

If you know a better way to describe this data, please do tell.

Function

Functional and simple, but kind of hard to create a return type, unless you want that defined separately or use ReturnType<typeof person>. 😢

const person = (name: string) => {
  const initial = name.charAt(0);
  const length = name.length;
  const initialRegex = new RegExp(initial, 'g');
  const initialOccurance = (name.match(initialRegex) || []).length;
  return { initial, length, initialRegex, initialOccurance};
}
Enter fullscreen mode Exit fullscreen mode

Class With Getters

Class based approach with getters, in my opinion the most elegant because it's less redundant, no repetition of the names to export as above. However the caveat (or benefit) of not running everything at runtime is interesting, meaning, at initialization, no properties are processed, however they're not cached either, so everytime you run this.initial it has to run that code again and again.

class Person {
  constructor (public name: string) {}
  get initial () {
    return this.name.charAt(0);
  }
  get initialRegex () {
    return new RegExp(this.initial, 'g');
  }
  get length () {
    return this.initial.length
  }
  get initialOccurance () {
    return (this.name.match(this.initialRegex) || []).length;
  }
}
Enter fullscreen mode Exit fullscreen mode

This isn't actually a fair contender because there's no way to get an object with all of these properties. You'd need to add a .toJSON() method repetitively building up the properties again.

  toJSON() {
    return {
      name: this.name,
      initial: this.initial,
      length: this.length,
      initialRegex: this.initialRegex
    }  
  }
Enter fullscreen mode Exit fullscreen mode

Class with Initialized Properties

This fixes the caching issue by having all the properties initialized with the constructor, but we get the repetition of having to define the types in the class now. 🙄

class Person {
  initial: string;
  length: number;
  initialRegex: RegExp;
  initialOccurance: number;
  constructor (public name: string) {
    this.initial = name.charAt(0);
    this.length = name.length;
    this.initialRegex = new RegExp(this.initial, 'g');
    this.initialOccurance = (name.match(this.initialRegex) || []).length;
  }
}
Enter fullscreen mode Exit fullscreen mode

Why so many options? Why are none of them good?

Discussion (5)

pic
Editor guide
Collapse
peerreynders profile image
peerreynders • Edited

but we get the repetition of having to define the types in the class now. 🙄

I suspect your conundrum is more related to your perspective which perhaps has fallen victim to Software Development by Slogan.

type Person = {
  initial: string;
  length: number;
  initialRegex: RegExp;
  initialOccurance: number;
};

const person: (n: string) => Person = (name) => {
  const initial = name.charAt(0);
  const initialRegex = new RegExp(initial, 'g');
  return {
    initial,
    length: name.length,
    initialRegex,
    initialOccurance: (name.match(initialRegex) || []).length,
  };
};
Enter fullscreen mode Exit fullscreen mode

The DRY principle is often quoted in the context of elimination of repetition/duplication because of its expansion "Don't Repeat Yourself" - but that is just the slogan. The actual core message is:

Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.

  1. Person - the type definition is authoritative representation of the type's shape - it exists in type space.
  2. person - the function is the authoritative representation of the type's construction - it exists in value space ‡.

So terms of DRY there isn't any duplication here because we are dealing with two separate dimensions of the same thing.

And in terms of class-based object-orientation I have a number of times run into the argument that class definitions that include constructors violate "Separation of Concerns" because they conflate the run-time behaviour of the object as defined by the methods with the construction of the object (largely because inept error handling inside a constructor can easily lead to "zombie objects" ruining your day).

I'm the last person to be a TypeScript apologist - OCaml's (i.e. ReScript) type inference is far superior without sacrificing soundness but gradual typing of JavaScript was a more important objective for the TypeScript team (for better or worse). Also at some point type inference becomes expensive in terms of CPU cycles (i.e. compilation times). While OCaml can be pretty swift, something like compiling Rust (with the additional borrow checking) can be rather time consuming. So being explicit with your types could perhaps - sometimes - improve compilation times.

JavaScript code lives entirely in value space, TypeScript types live in type space. TypeScript has some unique tooling to pull information from value space into type space - you mentioned the utility type ReturnType (es5.d.ts) which leverages type inference in conditional types.

Given the nature of JavaScript, TypeScript will often require explicit guidance and direction as to what information in value space is pertinent and how it is pertinent - and that guidance may look like repetition - but it's not.

Then there are design considerations. When learning Haskell you will often run into advice like this:

It's usually helpful to write down the type of new functions first;

Some people refer to this as type-driven development - I sometimes call it contract-first programming (leaning heavily on (positive) logic-to-contract coupling). You define your types and their relationships to one another before you implement any behaviour.
What you seem to be after is "Contract-last" development - i.e. have TypeScript divine from the implementation what the types should be.

In the Web Services space "Contract-last" was popular with developers because it made development easy - but in the majority of cases it lead to interoperability problems with clients using different host technologies. So "Contract-first" was the correct approach in most cases. Similarly designing your types first you often uncover constraints before you write a single line of code.

Would you ask a general contractor to build the house first and then derive the blueprint from the finished house?


‡ It would actually be more accurate to say that the function's type (n: string) => Person exists in type space while the function's implementation:

const person = (name) => {
  const initial = name.charAt(0);
  const initialRegex = new RegExp(initial, 'g');
  return {
    initial,
    length: name.length,
    initialRegex,
    initialOccurance: (name.match(initialRegex) || []).length,
  };
};
Enter fullscreen mode Exit fullscreen mode

exists in value space.

Collapse
reggi profile image
Thomas Reggi Author

This is a really great, in-depth explanation. I love it. Thank you for taking the time to write this out.

Collapse
lionelrowe profile image
lionel-rowe • Edited

You can use field initializers outside the constructor to avoid repeating the field names:

class Person {
    constructor (public name: string) {}

    initial = this.name.charAt(0);
    length = this.name.length;
    initialRegex = new RegExp(this.initial, 'g');
    initialOccurance = (this.name.match(this.initialRegex) || []).length;
}
Enter fullscreen mode Exit fullscreen mode

If performance is critical, you could use a getter in combination with memoization of the results. This way, you'd delay evaluation until the first time the property was accessed, but wouldn't have to recalculate it on subsequent accesses.

const memoize = <T extends (arg: any) => any>(fn: T) => {
    type In = Parameters<T>[0]
    type Out = ReturnType<T>

    const results = new Map<In, Out>()

    return (arg: In) => {
        if (results.has(arg)) {
            return results.get(arg) as Out
        }

        const result = fn(arg)

        results.set(arg, result)

        return result as Out
    }
}

const expensiveUpper = (name: string) => {
    const start = Date.now()

    while (Date.now() < start + 1000) { /* hang */ }

    return name.toUpperCase()
}

class Person {
    constructor (public name: string) {}

    private _memoizedUpper = memoize(expensiveUpper)

    get upper() {
        return this._memoizedUpper(this.name)
    }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
reggi profile image
Thomas Reggi Author

Thanks for the reply! Yes memoization / caching of getters is cool but I often think of it as too much boiler plate, then there's the async issue 🙄. But I do appreciate your other example of using field initializers outside the constructor.

Collapse
lionelrowe profile image
lionel-rowe

Yeah memoization is only really suitable where you need the performance boost. If you do need it though, you only need to write the boilerplate once, or zero times if you use a library. It can be adapted for asynchronous usage, as long as the memoized function always produces the same output from the same inputs. That wouldn't be the case when calling a web API, but might be the case if the asynchrony is due to carrying out expensive computation on a worker thread or something.