immerjs / immerjs/immer

Branding Drafts on Type level

Open
#1,232 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

question
Dominant language
JavaScript
Stars
29k
Forks
881
PR merge metrics
No merged PRs in 30d

Description

🙋‍♂ Question

I was wondering if it would be possible to not just brand at runtime, but branded type at compile time as well?

Cause

I was using redux/toolkit, which uses Immer and had an issue where current(val) was called on a non draft, typechecker was fine, executed it in development, all good, came to production, tried to unwrap a non draft and crashed. Not a big deal, just use isDraft, but it was something technically avoidable if....

Implementation

I could make a PR, I just wanted to figure out if that is even desirable since I assume this question came up already some time ago and was decided against it for a reason to me currently unknown (except maybe that some types are already relatively complex)

Proof of concept

I made a small proof of concept on typescript playground relative quickly to just check out the branding, the idea would be that produce etc return Drafted<T> instead of T hiding a potential source for errors (naming still up for discussion)

I made a small code example on TS playground where I do not duplicate the objects, Proxy them etc for simplicity. If no link should be used, here the snipped:

const brand : unique symbol = Symbol("foobar")

type Draft<T> = T & {[brand]:never}
type MaybeDraft<T> = Draft<T> | T

const a = 32;
const b = {
    a: 32,
    b: "Hello world"
}
const c = [1, 2, 3];
const d = [a,b,c] as const

const draft = <T extends object>(val: T)=> {
    const clone = val
    Object.defineProperty(clone, brand, {configurable: false, enumerable: false, writable: false, value: true})
    return clone as Draft<T>
}


const isDraft = <T extends unknown>(val: T): val is Draft<T> => {
    //@ts-ignore
    if (val[brand]) {
        return true
    }

    return false
}


const B = draft(b)
const C = draft(c)
const D = draft(d)


console.log("A: ", isDraft(a))
console.log("B: ", isDraft(B))
console.log("C: ", isDraft(C))
console.log("D: ", isDraft(D))


/**
 * Accepts a Draft or a Value, but differentiates them on a type level to enforce handling of each of the 2 scenarios
 */
const doSomething = (value: MaybeDraft<number[]>) => {
    //const val = current(value) // Error because value could also not be a draft
    const val = isDraft(value) ? current(value) : value
    return val
}

/**
 * Accepts a Draft or a Value, it doesn't care if it might be a Draft and just uses it
 */
const doSomething2 = (value: number[]) => {
    return value
}

/**
 * Accepts only a Draft, non Drafts are refused on a Type Level at compile time
 */
const current = <T extends object>(value: Draft<T>):T => {
    return value
}

doSomething(C)
doSomething2(C)
//const neverHappend = current(c)
const val = current(C)

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the TypeScript Playground proof of concept and compare its branded Draft, MaybeDraft, current, isDraft, and produce-related behavior with Immer's existing type signatures. The issue does not name repository files or tests; done would require an agreed decision on whether compile-time branding is desirable and a defined implementation scope.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
developer-experience
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.