loading...

Discussion on: My confusions about TypeScript

kelerchian profile image
Alan

This convo is fun :D

I'll throw some opinion that hopefully will help you with this.

Additionally, while your example demonstrates how to simulate private instance vars using closures with the _name, _energy, and _breed vars, if you were to modify your example above to add these vars to the returned object and to add static properties to the factory functions, it just feels more and more to me like you're writing a polyfill for the class keyword.

Java community and its derivative (Spring Boot, PHP Laravel, Ruby on Rails) to put the "smartness" of their library in the runtime bootstrap process and Objection.js is one of the libraries that walks into their path. In time this will conflict with TypeScript's approach to get types right, which is more of functional approach to type correctness (rust, Haskell, elm).

Let me take a detour a bit:

Edward's example of an object factory has a perfect type and perfect JavaScript encapsulation which is very convenient for people who cares about encapsulation and OOP in general.

  const Animal = (name, energy) => {
    let _energy = energy
    let _name   = name

    return {
      eat(amount) {               // object of type "function" is created everytime Animal is called
        console.log(`${_name} is eating.`)
        _energy += amount
      },

      sleep(duration) {
        console.log(`${_name} is eating.`)
        _energy += amount
      },

      play(duration) {
        console.log(`${_name} is playing.`)
        _energy -= duration
      }
    }
  }
  /* 
    Back then in my early javascript day I used this exact
    pattern to make a mini game (don't laugh), and man it was 
    damn slow. If you really have to add a method, use 
    prototype.[fnName] or use class.
  */

If you look closely to the problems you are having, they are pretty much all class oriented.

As Edward might notice, OOP's paradigm to mix data and operations in a single instance a.k.a class is where it gets problematic. And it is encouraged by languages like Java and javascript because the structure/prototype is described in the runtime, thus it can be queried a.k.a Reflection. Reflection is NOT wrong. It's a great feature, but the side effect is that it encourages the wrong approach to a problem, the storytelling.

Structures should be written like how we describe a character in a story, explicit and at once:

struct A{
 a: SomeType
 b: SomeOtherType
}

And operations should be written like how we describe a scene in a story, step by step, chronologically.

fn moveChessPiece(chessBoard: ChessBoard, chessPiece: ChessPiece, chessLocation: ChessLocation){
  chessBoard.detach(chessPiece)
  chessBoard.attach(chessPiece, chessLocation)
}

Let that new paradigm sink in. Then, let's rewrite the Animal/Dog code with some bonus code (because I really want to show you guys how this paradigm is development-scalable).

// Using type instead of class because type is zero-cost in the runtime
type Activity<ActivityTypes extends string[], Data extends object> = {
 activityType: ActivityTypes,
 activityData: Data
};

type Entity = {
 activity: Activity
};

// animal.ts
// a type based inheritance-like 
type Animal = Entity & {
 energy: number,
 name: string
};

// dog.ts
export const DogTypeSiberianHusky: unique symbol = Symbol()
export const DogTypeGoldenRetriever: unique symbol = Symbol()

type DogActivity = Activity<"sleeping" | "idle", {duration: number}>

type Dog = Animal & {
 activity: DogActivity,
 kind: "dog", // literal string
 breed: DogTypeSiberianHusky | DogTypeGoldenRetriever
}

export const DogActivityFns = {
 // create* functions are factory-ish
 createIdle: (): DogActivity => ({
   activity: "idle",
   duration: -1
 }),
 createSleep: (duration): DogActivity => ({
   activity: "sleeping",
   duration
 }),
 isExpired: (activity: DogActivity) => activity.duration <= 0,
 canSleep: (activity: DogActivity) => activity.type === "idle"
}

export const DogFns = {
 // called on render on every entity instantiated in world
 onUpdate: (dog: Dog) => {
  // assumptions are Dog as an Entity is a mutable object
  // meaning in this scenario dog object are not recreated { ...dog }
  // for the sake of performance
  const { activity } = dog
  switch(activity.type){
    case "sleeping": {
     if(activity.type === "sleeping"){
      activity.duration -= 1
      if(DogActivityFns.isExpired(activity)) {
       dog.activity = DogActivityFns.createIdle(activity)
      }
     }
     return
    }
  }
},

 triggerSleep: (dog: Dog, duration: number) => {
  if (!DogActivityHelper.canSleep(dog.activity)) return
  dog.activity = DogActivityHelper.createSleep(duration)
 }
}

// And then somewhere on the code
// There's an event listener that can be fired outside of the game's main loop

activityEventTrigger.subscribe("triggerSleep", (dog, duration) => 
  DogFns.triggerSleep(dog, duration)
)

The code above shows a really easy way to get a pretty complex idea done in a simple manner, only by separating operations and structure as opposed to the classical OOP where method and property is in one place so that it's confusing whether we should write attack() or receiveDamage().

Let's go back to the case of ObjectionJS, we've detoured pretty far.

TypeScript clearly doesn't do much good to the usage of ObjectionJS as it's very complex in terms of type correctness (e.g. idColumn could be string or string[]).

I'd treat ObjectionJS modules as another "unknown territory" in the application, like localStorage.get, fetch, fs.readFileSync, redisClient.get, etc. Let it be functions that return unknown. You could practically give unknown or if not possible any (forgive me, lord) to the idColumn and other static method return type.

Now create a funnel function where it returns that unknown object as a result of ObjectionJS query with a precise type or return an error if it is not the expected result.

async function fetchUserFunnel(){
  const raw = await fetchUserWithObjectionJS();
  if (!User.is(raw)) {  // decode process, will explain later
    return { value: null, error: new DecodeError() };
  }
  return { value: raw, error: null };
}

To scale this pattern let's make a function factory a.k.a higher-order function for it. I'll be using io-ts for the parser to demonstrate the new paradigm. Also, check out io-ts if you haven't, it's a cool lib.


export class DecodeError extends Error {}

// the hof
function makeFunnel<T extends object>(
  validateFn: (t: unknown) => t is T
) {
  return (raw: unknown): {value: T, error: null} | {value:null, error: DecodeError} => {
    if(!validateFn(raw)){
      return { value: null, error: new DecodeError() };
    }
    return { value: raw, error: null }
  }
}

// the usage
export const UserCodec = t.type({
  userId: t.string,
  userName: t.union([
    t.string,
    t.null
  ]),
})
export const UserFromDB = t.intersection([
  User,
  t.type({ children: t.array(UserCodec) }),
]);
export type UserFromDB = t.TypeOf<typeof UserFromDB>;
export const funnelUser = makeFunnel((t: unknown) => UserFromDB.is(t))
export const fetchUserById = 
  (id: string) => 
    User.query()
      .where("id", id)
      .withGraphFetched('children')
      .then(result => funnelUser(result[0]))

And using the fetch function would be

// And how to use it would be
const result = await fetchUserById(query.id)
if(result.error) return res.send(500); // corrupted data in the database, data is not contractual

return res.send(result.value)

If you're really sure that your database will always return the correct schema, do this, but I don't recommend this.

const users = await User.query()
  .where("id", id)
  .withGraphFetched('children') as UserFromDB

The code is long and it's already the optimal amount of code to achieve type correctness in your application.

I guess enforcing strict TypeScript in the ObjectionJS is not the best angle to approach this problem. ObjectionJS is of a different paradigm, and let it be that way because we can't change how it behaves nor we can put a strict TypeScript rule to it.

Let ObjectionJS be unknown. As a substitute, enforce type correctness in the business logic, detached from the resource facing layer.

Thread Thread
jwp profile image
John Peters

Ken;
You had asked "How would you restructure that program to use a class?" with respect to the goodness of "hoisting".

If multiple components need to use something in common, I usually abstract the component to an Angular Service today. It gives me that ability to re-use anything anywhere. The only drawback is that I have to import the service in order to reuse the code within it.

Thread Thread
kenbellows profile image
Ken Bellows Author

@Alan, thanks for the in-depth response! I'll have to take some time to read through it and consider it. Interestingly, I notice that Objection.js actually provides typings, though they seem rather convoluted and maybe more intended for internal library use; what do you think? github.com/Vincit/objection.js/blo...

@john Peters, sure, I think that's a great approach, and personally I don't consider an extra import a drawback at all; if anything, I prefer smaller individual modules, so I'm happy to ad an extra import in exchange for a smaller overall file 😁

Thread Thread
kelerchian profile image
Alan

@Ken I would take the shortest, easiest, and safest path that scales both in performance and development. TypeScript strict typing is relieving me from constantly fearing "undefined is not a function" issue, but if objection typing turns out to be a burden more than a help to my team, I would consider excluding objection from the strict typing in favor of development efficiency. It's just my opinion though, you know the lib better and might prove me wrong on this.

etampro profile image
Edward Tam
To return to my specific example, I think there's a lot of value to be found in subclassing a common Model class to represent each table in my database, especially given the use fo static getters to represent metadata like which columns are the primary keys and how different tables are related.

If you find value in using subclassing in your case, then go for it. I am not going to pretend to be the know-it-all :)

having written classes in Java, Python, Ruby, and JavaScript ...

So we pretty much have the same background :)

what do you see as the most important differences between classes in JavaScript and other languages, and most importantly, what do you see as misleading about them?

I think my biggest complaint about JS classes is the encapsulation. Let's see the following code as example:

class Person {
  constructor (name) {
    this.name = name
  }

  talk () {
    console.log(`${this.name} says hello`)
  }
}

const person = new Person('Tom')
const mockElement = {}

mockElement.onClick = person.talk
mockElement.onClick() // this.name -> undefined!

As you can see person.talk is not encapsulated as a part of the class Person. Instead, the context is related to where it is called instead. This problem is especially painful when you work with frontend code (e.g. React). You will find the context being mutable, and will be forced to bind them everywhere. So as Alan also mentioned in the other reply, I tend to go with functions and closures in order to workaround this hustle.

Thread Thread
kenbellows profile image
Ken Bellows Author

Ah I see. Yeah that's definitely a frustration sometimes, and I've dealt with the React issues you mention. That said though, I wouldn't tie that to JavaScript's class system at all; that's just how objects in general work in JavaScript, and it has its pros and cons. While it's true that it can cause some issues, it also facilitates the composition-over-inheritance style you mentioned above, as it lets you copy methods from one object directly to another without any fancy footwork. Pros and cons, I guess

Thread Thread
jwp profile image
John Peters

Interesting for sure! I have never seen this before.

Class Encapsulation seems to imply the name property only for the class object. So the behavior you've shown would be expected because mockElement.onClick is not a Person object. Right?

Indeed changing the assignment to const mockElement = this.person gives proper context when calling mockElement.Talk();

It looks to me like the class object's context is not mutable. That's a good thing.

Thread Thread
etampro profile image
Edward Tam
It looks to me like the class object's context is not mutable

Actually, class object's context is mutable. Let's take a closer look at the example again:

class Person {
  constructor (name) {
    this.name = name
  }

  talk () {
    console.log(`${this.name} says hello`)
  }
}

const person = new Person('Tom')
const mockElement = {}

mockElement.onClick = person.talk
mockElement.onClick() // this.name -> undefined!

mockElement.conClick = person.talk.bind(person)
mockElement.onClick() // this.name == 'Tom'

You can actually manipulate the reference of this. This is what makes it misleading in an OOP perspective because you would have thought the context is part of the encapsulation in the definition. The problem is not strictly coming from javascript classes, but more from the discrepancy of the expected behaviour of how class should be vs how javascript class actually is.

Thread Thread
kenbellows profile image
Ken Bellows Author

But I really don't think it has anything to do with classes. I think a better statement would be, "The problem is coming from the discrepancy of the expected behavior of how objects should be vs how javascript objects actually are". IMHO, the confusion is identical using a factory:

const Person = (name) => {
    return {
        name,
        talk(amount) {
            console.log(`${this.name} says hello`)
        }
    }
}

const person = Person('Tom')
const mockElement = {}

mockElement.onClick = person.talk
mockElement.onClick() // this.name -> undefined!

mockElement.conClick = person.talk.bind(person)
mockElement.onClick() // this.name == 'Tom'

I can't see this example being any less confusing than the class example just because we don't use the new keyword. The confusion all boils down to this step:

mockElement.onClick = person.talk
mockElement.onClick() // this.name -> undefined!

Regardless of how we built the person object, what's confusing is that the talk method loses its this context when attached to something else.

Now of course, one way to solve this problem is to use purely private vars and closures like you did in your Animal example, but personally, I have one really big problem with that approach: it makes the properties themselves inaccessible. You can no longer do doggo.name = 'Fido' to rename your dog. And hey, If all you need is private vars, go for it, but I don't think this approach covers all cases, or even most.

You can, of course, use a getter and a setter for each public property to make them accessible while keeping the closure and its advantages, but at that point the complexity of the code really ramps up while the readability falls, and personally, I just don't know if it's worth the trade-off:

const Animal = (name, energy) => {
    let _energy = energy
    let _name   = name

    return {
      get energy() {
        return _energy
      },
      set energy(energy) {
        _energy = energy
      },

      get name() {
        return _name
      },
      set name(name) {
        _name = name
      },

      eat(amount) {
        console.log(`${_name} is eating.`)
        _energy += amount
      },

      sleep(duration) {
        console.log(`${_name} is eating.`)
        _energy += amount
      },

      play(duration) {
        console.log(`${_name} is playing.`)
        _energy -= duration
      }
    }
  }

  const Dog = (name, breed, energy) => {
    let _breed = breed

    return {
      ...Animal(name, energy),

      get breed() {
        return _breed
      },
      set breed(breed) {
        _breed = breed
      },

      speak() {
        console.log(`${_name} says, "Woof!"`)
      }
    }
  }

That up there feels like a lot of boilerplate to produce an object with three properties, just so I can occasionally write myBtn.click = myDoggo.speak instead of myBtn.click = () => myDoggo.speak().

This is definitely a personal preference, but I don't think the relatively minor tradeoff of context-free methods is worth it. I personally don't use them nearly often enough to justify that kind of a change across the board. If you do, hey, maybe it's for you, but I personally am so used to JavaScript objects and how functions and this work that it's barely even a frustration, and tbh I just really love the elegance of the class syntax. Unpopular opinion, but IMO it will be even better once the class field and private class field syntaxes become standard.

Thread Thread
etampro profile image
Edward Tam
I think a better statement would be, "The problem is coming from the discrepancy of the expected behavior of how objects should be vs how javascript objects actually are".

I think that is a fair statement. Regardless, that was fun discussion and I think I learnt something from it :)

Thread Thread
kenbellows profile image
Ken Bellows Author

Definitely 😁 Thanks to everyone in this thread for the back and forth, it was a good discussion and we made it out without any flames