Boost TypeScript With Named Arguments In PureScript
Hey everyone! Today, we're diving into a super cool topic: how to make your PureScript code play even nicer with TypeScript when you're dealing with function argument labels. Specifically, we're looking at a neat trick to get named arguments in the TypeScript signatures that get generated from your PureScript code. This is awesome because it makes your code way more readable and easier to understand, which is always a win, right?
The Problem: Missing Argument Names
When you use tools to bridge your PureScript code to TypeScript, the generated TypeScript signatures sometimes don't include the names of your arguments. For instance, imagine you've got a PureScript function like setAge :: Store -> Int -> Unit. In TypeScript, this might translate to something like setAge: (store: Store, arg0: number) => void. See the issue? That arg0 doesn't tell you anything about what the number represents. Wouldn't it be great if it said age instead? That's where argument labels come in.
The PureScript Approach
Here's where the magic of PureScript and its type system shines. The idea is to use a Named type to wrap your arguments. This lets you specify the name of the argument right in the type signature. Here's a quick look at the core idea:
newtype Named (name :: Symbol) a = Named a
This creates a new type called Named, which takes a symbol (the name) and the original type (a). The cool part is how we can use this to annotate our functions.
The TsBridge Instance
Next, you'll need a way to tell the type bridge how to handle these Named arguments. This is done with a TsBridge instance, which defines how to translate the Named type into TypeScript. Here’s a simplified version:
instance (TsBridge a, TsBridge b, IsSymbol k) => TsBridge (((Named k a) -> b)) where
tsBridge = tsBridgeNamedFunction Tok
This instance tells the bridge that if it encounters a function that takes a Named argument, it should use the tsBridgeNamedFunction function to create the TypeScript signature. Inside tsBridgeNamedFunction, you'd do the real work of extracting the name and including it in the generated TypeScript code.
The tsBridgeNamedFunction Implementation
The tsBridgeNamedFunction is where the core transformation happens. This function takes care of extracting the argument's name from the Named type and then including it in the TypeScript signature. The following is the basic outline of what this function does:
- Extract Types: It uses
tsBridgeByto translate the wrapped argument and the return type into TypeScript types. - Get the Name: It uses
reflectSymbolto get the argument name. - Construct the Signature: It uses the name and the translated types to construct the final function signature, ensuring that the argument name is used instead of a generic name like
arg0.
tsBridgeNamedFunction
:: forall tok a b key
. TsBridgeBy tok a
=> TsBridgeBy tok b
=> IsSymbol key
=> tok
-> Proxy (((Named key a) -> b))
-> TsBridgeM DTS.TsType
tsBridgeNamedFunction tok _ = censor mapAccum ado
arg /\_ <- listen $ tsBridgeBy tok (Proxy :: _ a)
ret /\_ <- listen $ tsBridgeBy tok (Proxy :: _ b)
name = reflectSymbol (Proxy :: Proxy key)
in
DTS.TsTypeFunction []
[ DTS.TsFnArg
( DTS.TsName
( name
)
)
arg
]
ret
This function uses the TsBridge type class to convert PureScript types to TypeScript types. It then gets the name of the argument from the Named type and uses it in the generated TypeScript signature. This ensures that the generated TypeScript code is more readable and easier to understand.
Implementation Steps and Code Snippets
Alright, let's break down the implementation into more digestible chunks and show you some code snippets along the way.
1. Define the Named Type
As we saw earlier, this is the cornerstone. It's a simple wrapper that holds your value and the name you want to give it.
newtype Named (name :: Symbol) a = Named a
This allows us to wrap any type a with a symbol name. This symbol will be used as the argument label in the generated TypeScript code.
2. Implement the TsBridge Instance
This is where you tell the type bridge how to deal with your Named type. This is the crucial part that interfaces with the tool you use to generate your TypeScript definitions. This instance ensures that whenever the type bridge encounters Named k a, it knows how to handle it.
instance (TsBridge a, TsBridge b, IsSymbol k) => TsBridge (((Named k a) -> b)) where
tsBridge = tsBridgeNamedFunction Tok
This setup leverages the TsBridge type class, which acts as the interface for translating your PureScript types into their corresponding TypeScript counterparts. When the type bridge encounters a function with a Named argument, it utilizes tsBridgeNamedFunction.
3. Implement tsBridgeNamedFunction
This function does the heavy lifting: extracting the name and generating the correctly labeled TypeScript argument.
tsBridgeNamedFunction
:: forall tok a b key
. TsBridgeBy tok a
=> TsBridgeBy tok b
=> IsSymbol key
=> tok
-> Proxy (((Named key a) -> b))
-> TsBridgeM DTS.TsType
tsBridgeNamedFunction tok _ = censor mapAccum ado
arg /\_ <- listen $ tsBridgeBy tok (Proxy :: _ a)
ret /\_ <- listen $ tsBridgeBy tok (Proxy :: _ b)
name = reflectSymbol (Proxy :: Proxy key)
in
DTS.TsTypeFunction []
[ DTS.TsFnArg
( DTS.TsName
( name
)
)
arg
]
ret
This function is responsible for the actual translation. It uses functions like tsBridgeBy to convert the types and reflectSymbol to get the argument name. It then constructs the DTS.TsTypeFunction to build the TypeScript signature with the named argument.
4. Usage Example
Here's how you'd use it in your PureScript code:
setAge :: Store -> Named "age" Int -> Unit
setAge store (Named age) = unit
And the generated TypeScript might look like this:
setAge: (store: Store, age: number) => void;
See how clear and intuitive that is? The age label makes everything so much easier to understand.
Advantages of Named Arguments
Why bother with all this? Let's break down the benefits:
- Improved Readability: Named arguments make the purpose of each argument instantly clear. This is huge for anyone reading your code, including your future self.
- Enhanced Maintainability: When your code is easy to understand, it's also easier to maintain and update. You can quickly grasp what a function does without having to dig through documentation.
- Better Documentation: Named arguments act as a form of self-documentation. They provide context directly in the code, reducing the need for extensive comments.
- Reduced Errors: Clear argument names reduce the likelihood of passing arguments in the wrong order, a common source of bugs.
Potential Challenges and Considerations
While named arguments are super useful, there are a few things to keep in mind:
- Bridge Tool Compatibility: You need to use a type bridge that supports this kind of custom transformation. The example in the original post is based on m-bock and purescript-ts-bridge.
- Complexity: Adding argument labels can increase the complexity of your type signatures. It's a balance between clarity and conciseness.
- Tooling Support: Ensure that your IDE or editor properly handles these named arguments, providing helpful tooltips and code completion.
Conclusion: Level Up Your TypeScript
Using named arguments in your PureScript code, and having them translate into TypeScript, is a fantastic way to improve the quality of your code. It makes it easier to understand, maintain, and debug. It also enhances the developer experience when working with your code, and ultimately, helps you write more robust and reliable applications. I hope this helps you guys take your PureScript + TypeScript projects to the next level! Happy coding!