This article is a part of the systemic-ts series.
complication
complication

Source of Complication: Class vs Layered Lifetime

This is the third time class has been problematic. But now prototype is not involved. The syntax itself is making it hard to do refactoring with it, especially when layered lifetime is involved.

Did you just make up a new word?

Yes. Layered Lifetime, I Just Made Up That Phrase

Lifetime is literally when an object lives---the period between its creation and its deletion from the memory.

Specifically in TypeScript/JavaScript, an object's lifetime ends when nothing refers to it---when there are no references left; subsequently, non-referenced objects are removed by the GC, but the period between the reference count zeroing and the collection does not matter because nothing could be done with said objects.

"Layered lifetime" simply refers to a situation where there are objects whose lifetimes are diverse.

A perfect example of "Layered lifetime" would be a Page that has a Modal which is open only at times. This example will be written in React.

const Page = () => {
  const [modalOpen, setModalOpen] = useState(false);

  return (
    <Layout>
      <Toggle onClick={setModalOpen((open) => !!open)} />
      {modalOpen && <Modal />}
    </Layout>
  );
};

The above code says:

  • "A Page contains a Layout, a Toggle, and optionally a Modal"
  • "Toggle, when clicked, flips modalOpen from true to false, and vice versa."
  • "Modal is open only when modalOpen is true."

Talking in terms of lifetimes:

  • Modal lives within the Page's lifetime;
  • Modal lifetime is shorter than the Page's;
  • Modal cannot exist without Page.
  • Page can exist without Modal.

The diversity of the lifetime is apparent here. There are two objects Modal and Page, and their lifetimes differ. Contrast this with the relationship of Page and Layout whose lifetime is the same.

Lifetime and Type's Interesting Relationship

Above 6 PM I tend to write very dumb code such as:

class User {
  id?: string;
  name: string;
  address: string;
  constructor(
    name: string,
    address: string
  )                  { this.name = name; this.address = address }
  setId(id: string)  { this.id = id }
  isInvalid()        { return this.id === undefined; }
}

// On user input I want to create a "user"
function onUserInput(name: string, address: string) {
  return new User(name, address);
}

// The product manager wants to only reserve some Id
// from a centralized-badly-architectured-single-threaded-obviously-strawman-database
// ONLY WHEN the user click a confirmation to "confirm their seriousness in registering"
async function onUserConfirm(user: User) {
  user.setId(await reserveIdFromCentralizedDatabase())
  return user;
}

// When the user actually submits
async function submitUser(user: User) {
  // setId should be called before this
  if(user.isInvalid()) throw new Error()
  return await submitEntity("user", user);
}

The next morning, logic comes back to me. I realized that User without id does not make sense.

So I refactored the code into:

class UserData {
  public name: string;
  public address: string;
  constructor(name: string, address: string) {
    this.name = name;
    this.address = address;
  }
}

class User extends UserData {
  id: string;
  constructor(id: string, userData: UserData) {
    this.name = userData.name;
    this.address = userData.address;
  }
}

// On user input I want to create a "user"
function onUserInput(name: string, address: string) {
  return new UserData(name, address);
}

// The product manager wants to only reserve some Id
// from a centralized-badly-architectured-single-threaded-obviously-strawman-database
// ONLY WHEN the user click a confirmation to "confirm their seriousness in registering"
async function onUserConfirm(userData: UserData) {
  return new User(await reserveIdFromCentralizedDatabase(), userData);
}

// When the user actually submits
async function submitUser(user: User) {
  // IMPORTANT: Now there's no need to call `invalid`
  await submitEntity("user", user);
}

What I realize is that a User's lifetime can't outlive its id. After the refactor, id is not optional. submitUser does not need to validate the always-valid User.

This way, the code represents entities' lifetimes' relationships more correctly.

Too Many Lines Of Codes

"You claim it is simpler. Why are there more lines of code now?" - some review

Whelp! Such is the price of correctness.

"Why don't you just use type?" - some other review.

That's actually a great idea. Well, it was written in class, so I followed. In hindsight, a class without methods is wasteful.

type UserData = { name: string; address: string };
type User = UserData & { id: string };

const onUserInput = (name: string, address: string): UserData => ({
  name,
  address,
});
const onUserConfirm = async (data: UserData): User => ({
  ...data,
  id: await reserveIdFromCentralizedDatabase(),
});
const submitUser = (user: User) => await submitEntity("user", user);

There you go!

Wait! Isn't this what "Functional Programming" looks like (or at least, advertised to look like)? It is one of the "evolutionary phases" of systemic-ts.

complication
complication