Dealing with type changing side effects in TypeScript
Asked Answered
P

2

8

This is more of an open Question on how to deal with functions that have type altering side effects in TypeScript. I know and strongly agree with the notion, that functions should have as few side effects as possible, if any.

But sometimes, it is desirable to change an object (and it's type) in-place instead of creating a new copy of it with another static type. Reasons I have come across most frequently are readability, efficiency or reducing line count.

Since my original example was too convoluted and over-complicated a (hopefully) very basic example here:

type KeyList = 'list' | 'of' | 'some' | 'keys';

// Original type (e.g. loaded from a JSON file)
interface Mappable {
    source: { [K in KeyList]: SomeNestedObject },
    sourceOrder: KeyList[];
}

// Mapped Type (mapped for easier access)
interface Mapped {
    source: { [K in KeyList]: SomeNestedObject },
    sourceOrder: SomeDeepObject[];
}

// What I have to do to keep suggestions and strict types all the way
const json: Mappable = JSON.parse(data); // ignoring validation for now
const mapped: Mapped = toMappedData(json);

// What I would like to to
const mapped: Mappable = JSON.parse(data);
mapData(mapped); // mapped is now of type Mapped

Reasons, why I would like to mutate both object properties and it's type in-place could be:

  • The json object is very large and it would be counterproductive to have 2 copies of it in memory
  • It is very cumbersome to create a deep copy of the json object into the mapped object

I don't believe the code under "What I would like to do" is very readable, regardless of it not working. What I'm looking for is a clean and type safe way to navigate this issue. Or, alternativley, suggestions or ideas to extend typescript's functionality towards solving this issue.

Any suggestions, ideas and comments on this are greatly appreciated! Maybe I'm just in too deep with this and can't see the really simple solution.

Plaque answered 23/7, 2020 at 18:26 Comment(6)
Are you trying to do something like this? (without errors, obviously) typescriptlang.org/play/#code/…Contrasty
What is KeyList and what is SomeDeepObject? Are you saying that they can be converted into each other with an identity function?Valvule
@AlexWayne Pretty much, yes. Although in your example the transform function's type definition hides the side effect it has on it's parameter, which is not what I want. I'm looking for a way to make it clear the function has a side effect, that changes the parameter's type.Plaque
@Valvule KeyList is some string type and SomeDeepObject is some larger object that is not feasible to repeat over and over again in a JSON file. I'll rename to SomeNestedObject and add a short definition for KeyListPlaque
So how would you say that "mapped is now of type Mapped" without using a toMappedData function that changes the value of .sourceOrder?Valvule
@Valvule Yes, although I'm aware, that it is not possible they way I wrote it. Code like const mapped: Mappable = JSON.parse(data); setTimeout(() => 'What type is mapped now?', 100); mapData(mapped); would be impossible for typescript to check.Plaque
C
0

I don't think what you want to do is possible. You asking typescript change a variables type as a side effect. But that introduces all kinds of complications.

What if the mapData function is run conditionally?

const mapped: Mappable = JSON.parse(data);
if (Math.random() > 0.5) {
  mapData(mapped); // mapped is now of type Mapped
}
// What type is `mapped` here?

Or what if you pass a reference to this object before transforming it?

function doAsyncStuff(obj: Mappable) {}
doAsyncStuff(mapped)
mapData(mapped)
// Then later, doAsyncStuff(obj) runs but `obj` is a different type than expected

I think the closest you can get here is a typeguard with a cast to an intermediate type that supports the union of the pre-transform type and the post-transform type where you can actually do the transformation.

interface A {
  foo: string
}

interface B {
  foo: string
  bar: string
}

interface A2B {
  foo: string
  bar?: string
}

function transform(obj: A): obj is B {
  const transitionObj: A2B = obj
  transitionObj.bar = "abc" // Mutate obj in place from type A to type B
  return true
}

const obj: A = { foo: 'foo' }
if (transform(obj)) {
  obj // type is B in this scope
}
obj // but obj is still type A here, which could lead to bugs.

But if you actually use obj as type A outside that conditional, then that could lead to runtime bugs since the type is wrong. So a typeguard function with side effects is also a really bad idea, since a typeguard lets you override typescripts normal typing.


I really think you're already doing the best approach here, especially if the output type is different from the input type. Immutably construct the new object as the new type.

const mapped: Mapped = toMappedData(json);

If performance or memory is a huge concern, then you may have to sacrifice type safety for that cause. Write robust unit tests, cast it to any, add very prominent comments about what's going on there. But unless you dealing with hundreds of MB of data at a time, I'm betting that's really not necessary.

Contrasty answered 23/7, 2020 at 20:42 Comment(2)
You might be right. It still seems wrong to initialize a fairly large variable (ca. 8k lines of JSON) and then immediately discarding it. Might be better to split them up anyway, then I won't feel as bad for themPlaque
For your intermediate type solution, your if statement could read if (!transform(obj)) { throw 'dummy' }. That would guaranty that lines after that have obj mutated properly. I think you'll have issues, though if B['bar'] is not a subtype of A['bar'] without a // @ts-ignore above transform's declaration.Yemane
P
0

The way I have done at the moment is:

type KeyList = 'list' | 'of' | 'some' | 'keys';

// Merged the types to keep the code a little shorter
// Also makes clear, that I don't alter the type's structure
interface MyType<M ext boolean = true> {
    source: { [K in KeyList]: SomeNestedObject },
    sourceOrder: (M ? SomeNestedObject : (KeyList | SomeNestedObject))[];
}

function mapData(data: MyType<true>): MyType {
    const { source, sourceOrder } = data.sourceOrder
    for (let i = 0; i < sourceOrder.length; i++) {
        if (typeof sourceOrder[i] == 'string') {
            sourceOrder[i] = source[sourceOrder[i]];
        }
    }

    return (data as unknown) as MyType;
}

const json: MyType<true> = JSON.parse(data);
const mapped: MyType = mapData(json);

// mapped now references json instead of being a clone

What I don't like about this approach:

  • It's not type safe. Typescript can't check if I correctly mutate the type
    • Because of this I have to convert to unknown, which is not ideal
  • json type is not as strict. Could negatively impact code suggestions
  • The function has a side effect as well as a return type (exclusively side effect or return type would be cleaner)
Plaque answered 23/7, 2020 at 20:40 Comment(0)
C
0

I don't think what you want to do is possible. You asking typescript change a variables type as a side effect. But that introduces all kinds of complications.

What if the mapData function is run conditionally?

const mapped: Mappable = JSON.parse(data);
if (Math.random() > 0.5) {
  mapData(mapped); // mapped is now of type Mapped
}
// What type is `mapped` here?

Or what if you pass a reference to this object before transforming it?

function doAsyncStuff(obj: Mappable) {}
doAsyncStuff(mapped)
mapData(mapped)
// Then later, doAsyncStuff(obj) runs but `obj` is a different type than expected

I think the closest you can get here is a typeguard with a cast to an intermediate type that supports the union of the pre-transform type and the post-transform type where you can actually do the transformation.

interface A {
  foo: string
}

interface B {
  foo: string
  bar: string
}

interface A2B {
  foo: string
  bar?: string
}

function transform(obj: A): obj is B {
  const transitionObj: A2B = obj
  transitionObj.bar = "abc" // Mutate obj in place from type A to type B
  return true
}

const obj: A = { foo: 'foo' }
if (transform(obj)) {
  obj // type is B in this scope
}
obj // but obj is still type A here, which could lead to bugs.

But if you actually use obj as type A outside that conditional, then that could lead to runtime bugs since the type is wrong. So a typeguard function with side effects is also a really bad idea, since a typeguard lets you override typescripts normal typing.


I really think you're already doing the best approach here, especially if the output type is different from the input type. Immutably construct the new object as the new type.

const mapped: Mapped = toMappedData(json);

If performance or memory is a huge concern, then you may have to sacrifice type safety for that cause. Write robust unit tests, cast it to any, add very prominent comments about what's going on there. But unless you dealing with hundreds of MB of data at a time, I'm betting that's really not necessary.

Contrasty answered 23/7, 2020 at 20:42 Comment(2)
You might be right. It still seems wrong to initialize a fairly large variable (ca. 8k lines of JSON) and then immediately discarding it. Might be better to split them up anyway, then I won't feel as bad for themPlaque
For your intermediate type solution, your if statement could read if (!transform(obj)) { throw 'dummy' }. That would guaranty that lines after that have obj mutated properly. I think you'll have issues, though if B['bar'] is not a subtype of A['bar'] without a // @ts-ignore above transform's declaration.Yemane

© 2022 - 2025 — McMap. All rights reserved.