Side effects are unintentional consequences of actions. That might mean:
- something that happened at runtime that you didn't expect, or
- a knock-on effect from changing the source code
Side effects can cause correctness and robustness issues, because they're not meant to happen.
It's best illustrated with an example:
type MediaTypeOptions = OnErrorOptions;
type ContentTypeOptions = MediaTypeOptions;
function makeMediaType(
input: string,
{ onError = THROW_THE_ERROR }: MediaTypeOptions,
... fnOpts: FunctionalOption<MediaType>
): MediaType;
function makeContentType(
input: string,
{ onError = THROW_THE_ERROR }: ContentTypeOptions,
... fnOpts: FunctionalOption<ContentType>
): ContentType;
Here, we've got two type aliases for user-supplied options:
MediaTypeOptions
are for RFC-compliant media types, andContentTypeOptions
are for thetext/html
bit of a full media type
and we have two corresponding smart constructors: makeMediaType()
and makeContentType()
.
Imagine we ship that, and everyone starts using it.
Let's say six months have passed, and end-users are complaining. They want to be able to set the character set of a new MediaType
.
This is how NOT to do it:
// in response to end-user complaints,
// we have added `charset` as an option
type MediaTypeOptions = OnErrorOptions & {
charset?: string;
};
// in a pull-request, this line of code will
// appear unchanged. But we HAVE changed
// the definition of `ContentTypeOptions`.
type ContentTypeOptions = MediaTypeOptions;
// `makeMediaType()` now applies a default option
// every time it is called
//
// this make break existing code
function makeMediaType(
input: string,
{
onError = THROW_THE_ERROR,
charset = "UTF-8"
}: MediaTypeOptions,
... fnOpts: FunctionalOption<MediaType>
): MediaType;
// `makeContentType()` will appear unchanged
// in a pull-request, but it has changed
function makeContentType(
input: string,
{ onError = THROW_THE_ERROR }: ContentTypeOptions,
... fnOpts: FunctionalOption<ContentType>
): ContentType;
Adding an extra field to MediaTypeOptions
has introduced (at least!) two side effects:
- We haven't just changed the options that
makeMediaType()
accepts. We've also changed the options thatmakeContentType()
accepts. That's not what we intended. That's a side effect. - We've forced
makeMediaType()
to always set acharset
parameter. That's another side effect.
What are the consequences of these side effects?
ContentTypeOptions
is not longer accurate. Anyone who reads it will believe that they can pass acharset
option intomakeContentType()
.makeContentType()
won't barf if they do ... it'll just ignore the option completely. It'll look like a silent error if anyone tries to use the new option there.- The end-user will waste time wondering why the option isn't working. Then they might waste your time by filing a bug report about it.
- Worse still, they might think your code is too buggy to trust, and they might stop using it entirely.
makeMediaType()
now always sets acharset
. That breaks backwards-compatibility. It will probably break someone's existing code.
In this example, the correct way to solve this problem is to create a suitable functional option:
// our two types DO NOT CHANGE
// from the original definition
type MediaTypeOptions = OnErrorOptions;
type ContentTypeOptions = MediaTypeOptions;
// our two smart constructors DO NOT CHANGE
// from the original definition
function makeMediaType(
input: string,
{ onError = THROW_THE_ERROR }: MediaTypeOptions,
... fnOpts: FunctionalOption<MediaType>
): MediaType;
function makeContentType(
input: string,
{ onError = THROW_THE_ERROR }: ContentTypeOptions,
... fnOpts: FunctionalOption<ContentType>
): ContentType;
// our MediaType changes, because it's a
// convenient place to stash our new
// functional option
class MediaType {
// the rest of implementation isn't import
// for this example
// this is a functional option
static public buildSetCharset(charset: string): FunctionalOption<MediaType> {
return (input: MediaType): MediaType => {
// `parse()` returns an object with
// the different parts of the MediaType
// in separate fields
const parts = input.parse();
// we update one of those fields
parts.parameters['charset'] = charset;
// we build a BRAND NEW MediaType
// and return it
return MediaType.fromParts(parts);
}
}
}
// we can now pass our functional option in
// when we make the media type
const myMediaType = new MediaType(
res.headers["content-type"],
{},
MediaType.buildSetCharset("us-ascii")
);
// and if we want to change the charset of an
// existing `MediaType`, that's easy too
//
// this is only safe because we've avoided
// a second side effect
const betterMediaType = MediaType.buildSetCharset("UTF-8")(myMediaType);
At first glance, this looks like a heavy-handed way to do this. Parsing the contents of a MediaType
is going to incur a runtime cost, no matter how fast our parser is.
It's worth it, because this code completely avoids all of the side effects we listed earlier on, and more!
- We don't accidentally change the definition of ContentTypeOptions,
makeMediaType()
is no longer forced to set thecharset
parameter all the time,- At runtime, in the function created by
MediaType.buildSetCharset()
, we don't change thecharset
parameter of the inputMediaType
parameter. We treat that input parameter as immutable.
Not only do we save time and frustration when the end-user is writing their code, we prevent surprises in production when data mysteriously gets corrupted.